A comprehensive map of the Chocolate Doom networking stack as modified for VectorDoom (WebXDC realtimeChannel transport).
- High-Level Overview
- Layer Diagram
- Key Data Structures
- File-by-File Reference
- The Game Loop (Execution Order)
- Ticcmd Flow: Keyboard to Simulation
- The Loopback Mechanism (Host = Server + Client)
- Server Receive Window and PumpSendQueue
- Client Receive Window
- The Diff/Patch Mechanism
- Non-Blocking Server (VectorDoom Modification)
- Consistency Check
- WebXDC Transport Specifics
- Connection Lifecycle
- Disconnect Detection ("Player X left the game")
- Timeout and Keepalive Mechanisms
- Resend / Retransmission Logic
- Clock Synchronization (PID Filter)
- Packet Type Reference
VectorDoom uses a client-server lockstep architecture derived from
Chocolate Doom. All instances run the same deterministic game simulation.
The server collects per-tic input commands (ticcmd_t) from every player,
assembles them into a net_full_ticcmd_t containing all players' inputs for
that tic, and distributes them to all clients. Every client then executes the
same sequence of tics with the same inputs, producing identical game states.
VectorDoom's key modification: the transport layer (net_websockets.c)
was replaced with net_webxdc.c, which routes all packets through
webxdc.joinRealtimeChannel() -- a peer-to-peer broadcast channel available
inside WebXDC sandboxed apps. A JavaScript pre-loaded layer (webxdc-net.js)
handles server election and packet routing.
The host instance runs both the server and a client simultaneously,
connected via an in-process loopback (net_loop.c). Remote clients connect
to the server over the WebXDC transport.
+------------------------------------------------------------------+
| GAME LAYER |
| g_game.c G_Ticker, G_BuildTiccmd, consistency check |
| doom/d_net.c RunTic, PlayerQuitGame, loop_interface |
+------------------------------------------------------------------+
| LOOP LAYER |
| d_loop.c TryRunTics, NetUpdate, BuildNewTic, |
| D_ReceiveTic, D_InitNetGame, D_StartNetGame |
+------------------------------------------------------------------+
| CLIENT / SERVER |
| net_client.c Client state machine, send/recv windows, |
| clock sync, ticcmd sending |
| net_server.c Server state machine, recv window, |
| PumpSendQueue, player management |
+------------------------------------------------------------------+
| CONNECTION LAYER |
| net_common.c Connection state, keepalive, reliable packets, |
| disconnect handshake, timeout detection |
+------------------------------------------------------------------+
| SERIALIZATION |
| net_structrw.c Diff/Patch, Read/Write ticcmds, settings, |
| full ticcmds, wait data, protocol negotiation |
+------------------------------------------------------------------+
| I/O LAYER |
| net_io.c Context management, module dispatch, |
| SendPacket, RecvPacket, address refcounting |
| net_packet.c Packet buffer allocation, read/write integers, |
| strings |
+------------------------------------------------------------------+
| TRANSPORT MODULES |
| net_loop.c In-process loopback (host server <-> client) |
| net_webxdc.c WebXDC realtimeChannel transport (C side) |
| webxdc-net.js JS layer: channel setup, server election, |
| packet routing/filtering |
+------------------------------------------------------------------+
The fundamental unit of player input for one game tic:
typedef struct {
signed char forwardmove; // forward/backward (-127..127)
signed char sidemove; // strafe left/right (-127..127)
short angleturn; // turning (<<16 for angle delta)
byte chatchar; // chat character
byte buttons; // fire, use, weapon switch
byte consistancy; // game state hash for sync check
byte buttons2; // (Strife)
int inventory; // (Strife)
byte lookfly; // (Heretic/Hexen)
byte arti; // (Heretic/Hexen)
} ticcmd_t;A delta-encoded ticcmd. Only changed fields are transmitted:
typedef struct {
unsigned int diff; // bitmask of which fields changed
ticcmd_t cmd; // the new values for changed fields
} net_ticdiff_t;Diff flags: NET_TICDIFF_FORWARD, NET_TICDIFF_SIDE, NET_TICDIFF_TURN,
NET_TICDIFF_BUTTONS, NET_TICDIFF_CONSISTANCY, NET_TICDIFF_CHATCHAR,
NET_TICDIFF_RAVEN, NET_TICDIFF_STRIFE.
A complete set of all players' inputs for one tic, sent from server to client:
typedef struct {
signed int latency; // worst-case latency
unsigned int seq; // tic sequence number
boolean playeringame[NET_MAXPLAYERS]; // who is playing
net_ticdiff_t cmds[NET_MAXPLAYERS]; // per-player diffs
} net_full_ticcmd_t;The game loop's local storage for a tic's commands:
typedef struct {
ticcmd_t cmds[NET_MAXPLAYERS];
boolean ingame[NET_MAXPLAYERS];
} ticcmd_set_t;Stored in circular buffer: ticcmd_set_t ticdata[BACKUPTICS] (BACKUPTICS=128).
A network packet buffer:
struct _net_packet_s {
byte *data;
size_t len; // current data length
size_t alloced; // allocated size
unsigned int pos; // read cursor
};A network address with module dispatch:
struct _net_addr_s {
net_module_t *module; // which transport module owns this
int refcount; // reference counting
void *handle; // module-specific data (e.g., uint32_t* UID)
};Connection state tracked per peer:
typedef struct {
net_connstate_t state;
net_disconnect_reason_t disconnect_reason;
net_addr_t *addr;
net_protocol_t protocol;
int last_send_time;
int num_retries;
int keepalive_send_time;
int keepalive_recv_time;
net_reliable_packet_t *reliable_packets;
int reliable_send_seq;
int reliable_recv_seq;
} net_connection_t;States: CONNECTING -> CONNECTED -> DISCONNECTING -> DISCONNECTED
(also DISCONNECTED_SLEEP for lingering ACK window).
Server's per-client state:
typedef struct {
boolean active;
int player_number;
net_addr_t *addr;
net_connection_t connection;
int last_send_time;
char *name;
boolean ready;
unsigned int connect_time;
int last_gamedata_time;
boolean recording_lowres;
int sendseq;
net_full_ticcmd_t sendqueue[BACKUPTICS];
unsigned int acknowledged;
int max_players;
boolean drone;
sha1_digest_t wad_sha1sum, deh_sha1sum;
unsigned int is_freedoom;
int player_class;
} net_client_t;typedef struct {
boolean active;
signed int latency;
unsigned int resend_time;
net_ticdiff_t diff;
} net_client_recv_t;
static net_client_recv_t recvwindow[BACKUPTICS][NET_MAXPLAYERS];
static unsigned int recvwindow_start; // first tic in the windowTwo-dimensional: recvwindow[tic_offset][player].
typedef struct {
boolean active;
unsigned int resend_time;
net_full_ticcmd_t cmd;
} net_server_recv_t;
static net_server_recv_t recvwindow[BACKUPTICS];
static int recvwindow_start;
static ticcmd_t recvwindow_cmd_base[NET_MAXPLAYERS]; // diff baselineOne-dimensional: recvwindow[tic_offset], contains full ticcmds for all
players.
Purpose: The central game loop that ties together input generation, networking, and game simulation.
Key globals:
gametic-- the tic about to be (or currently being) simulatedmaketic-- the next tic to have input generated forrecvtic-- the latest tic fully received from the serverticdata[BACKUPTICS]-- circular buffer ofticcmd_set_tlocalplayer-- this instance's player indexinstanceUID-- WebXDC instance identifier
Key functions:
| Function | Role |
|---|---|
NetUpdate() |
Called every frame. Runs NET_CL_Run() + NET_SV_Run(), then builds new ticcmds based on elapsed time |
BuildNewTic() |
Reads input, calls BuildTiccmd(), sends to server via NET_CL_SendTiccmd(), stores locally |
D_ReceiveTic() |
Callback from net_client.c. Stores received ticcmds into ticdata[], increments recvtic |
TryRunTics() |
Main entry point per frame. Calls NetUpdate(), waits for enough tics, then runs them via loop_interface->RunTic() |
GetLowTic() |
Returns min(maketic, recvtic) -- the highest tic we can safely simulate |
D_InitNetGame() |
Sets up networking: server gets instanceUID=1, clients get random UID. Connects to server |
D_StartNetGame() |
Sends game settings, blocks until server starts the game |
D_QuitNetGame() |
Clean shutdown of server and client |
Purpose: Declares the loop_interface_t callback structure and public API.
typedef struct {
void (*ProcessEvents)();
void (*BuildTiccmd)(ticcmd_t *cmd, int maketic);
void (*RunTic)(ticcmd_t *cmds, boolean *ingame);
void (*RunMenu)();
} loop_interface_t;Purpose: Manages all connected clients, collects their ticcmds, assembles complete tic data, and distributes it.
Server states: SERVER_WAITING_LAUNCH -> SERVER_WAITING_START ->
SERVER_IN_GAME
Key functions:
| Function | Role |
|---|---|
NET_SV_Init() |
Creates server context, initializes client slots |
NET_SV_Run() |
Main server tick: receive packets, run each client, advance window, check resends |
NET_SV_Packet() |
Dispatches incoming packets by type (SYN, GAMEDATA, ACK, etc.) |
NET_SV_ParseSYN() |
Handles connection requests: validates magic, protocol, game mode. Allocates client slot |
NET_SV_ParseGameData() |
Receives ticcmd diffs from clients, stores in recvwindow[][], tracks sv_last_ticcmd[] |
NET_SV_PumpSendQueue() |
Core function: For each client, assembles net_full_ticcmd_t from all other players' data, enqueues and sends |
NET_SV_AdvanceWindow() |
Slides recvwindow_start forward based on lowest acknowledged tic |
NET_SV_RunClient() |
Runs connection state machine, calls PumpSendQueue and CheckDeadlock |
NET_SV_CheckDeadlock() |
If no data from a client for 1s, sends resend request to break deadlock |
NET_SV_SendResendRequest() |
Asks a client to retransmit specific tics |
NET_SV_CheckResends() |
Re-sends resend requests that timed out (300ms) |
NET_SV_SendTics() |
Serializes and sends a range of tics from a client's sendqueue |
NET_SV_ParseGameDataACK() |
Updates client's acknowledged tic number |
StartGame() |
Sends GAMESTART to all clients, transitions to IN_GAME |
NET_SV_GameEnded() |
Resets to WAITING_LAUNCH when all players disconnect |
Non-blocking modification (VectorDoom-specific):
sv_last_ticcmd[NET_MAXPLAYERS]-- reconstructed full ticcmd per playersv_player_has_sent[NET_MAXPLAYERS]-- tracks if player has ever sent data- When
PumpSendQueue()encounters a missing tic, it fabricates fromsv_last_ticcmd[]with all diff flags set, rather than blocking
Purpose: Manages the connection to the server, sends local ticcmds, receives and buffers complete tic data from the server.
Client states: CLIENT_STATE_WAITING_LAUNCH ->
CLIENT_STATE_WAITING_START -> CLIENT_STATE_IN_GAME
Key functions:
| Function | Role |
|---|---|
NET_CL_Run() |
Main client tick: receive packets, run connection, advance receive window, check resends |
NET_CL_Connect() |
Sends SYN packets every 1s, waits up to 120s for connection |
NET_CL_SendTiccmd() |
Diffs current ticcmd against last_ticcmd, stores in send_queue[], transmits |
NET_CL_SendTics() |
Serializes and sends a range of ticcmd diffs to the server |
NET_CL_ParseGameData() |
Receives net_full_ticcmd_t from server, stores in recvwindow[], triggers clock sync |
NET_CL_AdvanceWindow() |
Pops tics from front of recvwindow, calls ExpandFullTiccmd() then D_ReceiveTic() |
NET_CL_ExpandFullTiccmd() |
Applies diff patches against recvwindow_cmd_base[] to reconstruct full ticcmds |
NET_CL_CheckResends() |
Sends resend requests for timed-out tics (300ms); deadlock detection at 1000ms |
NET_CL_SendGameDataACK() |
Sends current recvwindow_start as acknowledgment to server |
NET_CL_Disconnect() |
Sends DISCONNECT, waits up to 5s for ACK |
UpdateClockSync() |
PID controller adjusting offsetms to synchronize client clocks |
NET_CL_ParseSYN() |
Handles SYN response, sets state to CONNECTED |
Client send window: send_queue[BACKUPTICS] of net_server_send_t
(stores seq, timestamp, ticdiff for each sent tic).
Purpose: Central header defining all network types and constants.
Key constants:
MAXNETNODES 16-- max connected nodes (including observers)NET_MAXPLAYERS 8-- max actual playersBACKUPTICS 128-- circular buffer size for tic dataNET_MAGIC_NUMBER 1454104972U-- connection handshake magicNET_RELIABLE_PACKET (1 << 15)-- flag in packet type for reliable delivery
Defines all packet types (NET_PACKET_TYPE_SYN, _GAMEDATA, _GAMEDATA_ACK,
_DISCONNECT, etc.), game settings structures, and the net_module_t interface.
Purpose: Reads and writes all network structures to/from packet buffers.
Key functions:
| Function | Role |
|---|---|
NET_TiccmdDiff() |
Computes diff between two ticcmds, producing net_ticdiff_t |
NET_TiccmdPatch() |
Applies a net_ticdiff_t to a baseline ticcmd_t to reconstruct the new one |
NET_WriteTiccmdDiff() / NET_ReadTiccmdDiff() |
Serialize/deserialize a single ticcmd diff |
NET_WriteFullTiccmd() / NET_ReadFullTiccmd() |
Serialize/deserialize a complete tic (latency + playeringame bitfield + per-player diffs) |
NET_WriteSettings() / NET_ReadSettings() |
Serialize/deserialize game settings |
NET_WriteConnectData() / NET_ReadConnectData() |
Serialize/deserialize connection data |
NET_WriteProtocolList() / NET_ReadProtocolList() |
Protocol negotiation |
Purpose: Shared connection state machine used by both client and server connections.
Key constants:
CONNECTION_TIMEOUT_LEN 4(seconds) -- modified from original 30s for WebXDCKEEPALIVE_PERIOD 2(seconds)MAX_RETRIES 5-- disconnect retry limit
Key functions:
| Function | Role |
|---|---|
NET_Conn_InitClient() |
Initializes connection in CONNECTING state |
NET_Conn_InitServer() |
Initializes connection in CONNECTED state |
NET_Conn_Run() |
Runs the connection state machine: timeout detection, keepalive sending, reliable packet retransmit |
NET_Conn_Packet() |
Processes common packet types (DISCONNECT, DISCONNECT_ACK, KEEPALIVE, RELIABLE_ACK). Returns true if consumed |
NET_Conn_Disconnect() |
Transitions to DISCONNECTING state |
NET_Conn_NewReliable() |
Creates a reliable packet (auto-retransmitted until ACKed) |
NET_Conn_SendPacket() |
Sends packet and updates keepalive_send_time |
NET_ExpandTicNum() |
Expands a 1-byte tic number to full 32-bit using relative window position |
Reliable packet mechanism: Reliable packets get a sequence number and
are stored in a linked list (reliable_packets). They are retransmitted
every 1 second until the other side sends a RELIABLE_ACK with the next
expected sequence number. Used for SYN, LAUNCH, GAMESTART, CONSOLE_MESSAGE,
DISCONNECT.
Purpose: In-process loopback so the host can be both server and client. Two packet queues connect the two sides without any real networking.
Architecture: Two net_module_t implementations:
net_loop_client_module-- used by the client sidenet_loop_server_module-- used by the server side
When the client sends a packet, it is duplicated and placed in server_queue.
When the server sends a packet, it is duplicated and placed in client_queue.
Each side pops from its own queue when receiving.
Queue size: 16 packets (circular buffer). Packets are deep-copied
(NET_PacketDup) to avoid aliasing.
Purpose: Manages net_context_t objects that hold a list of transport
modules. Provides unified Send/Recv that dispatches through modules.
Key functions:
NET_NewContext()-- allocates a contextNET_AddModule()-- registers a transport moduleNET_SendPacket()-- dispatches toaddr->module->SendPacket()NET_RecvPacket()-- polls all modules in order, returns first available packetNET_ReferenceAddress()/NET_ReleaseAddress()-- reference counting fornet_addr_t
Server context: has both net_loop_server_module and net_webxdc_module.
Client context (host): has only net_loop_client_module.
Client context (remote): has only net_webxdc_module.
Purpose: Low-level packet allocation and read/write primitives.
NET_NewPacket()-- allocates packet with initial sizeNET_PacketDup()-- deep copyNET_FreePacket()-- freeNET_ReadInt8/16/32(),NET_ReadSInt8/16/32()-- read with bounds checkNET_WriteInt8/16/32()-- write with auto-growNET_ReadString()/NET_WriteString()-- NUL-terminated strings- Big-endian wire format for multi-byte integers
Purpose: Implements net_module_t for the WebXDC realtimeChannel,
replacing the original WebSocket transport.
Wire format: [to(4 bytes LE)][from(4 bytes LE)][doom_payload]
Key functions:
| Function | Role |
|---|---|
InitWebXDC() |
Checks JS channel is ready, registers instanceUID |
NET_WebXDC_SendPacket() |
Prepends to/from headers, calls js_webxdc_send() |
NET_WebXDC_RecvPacket() |
Calls PollReceivedPackets(), pops from C queue |
PollReceivedPackets() |
Calls js_webxdc_recv() in a loop, extracts from-address, creates net_packet_t |
FindAddressByIp() |
Maintains a static table of net_addr_t keyed by uint32 UID |
EM_JS bindings:
js_webxdc_send(data, len)-- copies from WASM heap, sends viaglobalThis._webxdcChannel.send()js_webxdc_recv(buf, maxlen)-- shifts fromglobalThis._webxdcRecvQueue, copies to WASM heap
Address table is static (64 slots), never freed. Each UID maps to a
net_addr_t whose handle points to a uint32_t in the ips[] array.
Purpose: Pre-loaded before WASM. Sets up the realtimeChannel, handles server election, and filters incoming packets.
Server election protocol:
- On startup, broadcast
[42, 42, 42, 42]every 300ms - Any existing server responds with
[43, 43, 43, 43][unused(4)][timestamp(8)] - If response timestamp < own timestamp, they are server, we are client
- If no response within 3 seconds, we become the server
- Server continues broadcasting
[42, 42, 42, 42]every 3s for late joiners
Packet routing:
- All game packets are broadcast to all peers via realtimeChannel
- Format:
[to(4)][from(4)][doom_payload] - JS reads the
tofield (LE uint32) and compares toglobalThis._doomInstanceUID - Only packets addressed to this instance (or to address 0) are delivered
- Delivered to C as
[from(4)][doom_payload](to field stripped)
Fallback: If webxdc global is absent, uses BroadcastChannel('vectordoom-net')
for local testing.
Globals exposed:
_webxdcRecvQueue-- array of Uint8Array, polled by C_doomInstanceUID-- set by C when ready_webxdcChannel-- the realtimeChannel object_serverElectionP-- Promise
Purpose: Doom-specific glue between the generic loop layer and the game.
Key functions:
| Function | Role |
|---|---|
RunTic() |
Called by d_loop.c for each tic. Checks for player quits, sets netcmds, calls G_Ticker() |
PlayerQuitGame() |
Sets playeringame[i] = false, displays "Player N left the game" |
D_ConnectNetGame() |
Initializes connect data, calls D_InitNetGame() |
D_CheckNetGame() |
Registers loop callbacks, starts net game, loads settings |
LoadGameSettings() |
Copies net_gamesettings_t into global game variables |
SaveGameSettings() |
Copies global game variables into net_gamesettings_t |
Loop interface registration:
static loop_interface_t doom_loop_interface = {
D_ProcessEvents, // ProcessEvents
G_BuildTiccmd, // BuildTiccmd
RunTic, // RunTic
M_Ticker // RunMenu
};Purpose: The actual game simulation tick, input building, and consistency checking.
Key functions:
| Function | Role |
|---|---|
G_BuildTiccmd() |
Reads keyboard/mouse/joy state, fills ticcmd_t. Sets cmd->consistancy from local table |
G_Ticker() |
Main game tick: handles reborns, game actions, copies netcmds to player structs, runs consistency check, then advances game state |
Consistency array: byte consistancy[MAXPLAYERS][BACKUPTICS]
Each frame, the Emscripten main loop callback runs TryRunTics():
TryRunTics()
|
+-- if singletics: BuildNewTic()
| else: NetUpdate()
| |
| +-- NET_CL_Run() // Client: recv packets, advance window
| | +-- NET_RecvPacket() // poll transport modules
| | +-- NET_CL_ParsePacket()
| | | +-- NET_CL_ParseGameData() // store in recvwindow
| | +-- NET_Conn_Run() // keepalive, reliable retransmit
| | +-- NET_CL_AdvanceWindow()
| | | +-- NET_CL_ExpandFullTiccmd() // diff -> full ticcmd
| | | +-- D_ReceiveTic() // -> ticdata[], recvtic++
| | +-- NET_CL_CheckResends() // retransmit expired requests
| |
| +-- NET_SV_Run() // Server: recv packets, run clients
| | +-- NET_RecvPacket() // poll server context modules
| | +-- NET_SV_Packet() // dispatch by type
| | +-- for each client: NET_SV_RunClient()
| | | +-- NET_Conn_Run() // keepalive, reliable retransmit
| | | +-- NET_SV_PumpSendQueue() // assemble & send tics
| | | +-- NET_SV_CheckDeadlock()
| | +-- NET_SV_AdvanceWindow() // slide recvwindow_start
| | +-- NET_SV_CheckResends() // for each player
| |
| +-- Build new ticcmds based on elapsed time
| for each new tic:
| BuildNewTic()
| +-- I_StartTic() // pump OS events
| +-- ProcessEvents() // D_ProcessEvents
| +-- RunMenu() // M_Ticker
| +-- BuildTiccmd(&cmd) // G_BuildTiccmd
| +-- NET_CL_SendTiccmd(&cmd) // send to server
| +-- store in ticdata[maketic] // local copy
| +-- maketic++
|
+-- GetLowTic() -> lowtic = min(maketic, recvtic)
|
+-- Wait loop: while lowtic < gametic/ticdup + counts
| NetUpdate(), sleep 1ms, re-check
| Bail after MAX_NETGAME_STALL_TICS (10 tics)
|
+-- Run tics: while counts--
set = ticdata[(gametic/ticdup) % BACKUPTICS]
for i in 0..ticdup:
copy ingame to local_playeringame
loop_interface->RunTic(set->cmds, set->ingame)
-> d_net.c RunTic()
-> check player quits (ingame transitions false->true)
-> set netcmds = cmds
-> G_Ticker()
gametic++
NetUpdate() // check for new commands after each tic group
BuildNewTic() in d_loop.c
-> G_BuildTiccmd(&cmd, maketic) in g_game.c
- Reads keyboard, mouse, joystick state
- Fills forwardmove, sidemove, angleturn, buttons
- Sets cmd->consistancy = consistancy[consoleplayer][maketic % BACKUPTICS]
BuildNewTic() continues:
-> NET_CL_SendTiccmd(&cmd, maketic) in net_client.c
- NET_TiccmdDiff(&last_ticcmd, &cmd, &diff) // delta encode
- Store in send_queue[maketic % BACKUPTICS]
- last_ticcmd = cmd
- NET_CL_SendTics(maketic - extratics, maketic)
- Writes: [GAMEDATA type][recvwindow_start & 0xff][start & 0xff][count]
- For each tic: [latency(16)][ticdiff...]
- NET_Conn_SendPacket() -> transport module
NET_SV_Run() -> NET_SV_Packet() -> NET_SV_ParseGameData()
- Reads: ackseq, seq, num_tics
- Expands 8-bit seq to full via NET_SV_ExpandTicNum()
- For each tic in packet:
- NET_ReadSInt16() -> latency
- NET_ReadTiccmdDiff() -> diff
- Store in recvwindow[index][player]
- NET_TiccmdPatch(&sv_last_ticcmd[player], &diff, &sv_last_ticcmd[player])
- sv_player_has_sent[player] = true
- Update client->acknowledged from ackseq
- If gaps detected before seq, send resend request
NET_SV_RunClient() -> NET_SV_PumpSendQueue(client)
- For the client's current sendseq:
- recv_index = sendseq - recvwindow_start
- For each OTHER player i:
- If recvwindow[recv_index][i].active: use real diff
- Else if recvwindow[recv_index-1][i].active: use off-by-one (loopback fix)
- Else: fabricate from sv_last_ticcmd[i] with all diff flags
- Assemble net_full_ticcmd_t with seq, latency, playeringame[], cmds[]
- Store in client->sendqueue[sendseq % BACKUPTICS]
- NET_SV_SendTics(starttic, endtic)
- Writes: [GAMEDATA type][start & 0xff][count]
- For each tic: NET_WriteFullTiccmd()
- [latency(16)][playeringame bitfield(8)]
- For each active player: [ticdiff...]
- sendseq++
NET_CL_Run() -> NET_CL_ParseGameData()
- Reads: seq, num_tics
- Expands seq to full tic number
- For each tic: NET_ReadFullTiccmd() -> store in recvwindow[index]
- UpdateClockSync() for last tic in packet
- If gaps before seq, send resend request
NET_CL_Run() -> NET_CL_AdvanceWindow()
- While recvwindow[0].active:
- NET_CL_ExpandFullTiccmd(&recvwindow[0].cmd, recvwindow_start, ticcmds)
- For each player i (except self):
- NET_TiccmdPatch(&recvwindow_cmd_base[i], &diff, &ticcmds[i])
- recvwindow_cmd_base[i] = ticcmds[i]
- D_ReceiveTic(ticcmds, playeringame)
-> d_loop.c: stores into ticdata[recvtic % BACKUPTICS], recvtic++
- memmove window forward, recvwindow_start++
TryRunTics() -> loop_interface->RunTic(set->cmds, set->ingame)
-> d_net.c RunTic()
- Check for player quits (ingame changed from true to false)
- netcmds = cmds (global pointer)
- G_Ticker()
- For each player: memcpy(player.cmd, &netcmds[i])
- Consistency check (see section 12)
- Run game logic (P_Ticker -> thinkers, physics, etc.)
When a player starts as the server (-server flag):
D_InitNetGame():
instanceUID = 1
NET_SV_Init() // create server context
NET_SV_AddModule(&net_loop_server_module) // loopback server endpoint
NET_SV_AddModule(&net_webxdc_module) // WebXDC for remote clients
net_loop_client_module.InitClient() // loopback client endpoint
addr = net_loop_client_module.ResolveAddress(NULL) // -> &client_addr
NET_CL_Connect(addr, ...) // connect client to server via loopback
The server context receives packets from two sources:
net_loop_server_module-- packets from the local clientnet_webxdc_module-- packets from remote clients
The local client context only receives from net_loop_client_module.
Data flow for loopback:
Client SendPacket() -> QueuePush(server_queue, dup)
Server RecvPacket() -> QueuePop(server_queue)
Server SendPacket() -> QueuePush(client_queue, dup)
Client RecvPacket() -> QueuePop(client_queue)
Both NET_CL_Run() and NET_SV_Run() are called every frame in
NetUpdate(), so the loopback host processes both sides each frame.
recvwindow[BACKUPTICS][NET_MAXPLAYERS]
^ ^
| |
tic offset from player index
recvwindow_start
recvwindow_start is the sequence number of the first slot. Slot i
corresponds to tic recvwindow_start + i.
NET_SV_ParseGameData():
- Receives ticcmd diffs from a single client
- For each tic in the packet, computes
index = seq + i - recvwindow_start - If in range [0, BACKUPTICS), sets
recvwindow[index][player].active = trueand stores the diff - Also patches
sv_last_ticcmd[player]to maintain the reconstructed full ticcmd for that player
NET_SV_PumpSendQueue(client) runs for each client every server tick:
- Throttle check: If
sendseq - LatestAcknowledged() > 40, skip (backpressure) - Compute recv_index:
sendseq - recvwindow_start - For each other player: Check if their tic data is available:
recvwindow[recv_index][i].active-- ideal case, use real diffrecvwindow[recv_index - 1][i].active-- off-by-one fix for loopback timing- Neither -- fabricate from
sv_last_ticcmd[i](non-blocking)
- Build
net_full_ticcmd_t: set seq, latency, playeringame, cmds - Store in
client->sendqueue[sendseq % BACKUPTICS] - Send tics from
sendseq - extraticstosendseq(redundancy) - Increment
client->sendseq
NET_SV_AdvanceWindow():
- Computes
lowtic = NET_SV_LatestAcknowledged()(minimum across all clients) - While
recvwindow_start < lowtic:memmove(recvwindow, recvwindow + 1, ...)-- shift window left- Clear last slot
recvwindow_start++
This discards old tic data that all clients have acknowledged receiving.
recvwindow[BACKUPTICS] of net_server_recv_t
recvwindow_start = first tic in window
recvwindow_cmd_base[NET_MAXPLAYERS] = diff baselines
NET_CL_ParseGameData():
- Receives complete tics (all players' diffs) from server
- For each tic, computes
index = seq - recvwindow_start + i - Stores
net_full_ticcmd_tinrecvwindow[index]
NET_CL_AdvanceWindow():
- While
recvwindow[0].active:NET_CL_ExpandFullTiccmd(): for each player (except self), applies diff againstrecvwindow_cmd_base[i]to get fullticcmd_tD_ReceiveTic(ticcmds, playeringame)-- delivers to game loop- Shift window:
memmove, clear last,recvwindow_start++
Important: The local player's ticcmd is NOT overwritten by the server data.
In D_ReceiveTic(), if (!drone && i == localplayer) skips the copy. This
prevents round-trip latency from affecting local input -- the local copy was
already stored directly in ticdata[] by BuildNewTic().
Ticcmds are delta-encoded to save bandwidth. Only fields that changed from the previous tic are transmitted.
NET_TiccmdDiff(ticcmd_t *old, ticcmd_t *new, net_ticdiff_t *diff):
- Compares each field of old vs new
- Sets bits in
diff->difffor changed fields - Copies the full new ticcmd into
diff->cmd
NET_TiccmdPatch(ticcmd_t *base, net_ticdiff_t *diff, ticcmd_t *result):
- Starts with a copy of
base - For each set bit in
diff->diff, overwrites corresponding field fromdiff->cmd - Fields without the bit set retain their value from
base - Special:
chatcharandartiandinventoryare reset to 0 if their flag is not set (they are event-like, not stateful)
NET_WriteTiccmdDiff(): Writes the diff byte, then only the fields whose
bits are set. Angleturn is 2 bytes normally, 1 byte if lowres_turn.
NET_ReadTiccmdDiff(): Reads the diff byte, then conditionally reads each
field.
Client -> Server: Each client maintains last_ticcmd as the baseline.
Each sent diff is relative to the previously sent ticcmd.
Server side: The server patches diffs onto sv_last_ticcmd[player] to
maintain the running full state. The recvwindow stores the raw diffs.
Server -> Client: The server sends diffs in net_full_ticcmd_t. Each
client maintains recvwindow_cmd_base[player] as the baseline for each
remote player. NET_CL_ExpandFullTiccmd() patches each received diff onto
this baseline.
Original Chocolate Doom's PumpSendQueue() would block (return early) if
any player's tic data was missing. This caused the game to freeze when any
single player had packet loss.
VectorDoom modifications:
Tracks the last fully-reconstructed ticcmd for each player. Updated in
NET_SV_ParseGameData() via NET_TiccmdPatch().
In the player-scanning loop:
if (recvwindow[recv_index][i].active) {
// Use real data
} else if (recv_index > 0 && recvwindow[recv_index - 1][i].active) {
// Loopback off-by-one fix: use data from one position back
} else {
// Fabricate: use sv_last_ticcmd[i] with ALL diff flags set
if (sv_player_has_sent[i]) {
cmd.cmds[i].diff = NET_TICDIFF_FORWARD | NET_TICDIFF_SIDE
| NET_TICDIFF_TURN | NET_TICDIFF_BUTTONS
| NET_TICDIFF_CONSISTANCY;
cmd.cmds[i].cmd = sv_last_ticcmd[i];
} else {
memset(&cmd.cmds[i], 0, sizeof(net_ticdiff_t));
}
}The fabricated diff has ALL relevant flags set so the receiver gets absolute values. This prevents baseline drift on the client side (since the client applies patches sequentially, a fabricated diff with full absolute values resets the baseline correctly).
while (recvwindow_start < lowtic) {
// Advance unconditionally -- PumpSendQueue handles missing data
memmove(...);
recvwindow_start++;
}No check for "all players have data" before advancing -- the window advances purely based on client acknowledgments.
The consistency check detects desynchronization between clients.
Building: In G_BuildTiccmd():
cmd->consistancy = consistancy[consoleplayer][maketic % BACKUPTICS];Checking: In G_Ticker():
if (netgame && !netdemo && !(gametic % ticdup)) {
if (gametic > BACKUPTICS && consistancy[i][buf] != cmd->consistancy) {
// Consistency failure! (currently just prints warning)
}
if (players[i].mo)
consistancy[i][buf] = players[i].mo->x; // player X position
else
consistancy[i][buf] = rndindex; // random state
}Mechanism:
- Each tic, the local player embeds
consistancy[consoleplayer][tic % BACKUPTICS]into their ticcmd - This value was computed from
players[i].mo->x(player's X position) during the previous processing of that buffer slot - When another client receives this ticcmd, it compares the received
consistancybyte against its own locally-computed value - If they differ, the game states have diverged
VectorDoom note: The check currently only prints a warning (not a fatal error), which is important given the non-blocking server can send fabricated inputs that may cause temporary inconsistencies.
Instance A (Server) Instance B (Client)
================== ===================
net_webxdc.c net_webxdc.c
| |
v v
js_webxdc_send() js_webxdc_send()
| |
v v
_webxdcChannel.send() <---> _webxdcChannel.send()
| (broadcast) |
v v
handleMessage() handleMessage()
| |
+-- filter by destUID +-- filter by destUID
| |
v v
_webxdcRecvQueue _webxdcRecvQueue
| |
v v
js_webxdc_recv() js_webxdc_recv()
| |
v v
PollReceivedPackets() PollReceivedPackets()
Byte offset: 0 1 2 3 4 5 6 7 8...
[------to------] [-----from------] [doom payload]
Little-endian Little-endian
uint32 destUID uint32 srcUID
All packets are broadcast to all peers. The JS handleMessage() function
filters by destination UID. Only packets addressed to _doomInstanceUID (or
address 0 for broadcast) are delivered to C.
Handled entirely in webxdc-net.js before WASM starts:
- Each instance records
thisAppStartedAt = Date.now()on load - Sends
[42, 42, 42, 42]every 300ms - If an existing server receives this, it responds with
[43, 43, 43, 43][unused(4)][timestamp(8)] - Receiver compares timestamps: earlier start time wins
- After 3 seconds with no earlier server found, self-elect as server
- The server gets
instanceUID = 1(set in C code inD_InitNetGame())
- Server always has
instanceUID = 1 - Clients get random UIDs:
rand() % 0xfffe - In
net_webxdc.c,FindAddressByIp()maintains a static lookup table mapping uint32 UIDs tonet_addr_tpointers - The
handlefield ofnet_addr_tpoints to the UID value in theips[]array - Clients connect to address "1" (the server) via
net_webxdc_module.ResolveAddress("1")
- realtimeChannel is unreliable (no delivery guarantee, no ordering)
- All packets are broadcast (bandwidth scales O(n) with peers)
- No fragmentation support -- packets must fit in a single realtimeChannel message
- The JS filtering means all instances receive and discard each other's traffic
1. SERVER_WAITING_LAUNCH
- Accept SYN connections
- Send WAITING_DATA every 1s to each client
- Oldest client becomes controller
2. Controller sends LAUNCH packet
-> Forward to all clients
-> SERVER_WAITING_START
3. Each client sends GAMESTART with settings
- Controller's settings are adopted
- client.ready = true
- When AllNodesReady(): StartGame()
-> SERVER_IN_GAME
4. IN_GAME loop:
- Receive GAMEDATA from clients
- PumpSendQueue for each client
- AdvanceWindow
- CheckResends
- CheckDeadlock
1. Send SYN every 1s (up to 120s timeout)
- Include magic number, version, protocol list, connect data, name
- Receive SYN response -> CONNECTED
2. CLIENT_STATE_WAITING_LAUNCH
- Receive WAITING_DATA from server
- Receive LAUNCH -> CLIENT_STATE_WAITING_START
3. CLIENT_STATE_WAITING_START
- Send GAMESTART with settings
- Receive GAMESTART -> CLIENT_STATE_IN_GAME
- Clear receive/send windows
4. IN_GAME loop:
- Send GAMEDATA (ticcmd diffs) to server
- Receive GAMEDATA (full ticcmds) from server
- AdvanceWindow -> D_ReceiveTic
- CheckResends
Disconnect propagates through multiple layers:
NET_Conn_Run():
if (nowtime - conn->keepalive_recv_time > CONNECTION_TIMEOUT_LEN * 1000) {
conn->state = NET_CONN_STATE_DISCONNECTED;
conn->disconnect_reason = NET_DISCONNECT_TIMEOUT;
}CONNECTION_TIMEOUT_LEN = 4 seconds (reduced from 30 for WebXDC).
NET_SV_RunClient():
if (conn->state == NET_CONN_STATE_DISCONNECTED
&& disconnect_reason == NET_DISCONNECT_TIMEOUT) {
NET_SV_BroadcastMessage("Client '%s' timed out and disconnected");
}
if (conn->state == NET_CONN_STATE_DISCONNECTED) {
client->active = false;
// If in waiting state, abort game
// If no players left, call NET_SV_GameEnded()
}When client->active becomes false, sv_players[i] eventually becomes NULL
(reassigned by NET_SV_AssignPlayers()). In PumpSendQueue(), that player
gets cmd.playeringame[i] = false.
The net_full_ticcmd_t now has playeringame[i] = false for the
disconnected player. NET_CL_ExpandFullTiccmd() passes this through to
D_ReceiveTic().
// d_loop.c: D_ReceiveTic()
ticdata[recvtic % BACKUPTICS].ingame[i] = players_mask[i]; // now false
// d_loop.c: TryRunTics() -> loop_interface->RunTic()
// copies ingame to local_playeringame
// doom/d_net.c: RunTic()
for (i = 0; i < MAXPLAYERS; ++i) {
if (!demoplayback && playeringame[i] && !ingame[i]) {
PlayerQuitGame(&players[i]);
// -> "Player N left the game"
// -> playeringame[i] = false
}
}The critical comparison is playeringame[i] && !ingame[i] -- the global
playeringame[] still has the player as active, but the new tic data says
they are gone.
| Mechanism | Location | Period | Action |
|---|---|---|---|
| Connection timeout | net_common.c NET_Conn_Run() |
4 seconds no recv | State -> DISCONNECTED (TIMEOUT) |
| Keepalive send | net_common.c NET_Conn_Run() |
2 seconds no send | Send KEEPALIVE packet |
| Reliable retransmit | net_common.c NET_Conn_Run() |
1 second | Resend head of reliable queue |
| Disconnect retry | net_common.c NET_Conn_Run() |
1 second, max 5 | Resend DISCONNECT packet |
| Disconnect ACK sleep | net_common.c NET_Conn_Run() |
5 seconds | Linger in DISCONNECTED_SLEEP |
| Server deadlock | net_server.c CheckDeadlock() |
1 second no gamedata | Send resend request for next expected tic |
| Client deadlock | net_client.c CheckResends() |
1 second no gamedata | Force resend request for recvwindow[0] |
| Resend request retry | Both | 300ms | Re-send resend request for still-missing tics |
| Server waiting data | net_server.c RunClient() |
1 second | Send WAITING_DATA to connected clients |
| Client SYN retry | net_client.c Connect() |
1 second, 120s total | Re-send SYN packet |
| Game loop stall | d_loop.c TryRunTics() |
MAX_NETGAME_STALL_TICS (10) | Bail out and render frame anyway |
| Server election | webxdc-net.js | 300ms probe, 3s timeout | Broadcast [42,42,42,42], self-elect if no response |
| Server election re-announce | webxdc-net.js | 3 seconds (server only) | Keep broadcasting for late joiners |
Trigger 1 -- Out-of-order detection (NET_SV_ParseGameData()):
When a packet arrives with seq > expected, the server scans backward from seq
to find missing tics. If found, sends GAMEDATA_RESEND to that client.
Trigger 2 -- Timeout retry (NET_SV_CheckResends()):
Scans the entire recvwindow for each player. If a tic is not active and its
resend_time is >300ms ago, re-sends the resend request.
Trigger 3 -- Deadlock (NET_SV_CheckDeadlock()):
If no gamedata from a client for >1000ms, finds the first missing tic and
sends a resend request. Also resends any tics from the server's send queue
to break mutual deadlock.
Trigger 1 -- Out-of-order detection (NET_CL_ParseGameData()):
Same logic as server side -- if seq > expected, scan backward for gaps.
Trigger 2 -- Timeout retry (NET_CL_CheckResends()):
Same 300ms retry logic. Additionally, if no gamedata for >1000ms and
recvwindow[0] is not active and has never been requested, force a resend
(deadlock breaker).
Trigger 3 -- Drone acknowledgment (NET_CL_CheckResends()):
If need_to_acknowledge is true and >200ms since last gamedata, send a
standalone GAMEDATA_ACK.
GAMEDATA_RESEND: [type(16)][start_tic(32)][count(8)]
UpdateClockSync() in net_client.c adjusts offsetms (a global fixed-point
variable) to keep client clocks synchronized:
latency = now - send_queue[seq].time; // round-trip for this tic
error = latency - remote_latency; // how we compare to worst other player
cumul_error += error;
offsetms = KP * error - KI * cumul_error + KD * (last_error - error);
// KP = 0.1, KI = 0.01, KD = 0.02offsetms feeds into GetAdjustedTime() in d_loop.c, which controls how
many tics NetUpdate() generates. A positive offset speeds up the clock
(generate tics faster), negative slows it down. This keeps all clients
producing tics at roughly the same rate.
Only the last tic in each received packet triggers the update (to avoid bogus latency from packets received via extratics redundancy).
| Type | Value | Direction | Reliable? | Purpose |
|---|---|---|---|---|
| SYN | 0 | C->S, S->C | Yes (response) | Connection handshake |
| ACK | 1 | -- | -- | Deprecated |
| REJECTED | 2 | S->C | No | Connection rejected |
| KEEPALIVE | 3 | Both | No | Prevent timeout |
| WAITING_DATA | 4 | S->C | No | Lobby status |
| GAMESTART | 5 | C->S, S->C | Yes | Game settings / start signal |
| GAMEDATA | 6 | Both | No | Ticcmd data (main game traffic) |
| GAMEDATA_ACK | 7 | C->S | No | Acknowledge received tics |
| DISCONNECT | 8 | Both | No (retried) | Request disconnect |
| DISCONNECT_ACK | 9 | Both | No | Acknowledge disconnect |
| RELIABLE_ACK | 10 | Both | No | Acknowledge reliable packet |
| GAMEDATA_RESEND | 11 | Both | No | Request retransmission |
| CONSOLE_MESSAGE | 12 | S->C | Yes | Server message to client |
| QUERY | 13 | C->S | No | Server info query |
| QUERY_RESPONSE | 14 | S->C | No | Server info response |
| LAUNCH | 15 | C->S, S->C | Yes | Game launch signal |
| NAT_HOLE_PUNCH | 16 | -- | -- | Not used in WebXDC |
The NET_RELIABLE_PACKET flag (bit 15) is OR'd into the type field for
reliable packets. The receiver strips this flag, processes the reliable
sequence number, sends RELIABLE_ACK, and passes the inner packet type
through for normal processing.
- Transport:
net_websockets.creplaced withnet_webxdc.c+webxdc-net.js - Server election: JavaScript-level election via magic bytes and timestamps
- Non-blocking server:
PumpSendQueue()fabricates missing tics fromsv_last_ticcmd[]instead of blocking - Reduced timeouts:
CONNECTION_TIMEOUT_LEN= 4s (was 30s) - Instance UIDs: Server always UID=1, clients get random UIDs
- Consistency check softened: Prints warning instead of fatal error
- Packet routing: All packets broadcast, JS filters by destination UID