Skip to content

Networking

Layer: kernel-side (good_net)

A 3D game needs the same session and messaging plumbing a 2D one does, so networking sits beside the kernel instead of under a renderer.

Networking is the command API, over a socket. A network message and a GameCommand are the same thing — a typed record, declared once, identified on the wire by its position in that declaration, handed to a handler registered where it runs. So they are not two implementations: NetMessage and NetSignal are spelled exactly like SinkCommand and SignalCommand, and the record layer underneath is the kernel's own, reused instead of reimplemented.

class MyState extends GameState2D<MyGame> with MultiplayerState<MyGame> {
  late final Fire fire;

  @override
  void describeNetwork(NetDescriptor descriptor) {
    descriptor.transport(LoopbackNetTransport());
    fire = descriptor.has(Fire(), id: 'fire', channel: NetChannel.unreliable);
    descriptor.hasHandler(fire, _onFire);
  }

  void _onFire(({double angle}) params, NetPeerId from) {
    spawnBullet(from, params.angle);
  }
}

The id is the protocol, not the class name

Every message declares an id, and it is the thing two peers actually agree on. The handshake hashes it — along with each message's layout, target and channel — and refuses a peer whose hash differs, which is what stops two builds forming a session over bytes they will read differently.

It is a string you choose, and the only rule the engine enforces is that no two messages in one game share one. Both halves of that are checked where you declare them, not at a handshake.

Rename the class freely. Fire can become FireCommand without touching the protocol, because the id did not move. That is the point: a refactor should not be a wire change.

The alternative — hashing the Dart class name — is what this replaced, and it failed in two ways that look like nothing from the code. A rename broke every peer. And --obfuscate, which release builds use, rewrites type names outright: measured on a Windows release build, PlayerInputMessage became zl, so an obfuscated client computed a different hash from a plain server built from the same source and the two refused each other.

Change an id when the wire format changes and you want old peers turned away — a field added, a width widened, a meaning altered. 'fire' becoming 'fire.v2' is a deliberate break, which is the only kind worth having. Send it by calling it:

fire((angle: 1.2));

What networking adds over commands

Two facts an isolate boundary does not have, and both are declared instead of passed at the send site — so a message's whole contract is readable in one place, and it cannot be sent reliably in one file and unreliably in another.

NetTarget — which machine handles it

Target Handled by Sent by For
host The host Anyone A client's intent: "I pressed fire", "I want to buy this"
clients Every client Host only The host's decisions: "you died", "the door opened"
everyone Every client and the host Host only A decision the host must react to through the same path

NetTarget.host is the workhorse of an authoritative game, and it has one property worth spelling out: calling one on the host runs it locally instead of failing. That is what makes single-player, host and client one code path — the firing code says fire((angle: a)) and does not care which machine it is on.

clients does not run on the host: the host already knows, it is the one that decided. Use everyone when the host must react through the same code path, so host and client visibly agree instead of agreeing by two implementations that drift.

NetChannel — how hard to try

descriptor.has(PlayerMoved(), id: 'playerMoved', channel: NetChannel.unreliable);
descriptor.has(RoundEnded(), id: 'roundEnded', channel: NetChannel.reliable);
Channel Guarantee For
reliable Arrives exactly once, in order, however many retransmissions it takes Anything a game cannot resolve by waiting: "player joined", "you took 12 damage", chat, the initial snapshot
unreliable Sent once, may be dropped or reordered; an older one arriving after a newer one is discarded State that supersedes itself: transforms, input samples — anything sent every tick where only the newest value matters

Do not send transforms reliably

Head-of-line blocking is reliable delivery's price: one lost packet stalls everything queued behind it until it is resent. For a position snapshot that means waiting for data which was already obsolete when it was lost. Losing an unreliable packet costs a tick of smoothness; waiting for its retransmission costs far more.

Two channels, not a per-message tunable policy, because these are the two that game netcode actually needs and every extra one costs a receiver-side reassembly structure that must be paid for whether or not a game uses it. It is the same split ENet, Steam Sockets and QUIC's stream/datagram divide use.

How big one message may be

A field declared hasString() or hasBytes() has no capacity of its own — the size of a record is decided by the value written into it — so what bounds it is the backend, and the backend says so:

good_net_p2p LoopbackNetTransport
reliable 300,390 bytes (255 datagrams, put back together) 261,120
unreliable 1,178 bytes (one datagram) 1,024

