⚓ Seaworth Games

lib/network · lib/networkPacks

Wire Protocol

The Systems Map's founding diagram draws one arrow labeled request · network pack and one labeled result · broadcast pack. This is what's actually inside those arrows: a length-prefixed byte stream, a double-dispatch visitor that turns a deserialized struct into a real function call, and — on the client — a sandwich around the moment state actually changes.

Three connections, two protocols

Every message on every connection is length-prefixed — a 4-byte size, then the payload. What differs is what's inside.

Three distinct connections can exist during a game's life: client ↔ match server (gameplay — the one this atlas's other five docs already assume), client ↔ lobby server (global multiplayer matchmaking and chat), and match server ↔ lobby server (a control channel, opened when a multiplayer room is created). Gameplay traffic is VCMI's own binary serializer — the same framework behind savegames, one shared h & object pattern for both, gated by a single version constant with no backward compatibility. Lobby traffic is plain UTF-8 JSON, and unlike the rest of the codebase's comment-tolerant JSON, every lobby message is validated as strict JSON with a mandatory "type" field.

NetworkConnection (real TCP, via a length-prefixed NetworkBuffer) isn't the only way two sides of this protocol talk, though — and this is the part the docs don't quite say. When a singleplayer game starts, ServerThreadRunner::connect() doesn't open a socket at all: it calls createInternalConnection(), which wires client and server together through InternalConnection, an in-process class that implements the identical INetworkConnection interface but hands framed messages directly between two objects in the same address space. The wire protocol — framing, packs, the visitor dispatch below — runs unconditionally either way; only the transport is conditional. Real TCP comes back for lobbyMode (true remote play) and, behind a separate build flag, for running the server as a detached OS process instead of a thread.

From bytes to a call

A deserialized CPack doesn't know what it is until something visits it — twice on the way in, and on the client, wrapped around the moment state actually changes.

Every incoming pack is a CPack subclass, and CPack::visit() dispatches in two stages: visitBasic(), implemented once per base category (CPackForServer, CPackForClient, CPackForLobby), then visitTyped(), overridden by every concrete leaf type, which calls the one matching method — visitMakeAction(), visitBattleAttack(), one of roughly 170 such methods declared on ICPackVisitor — on whichever visitor implementation is doing the asking. That's the whole trick: a `switch` the compiler writes for you, once per pack type, instead of one arm of a hand-maintained dispatch table.

SERVER · ApplyGhNetPackVisitor::visitMakeAction() gh.throwIfWrongPlayer(connection, &pack) sender must own the player color it claims then: gh.battles->makePlayerBattleAction() CLIENT · CClient::handlePack() pack.visit(beforeVisitor) ApplyFirstClientNetPackVisitor — state not yet changed gameState().apply(pack) CGameState mutated here, under lock pack.visit(afterVisitor) ApplyClientNetPackVisitor — state already changed BattleAttack, concretely beforeVisitor starts the attack animation (& battleStacksAttacked) against the old HP afterVisitor is empty — nothing left to do no before/after split needed: the server has no animation to sequence against same MakeAction → BattleAction round trip this atlas's Battle System doc already traced
The server has one visitor pass, because it has no rendering to time. The client has two, wrapped around gameState().apply(pack), because it does: ApplyFirstClientNetPackVisitor::visitBattleAttack() starts the strike animation and applies battleStacksAttacked while the target's HP is still the pre-hit value, specifically so the animation and the damage don't arrive in the same instant. This is what Battle System meant by “BattleStacksController plays it” without explaining how the timing actually works.

Two ways to say “got it”

A client request and a pending server question are correlated by two unrelated IDs, because they're answering two different questions.

requestID — did my write land?

stamped by the client on every CPackForServer

CGameHandler::handleReceivedPack() echoes it straight back twice: a PackageReceived the instant the pack arrives, then a PackageApplied (success flag included) once it's actually been processed. Two acks, not one — a client can tell “the server has my request” apart from “the server acted on it.”

queryID — what am I blocked on?

stamped by the server on a Query, a CPackForClient subtype

isBlockedByQueries() refuses further action from a player while one of their queries is outstanding — the same CBattleQuery mechanism Battle System found locking both sides out for the length of a fight. QueryResolved broadcasts the ID once it's answered.

Proxy mode

Getting two players behind different NATs onto one connection takes a mid-flight identity swap on both ends at once.

1. joinGameRoom

client asks the lobby to join an open room

→

2. lobby → match server

notifies over the existing control connection

→

3. serverProxyLogin

match server opens a new connection to the lobby, hands it to VCMIServer

→

4. clientProxyLogin

client mirrors the move, hands its side to CServerHandler

From here the lobby server stops looking at either connection — it just relays raw bytes between the two proxied sockets. Neither side ever learns it isn't talking directly to the other; the same framed gameplay protocol, the same MakeAction/BattleStart packs, just with the lobby quietly forwarding every byte in between.

The pack taxonomy

Four files, split by who sends and what context — and the traffic is lopsided in the direction you'd expect.

PacksForClient.h79 types — server → client, general gameplay: SetResources, SetMana, GiveBonus, FoWChange, PlayerStartsTurn… detailed state deltas, by far the largest file here
PacksForServer.h39 types — client → server, requests: MoveHero, EndTurn, BuildStructure, BulkMoveArmy… terse intents, roughly half as many
PacksForClientBattle.h23 types — the battle-specific slice: BattleStart, BattleAttack, BattleNextRound, BattleResultAccepted — the exact names Battle System already traced
PacksForLobby.h32 types — the JSON side: clientLogin, sendChatMessage, activateGameRoom, the *ProxyLogin pair from above

Roughly two broadcasts for every one request, which is the shape you'd expect from a server that's the sole writer of state: it has a lot more to report than any single client ever has to ask for.