LOG IN
Docs
By GitHub Copilot

Multiplayer Lockstep Run-Ahead

How Generals Remastered chooses its multiplayer look-ahead window and why the network layer owns the real sim rate.

Generals Remastered still runs multiplayer in lockstep. Every peer must execute the same commands on the same logic frame. Nobody gets to run "right now". Each client stays a few logic frames ahead of the last fully agreed frame, queues local commands for a future execution frame, and waits until everyone else has delivered the matching packet for that frame.

That gap is m_runAhead. It is the real input-lag budget for multiplayer. Too small and the match stalls whenever latency spikes. Too large and the game feels syrupy even on loopback. The important detail is that this number is decided by the network layer, not by the render loop, and in multiplayer the network layer also owns the effective logic rate.

How The Rate Gets Into The Match

The host-side rate starts in GameInfo. New LAN games default m_gameFps to 70 in GameInfo.cpp:323, and GameInfoToAsciiString always serializes that choice as GFPS=N; in GameInfo.cpp:949. That makes the selected logic rate part of the game options string instead of a local preference.

The actual network object still boots with older defaults. Network::init in Network.cpp:329 sets m_runAhead to MIN_RUNAHEAD and m_frameRate to 30. The handoff happens a little later in LANAPI::OnGameStart at LANAPICallbacks.cpp:185, where the freshly created TheNetwork is immediately patched with m_currentGame->getGameFps(). If that call does not happen, multiplayer quietly stays at 30 Hz.

GameLogic::startNewGame also seeds the local frame pacer in GameLogic.cpp:1123. It uses 30 for campaign and shell maps, and 70 for skirmish or multiplayer. That matters less than it looks. In FramePacer::getActualLogicTimeScaleFps at FramePacer.cpp:168, the first multiplayer check is if (TheNetwork != nullptr) return TheNetwork->getFrameRate();. Once the network exists, the frame pacer's local setting stops being authoritative. The network rate wins.

How The Window Gets Tuned

The live tuning loop starts in Network::update at Network.cpp:717. While the local player is in-game it calls m_conMgr->updateRunAhead(m_runAhead, m_frameRate, m_didSelfSlug, getExecutionFrame()). The inputs for that computation come from FrameMetrics. FrameMetrics::doPerFrameMetrics in FrameMetrics.cpp:85 counts logic ticks, not render frames, specifically so monitor refresh or a render cap does not poison the lockstep governor.

ConnectionManager::updateRunAhead in ConnectionManager.cpp gathers the current average latency and the slowest reported logic FPS, applies the configured slack percentage, and computes a new look-ahead in frames:

const Real slack = 1.0f + (Real)TheGlobalData->m_networkRunAheadSlack / 100.0f;
Int newRunAhead = ceilf(getMaximumLatency() * slack * (Real)minFps);
newRunAhead = clamp<Int>(MIN_RUNAHEAD, newRunAhead, MAX_FRAMES_AHEAD / 2);

getMaximumLatency averages the top two non-zero latencies rather than taking the true max — see the quirks section for why.

That result becomes a NetRunAheadCommandMsg. The packet router sends one copy to every player except the slowest-FPS peer, then sends a second copy to the slowest peer. The second message uses the same command ID on purpose. The comment in ConnectionManager.cpp:1340 explains why: disconnect recovery can splice command lists from different peers together, and two logically identical run-ahead commands must compare equal or the recovering client ends up with an extra command for that frame and never catches back up.

When the command lands, Network::processRunAheadCommand updates m_runAhead and m_frameRate, then converts the pair into a packet grouping interval in milliseconds:

m_runAhead = msg->getRunAhead();
m_frameRate = msg->getFrameRate();
time_t frameGrouping = (1000 * m_runAhead) / m_frameRate;
// ... halved because one-way latency, not RTT, should dominate grouping ...

That value is halved because the code wants one-way latency to dominate the grouping decision, not round-trip time.

How It Gates Local Simulation

The local machine does not advance logic just because packets are ready. Network::update first checks AllCommandsReady(TheGameLogic->getFrame()), then calls Network::timeForNewFrame. That second check is the local pacing gate.

timeForNewFrame compares the current performance-counter time against m_nextFrameTime, using m_frameRate as the target cadence. It also watches the packet cushion. If the minimum cushion falls below the configured slack percentage of m_runAhead, it temporarily increases frameDelay by 10 percent and marks m_didSelfSlug = TRUE. That is a short-term brake. It lets a client stop outrunning the rest of the session before lockstep hard-stalls.

The next metrics window treats that flag specially. ConnectionManager::updateRunAhead reports the configured frameRate instead of the measured local FPS when didSelfSlug is true. That looks odd until you read the comment: using the slower measured FPS would feed the governor its own emergency slowdown and create a one-way ratchet toward the floor.

Quirks

  • MIN_RUNAHEAD in NetworkUtil.cpp:30 is the real feel floor. Network::init starts every match there before the first metrics tick, so changing that constant changes input lag from frame 1. It's currently tuned at 2 — one of the most load-bearing integers in the entire networking stack. Setting it to 1 produces unplayable jitter on any real-world connection; setting it to 4+ adds visible command lag on loopback. See netcode_runahead_tuning.
  • getMaximumLatency() is not actually the worst single latency. It averages the top two non-zero latencies. That makes the governor less jumpy, but it also means one catastrophic peer does not fully dictate the window by itself. A player on a half-second connection drags the whole match into syrupy territory regardless of how good the other links are — two slow peers produce the same window as one.
  • The current code heavily favors the host's GFPS. updateRunAhead floors minFps at TheGameInfo->getGameFps(), and the slow-player bonus is capped there too, so most live tuning happens in m_runAhead and packet grouping rather than in the advertised shared tick rate. Bumping host GFPS to 70 buys significant pacing headroom even without raising run-ahead.
  • The pair of run-ahead commands is intentional, not duplicate traffic. One excludes the slowest peer. The other targets that peer specifically, and both reuse the same command ID so disconnect recovery does not manufacture a phantom extra command. Deduplicating them "to save a packet" silently breaks reconnection.
  • In multiplayer, the frame pacer is a mirror of the network. If a pacing bug only appears when TheNetwork exists, looking at setLogicTimeScaleFps alone will send you in the wrong direction — the if (TheNetwork != nullptr) return TheNetwork->getFrameRate() short-circuit wins over any local setting.
  • m_didSelfSlug is a one-shot flag with telemetry-side effects. Setting it triggers a 10% frameDelay bump on the local machine AND makes updateRunAhead report the configured frameRate instead of the measured local FPS in the next metrics window. Without that substitution, the governor would feed itself the emergency slowdown and spiral into a one-way ratchet toward the floor.