Writing more than that into one message throws, at the call that wrote it, naming the number. It throws in a game with nobody connected too — a message that only fails once somebody joins is a message that fails in front of a player.

A tick's worth of messages is not bounded by this

A frame's messages are packed into one batch per channel, and a busy frame's batch is regularly longer than any one message. That is cut at a record boundary and sent in pieces, so a hundred position updates in one tick cost a hundred records and not a refusal. Only a single record over the ceiling has no answer of that shape.

The unreliable channel does not fragment, and that is deliberate

A message cut into N pieces with nothing retransmitting them is lost whenever any one of the N is, so its loss rate is the link's multiplied by N — and "losing one costs a tick of smoothness" stops being true. There is nothing to gain by it either: this channel carries state that supersedes itself, so a value that does not fit this tick will not fit on the next one, and the failure repeats rather than being absorbed. Send it reliable if it has to arrive, or split it into messages that each stand alone and each supersede on their own.

Loopback's ceilings are lower than any real backend's on purpose. It has no wire and therefore no bound of its own, and a backend that silently accepts what another refuses is a backend that hides bugs — so a message loopback takes is one a real backend takes too. Both are constructor arguments if a test wants a smaller one to aim at.

Declaring messages

class Fire extends NetMessage<({double angle})> {
  late final ParamPointer<double> angle;

  @override
  void describeParams(ParamDescriptor descriptor) {
    angle = descriptor.hasFloat32();
  }

  @override
  void bufferFromParams(ParamBuffer message, ({double angle}) params) {
    angle[message] = params.angle;
  }

  @override
  ({double angle}) paramsFromBuffer(ParamBuffer message) {
    return (angle: angle[message]);
  }
}

Identical in shape and vocabulary to a GameCommand — same ParamDescriptor, same packing rules, same "keep the handle in a late final field", and the same answer for more than one parameter: P is a single type, so several values travel as a Dart record.

typedef Shot = ({double angle, double power, bool charged});

class Fire extends NetMessage<Shot> { /* ... */ }

fire((angle: 1.2, power: 0.8, charged: true));

Worth a typedef: the record type appears in three signatures here, so naming it once means a new field is one edit, not three. See commands for the full walk-through — it is the same mechanism.

NetSignal is the no-parameter shape:

class RoundEnded() extends NetSignal;

// declared
roundEnded = descriptor.has(RoundEnded(), id: 'roundEnded', to: NetTarget.everyone);
descriptor.hasSignal(roundEnded, _onRoundEnded);

// sent
roundEnded();

Sending to one peer

fire.sendTo(peerId, (angle: 1.2, power: 0.8, charged: true));

Nobody to send to is not an error

A send with no session, or with nobody on the other end, returns without doing anything. A game that fires a shot while waiting for a second player has not made a mistake, and neither has a host with no clients yet.

Sessions

Mix MultiplayerState into your GameState and you get network:

final session = await network.host(SessionOptions(name: 'My Game', maxPeers: 4));
print(session.id);                       // the join code

await network.join(SessionId('ABCD12'));
await network.leave();

NetSession is the roster:

Member What it gives
id The join code, stable for the session's life
localPeer This peer's own id — NetPeerId.host when hosting
isHost Whether this machine is the host
peerCount / peerAt(i) The roster, by index
hasPeer(id) Whether a peer is still on the roster
connectionTo(id) The direct link, or null — always null between two clients
sendToAll(...) Every peer with a direct link

Indexed roster access, not a list

peerAt(index), not a List<NetPeerId> getter, so walking the roster every tick allocates neither a list nor an iterator. Roster order is unspecified and shifts as peers come and go — index into it within one tick only.

Events

class Lobby extends GameSystem with NetPeerListener, NetSessionListener {
  @override
  void onPeerJoined(NetPeerId peer) { }
  @override
  void onPeerLeft(NetPeerId peer, NetDisconnectReason reason) { }
  @override
  void onSessionOpened(NetSession session) { }
  @override
  void onSessionClosed(NetDisconnectReason reason) { }
}

Teardown events dispatch in reverse, matching every other teardown event in the engine: a listener told late can still read what the earlier ones were warned about.

Version safety

A message's identity on the wire is its position in describeNetwork's declaration order — two bytes at the head of the record. Both peers agree on it because both ran the same declaration pass, which holds only while both are running the same build.

NetTransport.schemaHash is what enforces that: the registry is sealed and hashed at boot, the hash is bound to the transport, and a peer running a different build is refused at join instead of silently misrouting messages into the wrong handlers.

