Sam Afshari's Notes
  • GitHub
  • Twitter/X
  • All posts
  • Ed
  • NuGets
  • POIWorld
  • RedCorners

How Lockstep Multiplayer Works in Generals64 - Mon, Jul 20, 2026

Generals64 keeps the multiplayer model used by Command & Conquer: Generals and Zero Hour: every player runs the full simulation, and the network distributes commands rather than world state.

There is no authoritative server sending unit positions, projectile states, or economy snapshots to clients. If I order five tanks to move, the network sends the order. Every machine then executes that order on the same logic frame and independently reaches the same result.

That sounds simple, but it gives the networking code a strict job. It must make sure every player has the same commands, executes them in the same order, uses the same random state, and does not advance until everyone is ready.

That is lockstep.

Commands replace state replication

A modern action game commonly sends snapshots and corrects clients when their local state drifts. Generals64 does almost the opposite. The synchronized unit is a GameMessage.

Commands generated by the UI enter the message stream. The network recognizes messages in the network range, wraps each one in a NetGameCommandMsg, assigns the local player and a unique command ID, and schedules it for a future logic frame:

Int Network::getExecutionFrame() {
    Int logicFrame = TheGameLogic->getFrame() + m_runAhead;
    if (logicFrame > m_lastExecutionFrame)
        m_lastExecutionFrame = logicFrame;
    return m_lastExecutionFrame;
}

The run-ahead is intentional input delay. If the simulation is on frame 1,000 and the run-ahead is four, a new order is assigned to frame 1,004. That gives the command time to reach every other machine before any of them needs to execute it.

sendLocalGameMessage() fills in the rest of the envelope:

NetCommandMsg *netmsg = newInstance(NetGameCommandMsg)(msg);
netmsg->setExecutionFrame(frame);
netmsg->setPlayerID(m_localSlot);
netmsg->setID(currentID);
sendLocalCommand(netmsg);

The receiving side stores commands by player and execution frame. It does not execute them as they arrive. Network arrival order is irrelevant.

A frame is complete when the counts match

The useful part of this protocol is how a peer knows it has received everything for a frame.

At the point where a local frame can no longer accept new commands, each player sends a NetFrameCommandMsg. It contains the frame number and the number of commands that player submitted for it:

UnsignedShort count = m_frameData[m_localSlot]->getCommandCount(frame);

NetFrameCommandMsg *msg = newInstance(NetFrameCommandMsg);
msg->setExecutionFrame(frame);
msg->setCommandCount(count);
msg->setPlayerID(m_localSlot);

This message means, in effect, “I am finished with frame N, and you should have K commands from me.”

Each peer keeps a FrameDataManager for every human slot. A frame is ready for one player only when the declared count equals the number actually received:

if (m_frameCommandCount == m_commandCount)
    return FRAMEDATA_READY;

if (m_commandCount > m_frameCommandCount)
    return FRAMEDATA_RESEND;

return FRAMEDATA_NOTREADY;

The game checks this for every active player. If any one of them is not ready, the simulation does not advance. The networking code continues receiving and retransmitting packets while the renderer can keep drawing, but the next logic tick waits.

Once all frame data is present, the network assembles the command list and opens the gate:

if (AllCommandsReady(TheGameLogic->getFrame())) {
    if (timeForNewFrame()) {
        RelayCommandsToCommandList(TheGameLogic->getFrame());
        m_frameDataReady = TRUE;
    }
}

The main game loop advances only when isFrameDataReady() returns true. Generals64 also keeps rendering separate from this lockstep gate and interpolates visual transforms between logic ticks. Network logic can remain fixed and deterministic without making camera movement and unit animation look like they are limited to the network rate.

Every peer must use the same order

Receiving the same set of commands is not enough. Every machine must process that set in the same order.

NetCommandList sorts commands first by network command type, then by player ID, then by the command's sort number, normally its unique command ID. Duplicates are rejected using the originating player and command ID.

That ordering matters whenever two players issue commands that interact during the same logic frame. Arrival timing cannot be allowed to decide which command wins. One peer may receive player 2's order first and another may receive player 0's order first, but both construct the same sorted list before handing it to game logic.

The result is a deterministic transaction for each frame:

  1. Collect commands from every player.
  2. Confirm the expected command counts.
  3. Sort the combined list deterministically.
  4. Execute the list on every machine.
  5. Advance the simulation by one logic tick.

UDP, acknowledgements, and the packet router

The normal transport is UDP. Generals64 keeps packets below a conservative payload limit of 1,100 bytes to reduce fragmentation, and each transport packet carries its own CRC and the 0xF00D Generals magic value. There is also a separate LANAPI relay mode that carries the same destination-addressed packets through a TCP relay. That transport choice is independent of the lockstep and client-server simulation topology.

UDP does not provide delivery guarantees, so the connection layer adds them where the game needs them. Game commands, frame information, run-ahead changes, player-leave messages, and several control commands require acknowledgements. A Connection retains those messages after sending and retries them until the matching acknowledgement removes them from the queue.

Multiplayer also has a packet-router role. In the normal peer-to-peer topology, a non-router sends a command to the router with a recipient bitmask. The router adds the command to its own frame data and fans it out to the other players. The router is a traffic coordinator, not an authoritative simulation server.

Generals64 also supports a client-server network topology. In that mode, clients maintain a direct connection only to the host, while still keeping frame data for every human player. The host relays commands between them. The topology changes, but the simulation contract does not: every machine still runs the world and waits for the same lockstep command set.

