Network multiplayer¶
The design of Coney's networked multiplayer, and what of it is built. It is the Online
multiplayer milestone, which the maintainer started on 2026-10-20. The original
has no network play, so nothing here reimplements the original: none of this code carries an @orig tag, and the
game offline runs exactly as before. This page is kept current: a change to the protocol, a layer or a plan updates
it in the same commit.
The architecture this builds toward (the original's own two-player co-op online, then dedicated and scripted servers), what every subsystem must sync and the checklist every engineer follows are on Network architecture.
Built: the platform-neutral library coney_net (bit stream, transport interface, loopback and link conditioner,
protocol, the server and client sessions with their handshake, keep-alive and timeouts), the ENet transport, and the
game's side: hosting and joining from the debug menus' Network page or the command line (In the game),
and the guest's pad as the host's pad port 2 (Player two). The guest does not see the host's game yet.
Next: the layers under The plan.
The model¶
- A listen server. One player hosts: the server runs inside the host's own game, which simulates the one true game, and the other players join it as clients. No separate server program yet; the session code does not need one, so a dedicated server later is a front end, not a rewrite.
- Server-authoritative. Only the server simulates. A client sends what its pad does, never where its character is; the server applies it on the step it belongs to and sends the result back. A client cannot move another player, teleport or decide a hit.
- Input up, snapshots down. Clients send input commands: the pad samples of each step, tagged with the step number, several recent steps per packet so a lost packet costs nothing. The server sends snapshots: the state of the entities a client needs, quantized, delta-compressed against the last snapshot that client acknowledged, each entity named by a network id.
- The client shows the past, smoothly. A client keeps a short jitter buffer of snapshots and draws the world interpolated between two of them, slightly behind the newest, and runs its own follow camera. Client-side prediction of the local player comes later; until then the local player shows the server's round trip.
- Fixed steps, no clock. The network code counts time in the game's fixed steps (30 a second,
The play tick) and, where it needs nanoseconds (latency, round trip), in the elapsed
time the main loop already measures with its frame pacer. It never reads a clock itself (the
no-real-clockpre-commit check coverssrc/net/as it covers the rest ofsrc/), so a test drives it frame by frame. - Single-threaded. The main loop polls the network without blocking, once a frame, beside the pad. No network thread, no locks.
The layers¶
| Layer | Where | What it does |
|---|---|---|
| Bit stream | src/net/bit_stream.h |
bits, varints, zig-zag, quantized floats, strings; strict reads |
| Transport | src/net/transport.h |
the interface every packet goes through: connect, listen, send with a delivery kind, poll |
| Loopback | src/net/loopback_transport.h |
an in-memory network for the tests: several machines in one process |
| Link conditioner | src/net/link_conditioner.h |
latency, jitter, loss, duplicates and reordering in front of any transport |
| Protocol | src/net/protocol.h |
the messages, their ids and exact encoding, the protocol version |
| Link quality | src/net/link_quality.h |
round trip and loss from the sessions' own pings |
| Sessions | src/net/net_server.h, net_client.h |
the handshake, slots, keep-alive and timeouts of each side |
| ENet | src/platform/enet_transport.h |
the transport over UDP |
coney_net is platform-neutral: no socket, no ENet and no operating-system code, the same rule as the rest of
src/ (AGENTS.md, Layout). ENet is used only in src/platform/.
Bit stream¶
Every message is written with BitWriter and read with BitReader. Bits are packed least significant first into
bytes, and the last byte is padded with zero bits. What it offers:
- whole numbers of 1 to 32 bits (
writeBits, and byte, 16- and 32-bit shorthands); - varints: 7 bits and a "more follows" bit per group, so a sequence delta or a count under 128 costs 8 bits; signed ones zig-zag mapped (0, -1, 1, -2, ...) so a small negative number is as cheap;
- quantized floats: a value clamped to a range and rounded to
bitsbits, ends exact, within half a step; a position in a 2 km level to 1 cm needs 18 bits a coordinate instead of 32; - exact 32-bit floats and length-prefixed strings with a maximum length.
Writing our own state cannot fail: a value out of its range is a programmer error (CONEY_ASSERT). Reading is
strict, since the bytes come from the network: every read returns std::expected, a read past the end is
Truncated, a varint over 64 bits or a string over its limit is Invalid (checked before allocating), and
finish() refuses trailing bytes and set padding bits.
Transport and delivery¶
Transport is what a socket library must offer, as an interface: listen, connect, send(peer, delivery,
bytes), advance(elapsedNs), poll() (never blocks), flush, disconnect, boundPort and byte and packet
totals. Connections are named by a PeerId the transport never reuses. Events are Connected, Disconnected and
Received.
Delivery¶
| Delivery | Guarantee | Used for | ENet |
|---|---|---|---|
| ReliableOrdered | exactly once, in order among reliable packets | handshake, Bye; later game events, mission state | ENET_PACKET_FLAG_RELIABLE, channel 0 |
| UnreliableSequenced | at most once; one older than a packet already delivered is dropped | later: snapshots | no flag, channel 1 |
| Unreliable | at most once, any order | Ping, Pong, PadInput (sent again every step anyway) | ENET_PACKET_FLAG_UNSEQUENCED, channel 1 |
An unreliable packet too big for one datagram also gets ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT: without it ENet
resends a big unreliable packet's fragments reliably, so a lost snapshot fragment would stall the channel instead of
costing one snapshot.
Loopback¶
LoopbackNetwork and LoopbackTransport are an in-memory network: transports on one network connect to each other
by a name and a port, as machines on a real one do, so a test runs a server and several clients in one process with
no socket. A packet goes straight into the other end's queue: no loss, no delay, in send order. Connecting where
nobody listens, or to a full listener, fails as a refused connection does.
Link conditioner¶
LinkConditioner sits in front of any transport and makes what this end sends arrive late (latency, plus jitter
drawn per packet), never (loss), twice (duplicates) or out of order (a packet held back a further delay). Conditions
on one end add their latency to the round trip once; set both ends for a symmetric link. The delivery contracts
hold under it: a reliable packet is only delayed and never overtakes another reliable one (a real transport resends
what is lost, which looks like delay), and a sequenced packet that would arrive after a newer one is dropped. It
draws from its own seeded xorshift generator, never from the game's random numbers (GameRandom), so network
conditions never change the game, and a test's conditions are the same on every run. The tests use it in front of
the loopback; the Network page will put it in front of ENet.
Protocol¶
Each message is one packet: a message id byte, its fields, zero padding to a whole byte. kProtocolVersion (now 2)
changes with any change to a layout or a meaning; a test pins the encoding byte for byte so a change cannot slip in
unnoticed.
| Id | Message | Direction | Fields | Delivery |
|---|---|---|---|---|
| 1 | Hello | client to server | magic CONY (32 bits), protocol version (16), player name (string, 32 bytes at most) |
reliable |
| 2 | Welcome | server to client | player slot (8), step rate in Hz (16), server name | reliable |
| 3 | Reject | server to client | reason (8): version mismatch, server full, bad name; server's version (16) | reliable |
| 4 | Bye | either | reason (8): quit, timed out, protocol error | reliable |
| 5 | Ping | either | sequence (varint, 32 bits at most) | unreliable |
| 6 | Pong | either | the Ping's sequence | unreliable |
| 7 | PadInput | client to server | the client's step (varint, 32 bits at most), its pad (below); since version 2 | unreliable |
PadInput's pad is connected (1 bit) and, only when connected, the button word (16 bits, the PS2 layout of
core/pad.h), the four raw stick bytes (right x, right y, left x, left y) and, for each of the 12 pressure bytes, a
bit saying whether it is above 0 followed by the byte when it is. A pad with nothing held costs 61 bits; a
disconnected one, 1 bit, and decodes as a pad at rest. A pressure flagged as above 0 but written as 0 is refused, so
each pad has one encoding.
Decoding is strict. An unknown id or enumerator, the wrong magic, a short packet, a name over its limit or trailing bytes is an error, and the receiving session closes the connection with Bye (protocol error). The one exception keeps versions apart cleanly: Hello's magic and version keep their place in every version, and a Hello of another version decodes to its version alone, so the server can always answer "version mismatch" rather than misread it.
A player name is 1 to 32 printable ASCII characters (space to tilde), not all spaces.
Sessions¶
NetServer and NetClient are the state machines over a transport. Each step, the main loop calls receive()
before the simulation step (every waiting event and message, then the step's timeouts) and send() after it (the
pings that are due, then a flush), and advance(elapsedNs) once a frame.
The handshake. The client connects and sends Hello. The server answers in this order of what the player can do
about it: another protocol version (Reject, version mismatch), a bad name (Reject, bad name), no free slot (Reject,
server full), else Welcome with the lowest free slot (1 to maxClients; the host is player 0) and its step rate,
and reports the player Joined. After a Reject the server closes the connection; the reason travels in the Reject.
The server accepts two transport connections more than it has slots, so a player over the limit still hears
"server full" rather than nothing.
Client states: Idle, Connecting, Handshaking (Hello sent), Connected (Welcome received), Disconnected, with why: left, unreachable, timed out, rejected (and the reason), the server closed it (and its Bye reason), connection lost, protocol error. A client can connect again after a session ends.
Keep-alive and timeouts, in steps. Each side pings the other every 6 steps (5 a second at 30 Hz) once joined and answers every Ping with a Pong. Defaults:
| What | Steps | At 30 Hz |
|---|---|---|
| Ping interval | 6 | 0.2 s |
| A joined peer silent for | 300 | 10 s |
| No Welcome (client) or no Hello (server) within | 150 | 5 s |
A side that times the other out says Bye (timed out) and closes; the server reports the player Left. A Bye from a
client is reported as Left with its reason; a connection the transport drops with no Bye as Left with
connectionLost.
Link quality¶
LinkQuality measures each connection from the sessions' own Ping and Pong, so the figures are the same over ENet
and over the loopback: the round trip smoothed as TCP does (the newest answer weighs an eighth), the newest round
trip, and the loss: of the last 32 pings, those at least 8 pings old that were never answered. Late, duplicate and
forged answers are ignored.
ENet¶
ENet 1.3.18 (MIT) is the UDP transport, approved by the maintainer on 2026-10-20. It is
fetched at the tag's commit by cmake/deps.cmake and compiled from its C files into a static library, as Dear ImGui
is, linked by src/platform/ alone (Building and testing). It gives reliable and sequenced
channels, fragmentation, resends and its own connection timeouts.
EnetTransport polls with no wait. ENet keeps its own time for its resends and timeouts, the one clock in network
play Coney does not feed; advance() is ignored. A server listens on every interface by default; the tests listen
on 127.0.0.1 only, so no desktop firewall asks about the test program. The default port is 27615 (UDP).
Closing without losing the Bye. ENet throws away a peer's packets not yet handed to the program when that peer's
close arrives, so a transport destroyed straight behind the Bye lost it, and the other game read a player who quit as
one whose connection was lost (seen on the Network page's log, 2026-10-21). So a session that ends never takes its
transport with it: NetPlay keeps the transport and its link conditioner going, serviced once a step and never
waited on, until Transport::closed() says every connection is gone (after the far end acknowledged what was sent
before the close), for at most 15 steps (NetPlay::kCloseSteps, half a second; Coney's choice), then destroys it,
which closes what is left at once. Hosting again on the port a closing host still holds closes that host at once. At
the end of a run, after the last frame, NetPlay::shutdown() does the same with a short real wait: at most half a
second (kQuitWaitNs) of the time the wait it is given reports (a FramePacer at 100 frames a second in the game, a
fixed step in the tests), and only while something is closing.
In the game¶
NetPlay (src/platform/net_play.h) is network play in the running game: it owns the session (a NetServer when
hosting, a NetClient when joining), a fresh transport per session with a link conditioner in front, and the
settings. Its figures and simulated network are net::NetStatus and net::NetSimulation (src/net/net_status.h).
The Network page (src/debug/pages_net.cpp, The debug menus) is defined over
debug::NetControls (src/debug/net_controls.h), so it needs no platform code, as the Audio page over
AudioControls; NetPlayControls (src/platform/net_play_controls.h) turns NetPlay's own API into it, on the debug
tools' side (The debug tools and the game), so a build without the debug tools still has
network play.
The main loop. FrameHooks (src/gamemodes/game_mode_stack.h) has beforeStep and afterStep, called around
every due step, a step the pause holds included, so a paused host keeps its players. Network play receives in
beforeStep, before the step's pads are read, so a guest's pad that has arrived is in the same step's samples; it
sends and flushes in afterStep, so a step's result leaves in the same frame. Its time is the
frame time the frame pacer measured, passed on once a frame; in test mode, which has no clock, each step passes a
fixed 1/30 s, so a run is the same every time.
The Network page: the status line; Player name (also the server's name when hosting), Port (default 27615; 0 is
any free port) and Address (ADDR or ADDR:PORT); Host (every interface), Join and Disconnect; the ping (plotted
as Network/Ping (ms); a host shows its slowest player's), the loss, bytes and packets sent and received; the
players with their ping and loss; a Simulate network submenu (latency, jitter, loss, duplicates, reordering on
what this game sends, live); and a log of what happened, also printed as net: lines.
The command line: --host [[ADDR:]PORT], --join ADDR[:PORT] and --net-name NAME
(Running Coney).
Player two¶
A guest plays the host's game as player 2 on pad port 2: their own player 1 pad, sampled by their game each step, is what the host's game reads on port 2, exactly as a second pad plugged into the host's machine would be. Nothing in the game knows the pad is remote, so whatever a second local pad does, a remote one does too, and gameplay stays the original's.
- Two ports, one remote player. The PS2 has two pad ports, and the original reads only pad records 0 (port 1) and
4 (port 2, first multitap slot) (confirmed (code) at
0x001454a8, Pad record), so its games have two players at most. A host therefore takes one remote player (ServerConfig::maxClients1 inNetPlay); a second is told the server is full. The guest holds slot 1, which is port 2. Port 1 is always the host's. - The path of a pad. On the guest,
NetInput(src/platform/net_play.h) sits between the debug menus' input gate and the mode stack: port 1 as the guest's game reads it (never their debug menu's input, which the gate keeps) goes to the session, which sends it as PadInput at the end of the step, numbered by the guest's step. On the host the server keeps each player's newest pad by that number (a late or repeated packet never replaces a newer one), and the host'sNetInputputs it on port 2. On a link with no delay a press reaches the host's game in the host's step after the one in which the guest's game saw it; over a network, add the one-way delay. - The host's own port 2 plays while nobody has joined, and again once the guest leaves; while they are joined it is set aside (Coney's choice: one pad per port).
- Until the first PadInput arrives, port 2 reads unplugged; a guest with no pad plugged in sends an unplugged pad, and port 2 stays unplugged.
- A stalled link lets go. When no new PadInput has come for more than 15 steps (half a second;
ServerConfig::padHoldSteps), port 2 reads plugged in with nothing held and the sticks centred, rather than holding the last buttons until the 10 s timeout drops the player (Coney's choice). The next PadInput takes over again. - What a second pad does in Coney today. The front end's two-player screens read port 2: the Story's number of
players (
PM_NumPlayersand its "player 2 press START"; the main menu first counts the connected pads, Profile manager) and the Rumble mode's (RM_NumPlayers, No 2nd Controller, Choose Gangs' second side). In play, the original lets a second pad join whenCfgMultiplayerJoinallows it:GameState_SyncPlayers(0x0041a460) makes the gang's next Warrior player 2 and calls the multiplayer callback (level99). Coney has one player in play so far (PlayerState::multiplayerCallbackis kept, not called), so a remote pad 2 joins a level only once that two-player sync is built; nothing in network play has to change for it.
Compared with the original (2026-10-21). In the original, PCSX2 2.9.129 (the PCSX2-MCP fork) with a DualShock 2
on port 2 and both driven through the debug server's pad: from a cold boot, both pad records read connected
(+0x4c of records 0 and 4 at 0x005dd810); STORY shows 1 PLAYER : 2 PLAYER; 2 PLAYER and cross shows "Player 2
press the START button"; START on port 2 goes on to the Profile Manager. Confirmed (runtime). Coney, a host on the
disc (--skip-movies, an input script on port 1) and a guest joined over ENet on 127.0.0.1 whose --pad-pipe pad
pressed START: the host went PM_Mode, PM_NumPlayers, then PM_Profile after the guest's START, showing the same
three screens; the host alone went from PM_Mode straight to PM_Profile. The network carrying the pad has no
counterpart in the original.
This is the plain form of the next layer: The plan's input commands replace the one pad per packet with the last few steps' pads, and a buffer on the host that applies each on its own step.
Testing¶
Everything above EnetTransport is tested on the loopback, frame by frame with no real time, in tests/net/: the
bit stream's encodings and refusals, the loopback, the link conditioner's contracts and determinism, the protocol's
round trips, pinned bytes and refusals, link quality, and the sessions (handshake, slots, refusals, leaving, every
timeout, protocol errors, and the round trip and loss through a conditioned link). tests/platform/
enet_transport_test.cpp connects two ENet transports on a free port of 127.0.0.1, sends every delivery kind and a
fragmented packet, closes, checks that a transport kept going until closed() delivers its Bye before its close,
and runs the sessions' handshake over real UDP. tests/platform/net_play_test.cpp hosts
and joins between two NetPlays on the loopback (by address, the figures, the simulated latency in the ping,
leaving and stopping, a leave over a slow link, a close that never completes, hosting again on a closing host's
port, shutting down, refusals, the guest's pad on the host's port 2 and the host's own port 2 around it, one remote
player at most); tests/net/sessions_test.cpp also covers the server's side of the pad (by slot, newest step wins, a
pad before the handshake, letting go of a quiet pad); tests/net/protocol_test.cpp pins PadInput's bytes;
tests/gamemodes/game_mode_stack_test.cpp checks that beforeStep comes before the pads are read;
tests/debug/net_page_test.cpp drives the Network page over a fake
NetControls; and the coney.hosts_headless smoke test runs coney --headless --frames 3 --host 127.0.0.1:0.
The plan¶
Each step is a pull request that builds and is tested on its own.
- Foundation (built): this page, ENet,
coney_netand its tests. - The Network page and flags (built): In the game.
- A second player (built): the guest's pad on pad port 2 of the host's game (Player two).
- Input commands: the client's pad samples, tagged with the step, with the last few steps repeated in every packet; the server's buffer that applies each on its step.
- Replicated players: network ids, snapshots (quantized, delta-compressed against acknowledged baselines), the client's jitter buffer and interpolation, and its own follow camera.
- AI humans, events, mission state: the rest of what a client must see and hear, events reliable.
- Prediction: the local player predicted and corrected from the server's snapshots.