TCP sockets for the Nintendo 64, over a SummerCart64.
The N64 has no network hardware. It does have a cartridge port, and a
SummerCart64 in that port has a USB link to your computer. n64net turns that
link into real sockets: your ROM calls connect(), send(), recv(), and a
small bridge process on the host does the actual networking.
ROM ──► n64net ──► SC64 ──► USB ──► bridge.py ──► the internet
This is not an emulator trick. It runs on real hardware — an original N64 with a SummerCart64, or an FPGA console like the ModRetro M64 — and the packets are real packets.
N64 NETWORK CLIENT TEST
=======================
usb init OK SummerCart64
bridge ping OK 2920 us round trip
tcp connect OK h0 -> 127.0.0.1:9999
echo OK 14 B "HELLO FROM N64"
close OK clean
http get OK HTTP/1.1 200 OK (868 B)
That last line is an N64 making an HTTP request to a live host on the open internet, over IPv6.
| Cartridge | SummerCart64, firmware v2.20.2 or newer |
| Console | Original N64, or an FPGA console (tested on ModRetro M64) |
| Cable | USB-C data cable from the cart to your computer |
| Host | Anything running Python 3.10+ — laptop, Raspberry Pi, mini PC |
| Toolchain | libdragon (preview branch) — or just Docker |
The host machine is your network adapter. It needs to be connected to the console by USB and to have internet itself — so a Pi on your Wi-Fi works just as well as a laptop.
Charge-only USB-C cables are the single most common setup failure. They have no data lines. The cart's LED may still light up.
git clone https://github.com/rgloria/n64net
cd n64net/host
python3 -m venv .venv && .venv/bin/pip install -r requirements.txtCheck the cart is visible. sc64deployer must not be running — it holds the
same serial port:
.venv/bin/python -c "import sc64link; print(sc64link.find_port())"
# /dev/cu.usbserial-SC64xxxxxxWith libdragon installed:
export N64_INST=/opt/libdragon # wherever you installed libdragon
cd examples/hello-net
../../tools/build.shOr with no toolchain at all — only Docker. The first run builds a local image (a few minutes), every run after is fast:
cd examples/hello-net
../../tools/build-docker.shThen flash it:
sc64deployer upload --reboot hello-net.z64--reboot works because the example answers the cart's AUX messages — see
No more power cycling.
cd host
.venv/bin/python echo_server.py & # a TCP echo target
.venv/bin/python bridge.py --allow 127.0.0.1:9999 --allow example.com:80The console runs its six checks and prints the results. That's the whole loop.
#include "n64net.h"
n64net_init();
int sock = n64net_connect("example.com:80", 5000);
if (sock < 0) {
/* n64net_last_error() carries the bridge's explanation */
}
n64net_send(sock, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", 38);
char buf[512];
int n = n64net_recv_wait(sock, buf, sizeof(buf), 5000);
n64net_close(sock);Hostnames are resolved on the host, so "example.com:80" works with no
resolver on the console. In your game loop, call n64net_poll() once a frame —
the host's writes are discarded if the console does not acknowledge them within
one second.
Full API in n64/n64net.h.
Measured on a ModRetro M64. Read this before designing anything — it is the constraint that shapes every decision.
RTT_ms = 2.3 + bytes / 9.56
A 2.3 ms fixed overhead per exchange, plus 19.1 bytes/ms of bandwidth shared across both directions (≈18.7 KiB/s, ≈153 kbit/s).
| Cadence | Frame budget | Round-trip bytes |
|---|---|---|
| 60 Hz | 16.67 ms | 137 B |
| 30 Hz | 33.33 ms | 296 B |
| 10 Hz | 100 ms | 933 B |
Latency is not your problem. 2.3 ms of cart overhead is noise beside a 20–80 ms internet round trip. The tether will not be what makes your game feel laggy.
Bandwidth is your problem. 18.7 KiB/s is roughly ISDN, shared both ways.
Comfortably affordable:
- Controller state is 4 bytes per pad — four players is 16 B, a tenth of one frame's budget
- Rollback netplay with a ~100 B state delta per frame
- Lobbies, matchmaking, leaderboards, presence, chat
- Save and config sync
Out of reach:
- Streaming audio, textures, or level data
- Any per-frame payload past a few hundred bytes
- Bulk downloads during play — move them to a loading screen and budget ~18 KiB/s
Design rule, straight off the measurements: batch small messages rather than sending them individually, and keep bulk transfers near 1 KiB. At 4 bytes per exchange the link runs at 3.3 KiB/s — 82% of capacity lost to fixed overhead. By 1 KiB you are within 3% of the ceiling.
Measure your own console with examples/echo-benchmark — an FPGA console
emulates PI timing rather than reproducing it, so its numbers are its own. See
docs/link-budget.md.
examples/netplay-pong is two N64s playing each other:
N64 A -- SC64 -- bridge.py --\
relay.py
N64 B -- SC64 -- bridge.py --/
It uses delay-based lockstep, which is the model this link can afford. Only inputs cross the wire, never game state — both consoles run the same deterministic simulation over the same inputs and arrive at the same world. That is 6 bytes per frame instead of a world snapshot, against a budget of ~137 bytes round-tripped per frame.
Local input is applied INPUT_DELAY frames in the future, giving the peer's
input that long to arrive. The first few frames use neutral input on both
sides, so a match self-starts with no handshake.
Determinism is the whole game. The simulation is integer-only: no floats, no
uninitialised state, and the RNG advances only inside the simulation step so
both consoles consume identical values in identical order. The two sides
exchange a state checksum every 60 frames and print DESYNC if they ever
diverge, rather than quietly drifting into different matches.
On the machine hosting the match:
python3 host/relay.py --port 7777Then on each console's host, pointing the ROM at the relay:
cd examples/netplay-pong && ../../tools/build-docker.sh RELAY=192.168.1.50:7777
sc64deployer upload --reboot netplay-pong.z64
python3 host/bridge.py --allow 192.168.1.50:7777host/virtual_player.py joins as the second player, so you can run the whole
netplay path with one console:
python3 host/relay.py &
python3 host/virtual_player.py --style sweep # or idle, or jitterIt is an input source, not a second simulation — it does not model the ball, so it cannot chase it, and it cannot detect desync. Only two real consoles can do that.
Verified at a sustained 60 fps with a console and a virtual player, and the match recovers on its own when a peer drops and rejoins.
Normally, flashing a new ROM means reaching for the power switch. sc64deployer upload --reboot avoids that, but only if the running ROM answers the cart's AUX
messages. n64/sc64aux.c does:
sc64aux_init(on_halt); /* once at startup */
while (1) {
sc64aux_poll(); /* once a frame; does not return on Reboot */
...
}Then upload --reboot halts your ROM, overwrites it, and restarts the console
by itself. The hello-net example does this, and it works.
The reboot reloads IPL3 from ROM and re-enters it, declaring an NMI-style reset
so IPL3 leaves RDRAM alone. If your ROM uses a CIC other than 6102/7101, call
sc64aux_set_cic_seed() first.
n64/ N64-side library
n64net.[ch] sockets over the SC64 USB channel
sc64aux.[ch] AUX messages + soft reboot (independent of networking)
host/ host-side, pure Python
sc64link.py SC64 serial protocol
netproto.py bridge wire protocol + codec
bridge.py the bridge itself (--selftest needs no hardware)
echo_server.py TCP echo target for testing
relay.py match relay for netplay-pong
virtual_player.py stand-in second player (one-console setups)
benchmark.py measure your link
stall_probe.py large-transfer stall hunt
examples/
hello-net/ six-step end-to-end test
echo-benchmark/ echo ROM for benchmark.py
netplay-pong/ two consoles playing each other
docs/
protocol.md wire protocol specification
link-budget.md measured performance and methodology
Test the bridge with no console attached:
cd host && .venv/bin/python bridge.py --selftestbridge.py opens TCP connections from your computer on the console's
instruction. A ROM could otherwise reach anything your machine can, including
your LAN — so the bridge refuses to start without an explicit allowlist:
$ bridge.py
bridge.py: error: no destinations permitted.
bridge.py opens TCP connections FROM THIS MACHINE on the console's
instruction, so it will not start without an explicit allowlist:
bridge.py --allow example.com:80 --allow 127.0.0.1:9999
--allow HOST:PORT is repeatable, and each value is validated at startup —
a missing port, a non-numeric port, or one outside 1–65535 is rejected before
the bridge opens anything. Hosts are compared case-insensitively, so
EXAMPLE.COM:80 cannot slip past an entry of example.com:80. IPv6 literals
must be bracketed: [::1]:9999.
To lift the restriction entirely — only sensible for a ROM you wrote yourself:
bridge.py --allow-any # logs a warning on every start- The host must be attached. This is a tether. An FPGA console's own Wi-Fi is not reachable from the N64 side of the machine, so it cannot help here.
- Existing commercial games cannot use this. They have no networking code; a transport cannot add a capability the ROM does not have.
- Docker on Apple Silicon / ARM. The official libdragon image is published
for
linux/amd64only, so a plaindocker pullfails with "no matching manifest".build-docker.shpasses--platform linux/amd64and runs it under emulation, which works but makes the first build slow. Note also that the official image ships only the cross-toolchain — libdragon itself is not in it, which is whybuild-docker.shderives its own image. - Occasional stalls above ~16 KiB. The host FTDI driver sometimes drops the
stream mid-transfer, always on an exact FTDI packet boundary.
SC64Linkdetects it and reopens the port automatically. Roughly 1 in 137 large transfers; irrelevant at networking payload sizes. usb_write()reports phantom timeouts. libdragon'sSC64_WRITE_TIMEOUTis 100 ms, so any write over ~1.9 KB returns-1even though the data arrives intact.n64netaccounts for this; if you call libdragon's USB API directly, only a0return is a real failure.
Built on libdragon and SummerCart64 by Polprzewodnikowy. The SC64 register map and AUX protocol come from that project's excellent documentation.
No code from SummerCart64 (GPL-3.0) or N64FlashcartMenu (AGPL-3.0) is included
here — sc64aux.c was written against the published register documentation, and
the IPL2 exit register contract it relies on is documented in libdragon's
Unlicense boot_trampoline.S.
MIT — see LICENSE.