Backends

NetTransport is the contract a backend implements. Exactly one is declared per game:

descriptor.transport(LoopbackNetTransport());

A game that wants a loopback backend in tests and a real one in a build passes a different instance here — nothing else in the game changes, because messages are declared against the game instead of against a backend.

Loopback — in good_net itself

LoopbackNetTransport is in-process and real, not a mock. It is what tests and split-screen run on, and it is why a multiplayer game can be developed without a second machine.

package:good_net/testing.dart ships a conformance suite that every backend is tested against, so "implements NetTransport" means the same thing for all of them.

good_net_p2p — the serverless backend

The "nothing to host, no bill" path, for a game that reaches another machine.

descriptor.transport(P2PNetTransport());

Hosting binds a UDP socket and hands back a ten-character code that is the host's address. Joining decodes it and starts talking. There is no broker, no relay, no account, nothing to deploy and nothing that can go down.

final session = await network.host(SessionOptions(name: 'Kitchen table'));
print(session.id);          // e.g. 4KM2QX9P7T — read this out to a friend

await network.join(SessionId('4KM2QX9P7T'));

On a LAN nobody has to type anything at all: a host announces itself once a second, and network.discover() lists what is out there.

for (final found in await network.discover()) {
  print('${found.name}${found.peerCount}/${found.maxPeers}');
  await network.join(found.id);
}

How far a code reaches

A code carries the address it was minted from, so it reaches exactly as far as that address does. Same machine and same LAN work today, with no setup. Across the internet does not yet. A home router hands out a private address and drops unsolicited inbound packets, so crossing one needs a peer's public address (STUN) and a moment of coordination to punch a hole through it (a rendezvous) — a separate landing, called out here rather than left for a player to discover.

The protocol

A lightweight UDP protocol implementing both channels directly, instead of emulating them over a TCP-shaped stream:

Mechanism How
Acknowledgement Each packet carries seq, ack and 32 ackBits, so one packet acknowledges 33 — losing an ack costs nothing
Reliability A reliable message is kept until a packet carrying it is acknowledged, then resent on a timer derived from the measured round trip
Ordering Reliable messages carry an id; the receiver delivers in order and buffers what arrives early
Batching A tick's worth of messages become one datagram, up to 1200 bytes
Fragmentation Anything larger is split, and reassembled on the far side
Liveness A keepalive every 100 ms; a link silent for linkTimeout (5 s by default) is declared gone

Why not WebRTC?

Binding full WebRTC or libdatachannel means pulling a DTLS and SCTP stack into a native game just to move packets. The custom protocol implements the same NetTransport contract without that weight.

Testing netcode that has only ever seen loopback

simulatedLoss throws away a fraction of outgoing datagrams, and it is a field on the shipped class, not a test helper for a reason: netcode that has only run over loopback has never had a packet lost, so every retransmission path in it is untested code that first runs on a player's hotel wifi.

final transport = P2PNetTransport(simulatedLoss: 0.3);
// ...and it is settable while running, which is how you model the case that
// matters most: a link that was fine and then was not.
transport.simulatedLoss = 1;

At 30% loss the reliable channel should still deliver everything, in order. good_net_p2p's own tests assert exactly that.

Topology

Traffic is host-and-spokes, not a full mesh: a client has a direct link to the host and to nobody else, which is why connectionTo is always null between two clients. Client-to-client communication goes through the host, which is also where authority belongs.

What this layer is, and is not

good_net is messaging and sessions. It moves declared records and manages who is in the session.

There is no request/reply shape. A GameCommand<P, R> can await a result because the other isolate answers on a known schedule; a remote peer may never answer at all, and an API that looks awaitable but can hang forever is worse than one that does not offer it.

The ECS replication layer — a Replicated mixin, delta compression, client- side prediction and reconciliation — is its own topic instead of something bundled into messaging. Building it on these interfaces is a game's decision, and the channel split is exactly the primitive it needs.

A note on isolates

describeNetwork runs once per copy of the state, at boot, inside describeSystems — including on the main-isolate copy, exactly as every other declaration pass re-runs there. The transport built on that copy never opens a socket: nothing calls host or join on it, because a system only ticks on the copy that simulates.

NetworkSystem drains what arrived at the top of each fixed tick and puts what was queued on the wire once per frame, so message delivery is as deterministic as the rest of the simulation.