This document explains how gameplay data moves from a local player to the server and then out to other players, with focus on the classes that are easiest to confuse (Move, Ship, MoveItem, and the three ClientInfo variants).
It is a data-flow and architecture guide, not a low-level protocol guide.
Bitfighter uses three complementary patterns:
-
Input stream (client -> server) for controlled objects
- The local client samples input into a
Moveeach frame. ControlObjectConnectionbatches and sends pendingMoves in its packet stream.- Server replays those moves on the authoritative
Ship.
- The local client samples input into a
-
Ghost state replication (mostly server -> clients) for world objects
- World objects (including
Ship,MoveItemsubclasses, flags, etc.) are replicated as TNL ghosts. packUpdate()/unpackUpdate()send only changed fields using mask bits.- Scoping decides which objects each client currently receives.
- World objects (including
-
RPC/event stream (mostly server -> clients, some client -> server)
- Used for discrete game events and metadata (join/leave, team changes, auth, busy state, messages, score updates, etc.).
- Mostly declared on
GameConnectionandGameType.
A single gameplay moment usually touches all three: input stream drives simulation, ghosting sends resulting world state, RPCs send discrete announcements/metadata.
Move is a compact command snapshot:
- Movement axes (
x,y) - Aim angle
- Fire button
- Module primary/secondary toggles
- Time delta
Move is not a world object. It is player intent. It has pack()/unpack() helpers for compact transmission and prepare() to force client/server rounding consistency before prediction/replay.
Ship is a ghosted world object and simulation entity:
- Holds gameplay state (position, velocity, health, energy, loadout, timers, mounted items, etc.)
- Executes movement and firing logic from current
Move - Implements ghost replication (
packUpdate()/unpackUpdate()) - Implements control-state snapshots (
writeControlState()/readControlState()) used for correction/replay
Important distinction:
Movesays what a player tried to do.Shipis what actually happened in simulation.
MoveItem is a subclass of MoveObject for moving world items (flags/resources/etc.).
- It uses ghost replication of position/velocity.
- It is unrelated to player-input
Moveexcept name similarity.
If you are debugging player control flow, MoveItem is usually not part of that path.
ClientInfo base stores player metadata (name, team, score, auth flags, badges, spawn delay state, stats, ship pointer, etc.).
There are three practical forms in play:
-
Client-side local
FullClientInfo(ClientGame::mClientInfo)- Represents local player preferences/identity from client perspective.
- Exists even before full in-game synchronization completes.
-
Server-side
FullClientInfo(one per connected player/bot)- Authoritative metadata used by server simulation and policy decisions.
- For human clients, linked to a
GameConnection.
-
Client-side
RemoteClientInfo(in client game player list, including local player mirror)- Created from server RPC sync (
GameType::s2cAddClient). - Represents server-approved view of each player for scoreboard/UI/gameplay metadata.
- Client keeps a pointer to "my remote mirror" (
mLocalRemoteClientInfo) for the local player’s server-synced in-game record.
- Created from server RPC sync (
This local/full vs remote/mirrored split is a major source of confusion and is intentional.
A ghost is a client-side copy of a server-side object, managed by TNL's ghost system. The server is the only place where game objects truly exist and run their authoritative simulation. Each connected client receives a subset of those objects as ghosts — lightweight replicas that receive state updates from the server.
How much independent simulation a ghost performs depends on the object type:
- Purely server-simulated (e.g.
Mine,SpyBug):idle()returns immediately on the client. The client receives position/state entirely through ghost updates. - Client-predicted + server-authoritative (e.g. local player
Ship): client runs physics locally for responsiveness; server corrections trigger a replay of unacknowledged moves (see Pattern A). - Parallel simulation with ghost correction (e.g.
Projectile/bullets): both client and server run the full flight physics independently each frame. The server is authoritative for damage and lifetime. If the two diverge (e.g., after a shield bounce), the server sends a ghost position correction (PositionMask). This gives smooth bullet visuals without waiting for server round-trips. - Server-simulated with client interpolation (e.g.
Seeker): movement steering runs server-only; the client receives ghost updates and interpolates between them visually.
The lifecycle:
- Ghost create — when an object enters a client's scope, TNL calls
packUpdate()withisInitialUpdate() == true. The client constructs a new local ghost instance and callsonGhostAdd(). - Ghost update — subsequent changes are sent via
packUpdate()/unpackUpdate()using mask bits to transmit only changed fields. - Ghost destroy — when an object leaves scope, TNL calls
onGhostRemove()and deletes the local copy.
The net effect: clients always see a recent (slightly delayed) reflection of server state.
Objects opt into ghosting by setting mNetFlags.set(Ghostable) (see Ship constructor). Objects that are not ghostable are server-only.
Warping is the decision to skip position interpolation and snap a ghost directly to its new location instead of animating smoothly toward it.
Interpolation works well for small, expected movements (normal flight). It breaks down when a ship teleports, respawns, or the server corrects a large divergence — smoothly sliding a ship halfway across the map would look wrong. In those cases, shipwarped is set in unpackUpdate, which copies ActualState directly to RenderState and resets the trail. The WarpPositionMask bit in packUpdate is what signals a large positional change to clients.
The warp-in visual effect (spinning ship) is triggered separately by mWarpInTimer after a teleport or spawn.
ControlState is a compact server snapshot used to resynchronize the locally-predicted ship when it has drifted from the server's authoritative position.
Every packet from the server includes a CRC of what the server believes the client's state should be. If this CRC does not match, the server sends a ControlState blob: the authoritative position, velocity, cooldown flag, and active weapon. The client:
- Applies
readControlState()to jump its ship to the server-authoritative values. - Sets
mNeedReplayMoves = true. - Replays all still-unacknowledged pending moves (
pendingMoves) over the corrected base.
This is the correction half of predict-then-correct. The fields in ControlState were chosen to be the minimum needed to fully reseed the simulation for replay. (Energy and fast-recharge are currently commented out as an optimization.)
RPCs (Remote Procedure Calls) are discrete, typed function calls sent between client and server over the connection. They are declared with TNL_DECLARE_RPC and implemented with TNL_IMPLEMENT_RPC.
Naming convention:
s2c— server-to-client (e.g.s2cAddClient,s2cSetAuthenticated,s2cDisplayMessage)c2s— client-to-server (e.g.c2sSendChat,c2sRequestLoadout,c2sChangeTeams)s2r— server-to-recorder
RPCs are used for discrete, non-continuous events — anything that is not efficiently handled by the ghost update stream. Examples: a player joining or changing teams, a score change, a chat message, a weapon switch request, authentication results, announcement banners.
Ghost updates handle ongoing state. RPCs handle events and control-plane metadata.
Each client frame (ClientGame::idle):
- UI gathers controls into
Move(GameUserInterface::getCurrentMove). - Move time is set to frame delta.
Move::prepare()is run to match packing precision effects.- Move is enqueued via
GameConnection::addPendingMove()(inherited fromControlObjectConnection).
The client also immediately applies this move to its local controlled ship for responsive prediction.
Client writePacket() sends:
- Control CRC/state bookkeeping
- First move index + count
- Packed pending
Moves (delta-compressed against prior move)
Server readPacket():
- Unpacks incoming moves
- Applies anti-speed-cheat time credit checks (
mMoveTimeCredit) - Replays accepted moves on authoritative control object (
Ship) using server simulation path
So client sends intent, server executes authority.
Server advances authoritative object state from accepted moves.
For ships, movement, collision, weapons/modules, and game rules are resolved server-side.
Server sends ghost updates to each client for objects in scope.
For ships (Ship::packUpdate):
- Initial association data (player name + mounted items)
- Team/loadout/health/explosion/respawn flags
- Position/velocity
- Current move/module activity bits (as needed)
- Optional energy meter sync path (recording/replay use)
Clients apply with Ship::unpackUpdate:
- Resolve
ClientInfoassociation by player name on initial update - Update state and interpolation/warp decisions
- Apply module state and visual behavior
When server sends control-state correction:
- Client reads control state (
readControlState) for its controlled object. - Client marks replay needed and replays still-pending moves over corrected base state.
This gives responsive prediction with eventual server convergence.
Other clients receive the same authoritative ghost stream (subject to scope), so they see local player actions as replicated world-state changes.
GameType::performScopeQuery() determines what each connection receives:
GameTypeitself is always in scope.- Before sync completion, regular object ghosting is held back (
isReadyForRegularGhosts). - After sync, scope includes control object + nearby relevant objects + always-in-scope objects.
- Extra logic includes commander map and spy bug visibility.
This keeps bandwidth focused on objects relevant to that client’s view.
A key pattern is: metadata as RPCs, world simulation as ghosts.
Examples:
s2cAddClientcreatesRemoteClientInfoentries on clientss2cClientJoinedTeamupdates team assignment for a named clients2cClientChangedRoles, auth updates, busy/spawn-delay updates, etc.
So, if data is identity/role/UI-ish and discrete, it is usually RPC-driven. If it is ongoing spatial/simulation state, it is usually ghost-driven.
Every game object's idle(IdleCallPath path) is called once per frame. The path argument tells the object why it is being idled, so it can selectively execute only the logic appropriate for that context. Understanding this enum is essential for reading Ship::idle(), Projectile::idle(), or any other complex idle method.
| Value | Dispatched by | Objects affected |
|---|---|---|
ServerIdleMainLoop |
ServerGame::idle() — the server's main per-frame loop |
All game objects on server |
ServerProcessingUpdatesFromClient |
ControlObjectConnection::readPacket() — once per received client Move |
Control object only (human Ship) |
ClientIdlingLocalShip |
ClientGame::idle() — for the object that is the local player's ship |
Local player's Ship only |
ClientIdlingNotLocalShip |
ClientGame::idle() — for every other object |
All objects except local player's ship |
ClientReplayingPendingMoves |
ControlObjectConnection::addPendingMove() (local prediction) and readPacket() (correction replay) |
Control object only |
ServerIdleMainLoop
This is the standard server tick. Every world object gets this once per frame tick. For most objects this is the only path that triggers consequential server-side logic (damage, lifetime, firing, healing, etc.). The server sets currentMove.time = timeDelta for each object before calling idle.
For ships with a connected client this path is not where movement physics happen — that is done per-Move in ServerProcessingUpdatesFromClient. However, for ships without a controlling client (e.g. level-scripted ships, or briefly after a client disconnects), ServerIdleMainLoop also drives movement.
The server also uses this path to advance the ship's RenderState (the smooth-interpolated position that projectiles collide against), which lets other clients lead targets correctly.
ServerProcessingUpdatesFromClient
Called once for each Move that arrives from a client in a network packet. A single packet can carry several moves at once (they are batched). The server unpacks and applies them in order, including the anti-cheat time-credit check (mMoveTimeCredit).
This is where the authoritative ship movement, weapon fire, module activation, spawn shield management, and statistics accumulation happen on the server. It is strictly for human-controlled ships; Robot::idle asserts that this path is never used for bots.
ClientIdlingLocalShip
Called for the local player's ship during the client's main loop. The ship's currentMove is set to the freshly-sampled local input before this call. This path runs client-side prediction physics (weapons, modules, energy) to keep the local ship feeling responsive, but deliberately skips things that only make sense on the server (damage dealing, authoritative stats, etc.) and also skips interpolation (the ship is already at its correct predicted position).
ClientIdlingNotLocalShip
Called for every object that is not the local player's ship — remote ships, bullets, seekers, flags, teleporters, etc. currentMove.time is set to the frame delta. On this path objects typically:
- Run client-side visual effects (sparks, trails)
- Interpolate smoothly toward the last ghost-updated position
- Run parallel physics where applicable (e.g.
Projectileflight) - Skip server-only logic
ClientReplayingPendingMoves
Used in two places:
- Immediate local prediction (
addPendingMove): right after the client samples input and queues the move, it callsidle(ClientReplayingPendingMoves)on the local ship to apply that move immediately and update the client's prediction. - Correction replay (
readPacket): after receiving a server correction and callingreadControlState(), the client replays all still-unacknowledged moves with this path to resimulate the ship forward from the corrected base state.
On this path, Ship::idle runs physics and weapon/module processing (same as ClientIdlingLocalShip) but skips visual/audio effects and interpolation, because replaying physics must be fast and deterministic.
Most objects guard their logic with one of these patterns:
// Server-only, skip everything on client:
if(path != ServerIdleMainLoop)
return;
// Client visual only:
if(path == ClientIdlingNotLocalShip) {
emitSparks();
}
// Server authoritative consequence (damage, deletion):
if(!isGhost()) { /* ... */ } // isGhost() is true on all client-side copies
// Ship: physics runs on controlled paths only:
if(path == ServerProcessingUpdatesFromClient ||
path == ClientIdlingLocalShip ||
path == ClientReplayingPendingMoves)
{
processMove(ActualState);
}The key insight: the same idle() method must safely serve very different purposes depending on whether it is being called on a server simulation tick, a client prediction step, or a correction replay. The IdleCallPath is the mechanism that distinguishes these cases.
Used for controlled objects (especially ships). The goal is to make local input feel instantaneous while the server remains the authority on what actually happened.
How prediction works:
Each frame, the client applies the new Move to its local ship immediately (in ClientGame::idle), without waiting for a server round-trip. From the player's perspective, their ship responds instantly.
At the same time, the move is added to pendingMoves — a queue of moves the server has not yet acknowledged. The client keeps this queue so it can replay moves if a correction arrives.
How correction works:
Every packet from the server contains a CRC of what the server believes the client's ControlState should be. If the client's local state matches, no correction is needed. If there is a mismatch:
- The server sends the authoritative ControlState (position, velocity, cooldown, active weapon) in the same packet.
- The client calls
readControlState()to overwrite its local ship state with the server's values. - The client sets
mNeedReplayMoves = true. - After the packet is fully processed, the client iterates over all moves still in
pendingMovesand callscontrolObject->idle(ClientReplayingPendingMoves)for each one — re-simulating those inputs on top of the now-correct base.
The result: the ship snaps to the server-authoritative position and then catches up to where it should be based on inputs the server has not yet processed.
Why corrections are rare in practice:
The server's simulation and the client's prediction use the same physics code and the same Move::prepare() rounding. They diverge only when something unpredictable happens server-side — a collision, a hit, a spawn, a teleport. For straight-line movement in open space, client and server usually agree exactly.
Why pending moves stay small:
The time window of unacknowledged moves equals the one-way network latency (roughly half of round-trip time). At 100 ms RTT, the pending queue typically holds ~50 ms worth of moves. Replaying 50 ms of simulation is fast.
Use this pattern when responsiveness matters but cheating and divergence must be bounded.
Used for continuous world state:
packUpdate()sends only changed subsets via mask bits.unpackUpdate()applies partial updates safely.
Use this for efficient high-frequency object updates.
Used for bandwidth and correctness:
- Each client gets only relevant objects.
- Visibility/range/game-mode modifiers alter scope.
Use this for scalability and information control.
Used for non-continuous state:
- Join/leave/team/auth/roles/messages/etc.
Use this for explicit game events and control-plane data.
Used to separate concerns:
- Local
FullClientInfofor local config/identity context - Remote mirror (
RemoteClientInfo) for server-authoritative in-game identity record
Use this to avoid conflating pre-sync local state with in-game authoritative state.
When debugging a networking issue, classify the data first:
- Input intent? ->
Move+ControlObjectConnection - Continuous world state? -> Ghosts (
packUpdate/unpackUpdate) - Discrete metadata/event? -> RPCs (
GameType/GameConnection) - Player identity confusion on client? -> check local
FullClientInfovsmLocalRemoteClientInfo
This classification usually leads you to the right code path quickly.
zap/move.hzap/move.cppzap/controlObjectConnection.hzap/controlObjectConnection.cppzap/ship.hzap/ship.cppzap/ClientInfo.hzap/ClientInfo.cppzap/gameType.cppzap/gameConnection.cppzap/ClientGame.cppzap/moveObject.hzap/moveObject.cpp