The two-stage acknowledgement path follows the relay. The first stage confirms that the router received a command. The second confirms that the required recipients received the relayed copies. This lets the original sender retain pending commands if the router disappears halfway through forwarding them.

Recovering missing frame data

Normal packet loss is handled by command retries. There is also a frame-level recovery path.

If a peer receives more commands than the announced count, it clears the inconsistent frame and requests it again. Missing commands normally arrive through the acknowledgement and retry path. Generals64 keeps recent frame data in a ring buffer instead of discarding it immediately, so a peer can resend the stored commands and the corresponding NetFrameCommandMsg for each player.

for (UnsignedInt frame = startingFrame;
     frame < TheGameLogic->getFrame(); ++frame) {
    sendSingleFrameToPlayer(playerID, frame);
}

Keeping recent frames also helps when the packet router leaves. In peer-to-peer mode, the next slot in the shared fallback order becomes router, pending commands are sent again, and peers can exchange recent frame data to catch up. No world snapshot needs to migrate because every surviving machine already owns the world.

Client-server mode is deliberately different. If its server leaves, the match ends because clients do not have the full connection mesh needed for router promotion.

Run-ahead is the latency budget

A lockstep game needs enough lead time for commands to arrive, but every extra frame of lead is added input latency.

The packet router measures round-trip latency and reported simulation throughput. It computes run-ahead from its latency estimate, a configurable slack percentage, and the current logic frame rate:

const Real runAheadSlackScale = 1.0f +
    ((Real)TheGlobalData->m_networkRunAheadSlack / 100.0f);

Int newRunAhead = ceilf(
    getMaximumLatency() * runAheadSlackScale * (Real)minFps);

newRunAhead = clamp<Int>(
    MIN_RUNAHEAD, newRunAhead, MAX_FRAMES_AHEAD / 2);

A run-ahead change is itself a synchronized network command. Everyone applies the new value on the same execution frame. This avoids one client scheduling new orders against a different future-frame window than the others.

There is also a packet-arrival cushion. If commands are arriving too close to their execution frame, the local pacing code temporarily slows the logic rate to let the slow peer recover. This is why lockstep performance is tied to the slowest participant. The simulation can only advance as fast as every participant can produce and receive its frame data.

Determinism starts with the seed

Command lockstep works only if the simulation is deterministic.

The lobby distributes one game seed. At game start, every peer initializes the game random generator from that same value:

InitRandom(m_currentGame->getSeed());

From there, game-visible randomness must come from the synchronized generator in the same call order. The CRC calculation includes the game-logic random seed, so an extra random call on one machine becomes visible as a state mismatch.

The same rule applies to object iteration, AI, scripts, pathfinding, floating-point behavior, and container ordering. A network bug can cause a desync, but ordinary gameplay code can cause one just as easily if it produces a different result from identical input.

This is one of the difficult parts of moving an old deterministic simulation to 64-bit code. Lockstep exposes assumptions that a single-player run may never reveal.

The logic CRC detects divergence

Generals64 regularly walks the simulation and calculates a logic CRC. The input includes objects, the random seed, game logic, terrain, the partition manager, players, AI, scripts, sides, and the cave system.

The result is sent as another synchronized GameMessage. The current default checks every logic frame. Debug builds expose -NetCRCInterval for changing that interval:

m_CRC = getCRC(CRC_RECALC);
GameMessage *msg = newInstance(GameMessage)(
    GameMessage::MSG_LOGIC_CRC);
msg->appendIntegerArgument(m_CRC);
msg->appendBooleanArgument(isPlayback);
GameMessageList *messageList = TheMessageStream;
messageList->appendMessage(msg);

Because CRC messages travel through the same lockstep command path, every peer compares values representing the same simulation point. After processing the frame's command list, the game verifies that it received one CRC from every connected player and that all values match.

std::map<Int, UnsignedInt>::const_iterator crcIt =
    m_cachedCRCs.begin();
Int validatorCRC = crcIt->second;

while (++crcIt != m_cachedCRCs.end()) {
    if (validatorCRC != crcIt->second)
        sawCRCMismatch = TRUE;
}

A mismatch does not repair the world. At that point the simulations have already diverged, and there is no authoritative state to choose as correct. Instead, Generals64 records the failure, opens the mismatch flow, and writes additional diagnostics. The repository also includes a desync simulation harness that injects controlled divergence and checks the logging pipeline.

The packet CRC and logic CRC solve different problems. The packet CRC detects damaged network data. The logic CRC detects two valid command streams producing different worlds.

The tradeoff

Lockstep is a good fit for an RTS because player intent is much smaller than simulation state. A move order may be a few fields, while the result can involve hundreds of units, projectiles, pathfinding decisions, and economy updates. Sending commands saves bandwidth and makes replays natural because a replay is largely the initial state plus the same ordered command stream.

The cost is strict coupling. One slow peer can stall everyone. Input needs enough run-ahead to survive network latency. Every gameplay system must remain deterministic. A desync cannot be corrected by accepting a server snapshot.

Generals64's multiplayer code is therefore less about synchronizing objects and more about protecting a sequence. It schedules commands into future frames, confirms the exact count from every player, sorts them identically, retries lost data, and checks that the resulting worlds still match.

As long as that sequence remains identical, every machine can simulate the battle locally and arrive at the same result without sending the battle itself over the network.

Back to Home


© Sam Afshari 2026