Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

03 — Network Architecture

Our Netcode

Iron Curtain ships one default gameplay netcode today: relay-assisted deterministic lockstep with sub-tick order fairness. This is the recommended production path, not a buffet of equal options in the normal player UX. The NetworkModel trait still exists for more than testing: it lets us run single-player and replay modes cleanly, support multiple deployments (dedicated relay / embedded relay), and preserve the ability to introduce deferred compatibility bridges or replace the default netcode under explicitly deferred milestones (for example M7+ interop experiments or M11 optional architecture work) if evidence warrants it (e.g., cross-engine interop experiments, architectural flaws discovered in production). Those paths require explicit decision/tracker placement and are not part of M4 exit criteria.

Scope note: in this chapter, “P2P” refers only to direct gameplay transport (a deferred/optional mode), not Workshop/content distribution. Workshop P2P remains in scope via D049/D074.

Keywords: netcode, relay lockstep, NetworkModel, sub-tick timestamps, reconnection, desync debugging, replay determinism, compatibility bridge, ranked authority, relay server

Key influences:

  • Counter-Strike 2 — sub-tick timestamps for order fairness
  • C&C Generals/Zero Hour — adaptive run-ahead, frame resilience, delta-compressed wire format, disconnect handling
  • Valve GameNetworkingSockets (GNS) — ack vector reliability, message lanes with priority/weight, per-ack RTT measurement, pluggable signaling, transport encryption, Nagle-style batching (see research/valve-github-analysis.md)
  • OpenTTD — multi-level desync debugging, token-based liveness, reconnection via state transfer
  • Minetest — time-budget rate control (LagPool), half-open connection defense
  • OpenRA — what to avoid: TCP stalling, static order latency, shallow sync buffers
  • Bryant & Saiedian (2021) — state saturation taxonomy, traffic class segregation

The Protocol

All protocol types live in the ic-protocol crate — the ONLY shared dependency between sim and net:

#![allow(unused)]
fn main() {
#[derive(Clone, Serialize, Deserialize, Hash)]
pub enum PlayerOrder {
    // === Game-agnostic core (all RTS game modules use these) ===
    Move { units: Vec<UnitTag>, target: WorldPos },
    Attack { units: Vec<UnitTag>, target: Target },
    Stop { units: Vec<UnitTag> },
    Idle,  // Explicit no-op — keeps player in the tick's order list for timing/presence
    ChatMessage { channel: ChatChannel, text: String },  // D059 — display/replay only, no game state effect
    ChatCommand { cmd: String, args: Vec<String> },       // Mod-registered sim commands (D058)
    CheatCode(CheatId),                                   // Hidden cheat activation (D058)
    SetCvar { name: String, value: String },              // DEV_ONLY/SERVER cvar mutation
    Vote(VoteOrder),                                      // Surrender, kick, remake, draw, custom (vote-framework.md)
    // === Game-module extensibility (D018/D039, multi-game.md rule 7) ===
    // Game modules register their own order types via this variant.
    // The payload is serde-serialized by the game module and deserialized
    // by its OrderValidator (D041). The engine core routes these opaquely.
    // RA1 examples: Build, Sell, SetRallyPoint, Deploy, Stance, etc.
    GameOrder(GameSpecificOrder),
}

/// Opaque game-module order. The engine core does not inspect this;
/// the game module's OrderValidator (D041) deserializes and validates it.
/// `type_tag` identifies the order kind (game module assigns its own IDs).
/// `payload` is the serde-serialized order data.
#[derive(Clone, Serialize, Deserialize, Hash)]
pub struct GameSpecificOrder {
    pub type_tag: u16,
    pub payload: Vec<u8>,
}

/// UnitTag is the stable external entity identity (02-ARCHITECTURE.md § External
/// Entity Identity). Generational index into a fixed-size pool — deterministic,
/// cheap (4 bytes), safe across save/load/network boundaries. Bevy Entity is
/// NEVER serialized into orders or replays.
/// See also: type-safety.md § Newtype Policy.

/// Sub-tick timestamp on every order (CS2-inspired, see below).
/// In relay modes this is a client-submitted timing hint that the relay
/// normalizes/clamps before broadcasting canonical TickOrders.
#[derive(Clone, Serialize, Deserialize)]
pub struct TimestampedOrder {
    pub player: PlayerId,
    pub order: PlayerOrder,
    pub sub_tick_time: u32,  // microseconds within the tick window (0 = tick start)
}
// NOTE: sub_tick_time is an integer (microseconds offset from tick start).
// At 15 ticks/sec the tick window is ~66,667µs — u32 is more than sufficient.
// Integer ordering avoids any platform-dependent float comparison behavior
// and keeps ic-protocol free of floating-point types entirely.
//
// Authentication note: TimestampedOrder is the sim-level type (ic-protocol).
// The transport layer in ic-net wraps each order in an AuthenticatedOrder
// (Ed25519 signature from a per-session ephemeral keypair) before
// transmission. The relay verifies signatures before forwarding.
// See vulns-protocol.md § Vulnerability 16 for the signing scheme and
// system-wiring.md for the integration site.
//
// Chat routing note: ChatMessage orders are per-recipient filtered by the
// relay based on ChatChannel (D059). Team chat goes only to same-team clients,
// Whisper only to the target. This is safe because ChatMessage does not affect
// game state — the sim records it for replay but makes no state-changing
// decisions. Sync hashes cover game state only. The relay's order_stream_hash
// (V13 certification) covers the full pre-filtering stream; per-recipient
// filtering happens after hashing. See relay-architecture.md.

pub struct TickOrders {
    pub tick: SimTick,
    pub orders: Vec<TimestampedOrder>,
}

impl TickOrders {
    /// CS2-style: process in chronological order within the tick.
    /// Uses a caller-provided scratch buffer to avoid per-tick heap allocation.
    /// The buffer is cleared and reused each tick (see TickScratch pattern in 10-PERFORMANCE.md).
    /// Tie-break by player ID so equal timestamps remain deterministic if a
    /// deferred non-relay mode is ever enabled. Relay modes already emit
    /// canonical normalized timestamps, but the helper remains safe.
    pub fn chronological<'a>(&'a self, scratch: &'a mut Vec<&'a TimestampedOrder>) -> &'a [&'a TimestampedOrder] {
        scratch.clear();
        scratch.extend(self.orders.iter());
        scratch.sort_by_key(|o| (o.sub_tick_time, o.player));
        scratch.as_slice()
    }
}
}