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

System Wiring: Integration Proof

This section proves that all netcode components defined above wire together into a coherent system. Every type referenced below is either defined earlier in this chapter, in a cross-referenced file, or newly introduced here to fill an explicit gap.

Existing types referenced (not redefined):

TypeDefined InCrate
PlayerOrder, TimestampedOrder, TickOrders, PlayerIdThis chapter § “The Protocol”ic-protocol
NetworkModel traitThis chapter § “The NetworkModel Trait”ic-net
RelayCoreThis chapter § “RelayCore: Library, Not Just a Binary”ic-net
ClientMetricsThis chapter § “Adaptive Run-Ahead”ic-net
TimingFeedbackThis chapter § “Input Timing Feedback”ic-net
MatchCalibration, PlayerCalibration, MatchQosProfileThis chapter § “Match-Start Calibration”ic-net
QosAdjustmentEventThis chapter § “QoS Audit Trail”ic-net
ClockCalibrationresearch/relay-wire-protocol-design.mdic-net
TransportCryptoThis chapter § “Transport Encryption”ic-net
OrderBatcherThis chapter § “Order Batching”ic-net
AckVectorThis chapter § “Selective Acknowledgment”ic-net
DesyncDebugLevelThis chapter § “Desync Debugging”ic-net
Transport traitdecisions/09d/D054-extended-switchability.mdic-net
RelayLockstepNetwork<T> (struct header)decisions/09d/D054-extended-switchability.mdic-net
GameLoop<N, I> (struct + frame())architecture/game-loop.mdic-game
ReadyCheckState, MatchOutcomenetcode/match-lifecycle.mdic-net

ClientMetrics / PlayerMetrics Resolution

ClientMetrics (defined above in § “Adaptive Run-Ahead”) is the client-submitted report — it carries what the client knows about its own performance. The relay combines this with relay-observed timing data to produce PlayerMetrics, which is what compute_run_ahead() and QoS adaptation operate on:

#![allow(unused)]
fn main() {
/// Relay-side per-player metrics — combines client-reported ClientMetrics
/// with relay-observed timing data. Lives in ic-net (relay_core module).
/// compute_run_ahead() and evaluate_qos_window() operate on this.
pub struct PlayerMetrics {
    // From ClientMetrics (client-reported):
    pub avg_latency_us: u32,
    pub avg_fps: u16,
    pub arrival_cushion: i16,
    pub tick_processing_us: u32,
    // Relay-observed (not client-reported):
    pub jitter_us: u32,                // computed from arrival time variance
    pub late_count_window: u16,        // late orders in current QoS window
    pub ewma_late_rate_bps: u16,       // EWMA-smoothed late rate (basis points)
}

impl PlayerMetrics {
    /// Merge a fresh ClientMetrics report into this relay-side aggregate.
    fn update_from_client(&mut self, cm: &ClientMetrics) {
        self.avg_latency_us = cm.avg_latency_us;
        self.avg_fps = cm.avg_fps;
        self.arrival_cushion = cm.arrival_cushion;
        self.tick_processing_us = cm.tick_processing_us;
    }
}
}

CalibrationPing / CalibrationPong

These lightweight packet types are exchanged during the loading screen (§ “Match-Start Calibration” steps 1–2). 15–20 round trips over ~2 seconds, in parallel with map loading:

#![allow(unused)]
fn main() {
/// Sent by relay during loading screen. Lightweight timing probe.
pub struct CalibrationPing {
    pub seq: u16,                // sequence number (0..19)
    pub relay_send_us: u64,      // relay wall-clock at send (microseconds)
}

/// Client response. Echoes relay timestamp, adds client-side timing.
pub struct CalibrationPong {
    pub seq: u16,                // echoed from ping
    pub relay_send_us: u64,      // echoed from ping
    pub client_recv_us: u64,     // client wall-clock at receive
    pub client_send_us: u64,     // client wall-clock at send (captures processing delay)
}
}

The relay derives median_rtt_us, jitter_us, and estimated_one_way_us per player from these samples, then feeds them into MatchCalibration (§ “Match-Start Calibration” step 3).

EncryptedTransport<T>: Transport Encryption Wrapper

TransportCrypto (defined in § “Transport Encryption”) wraps any Transport implementation (D054), sitting between Transport and NetworkModel as described in the prose:

#![allow(unused)]
fn main() {
/// Encryption layer between Transport and NetworkModel.
/// Wraps any Transport, encrypting outbound and decrypting inbound.
/// MemoryTransport (testing) and LocalNetwork (single-player) skip this.
pub struct EncryptedTransport<T: Transport> {
    inner: T,
    crypto: TransportCrypto,  // defined in § "Transport Encryption"
}

impl<T: Transport> Transport for EncryptedTransport<T> {
    fn send(&mut self, data: &[u8]) -> Result<(), TransportError> {
        let ciphertext = self.crypto.encrypt(data)?;  // AES-256-GCM
        self.inner.send(&ciphertext)
    }

    fn recv(&mut self, buf: &mut [u8]) -> Result<Option<usize>, TransportError> {
        let mut cipher_buf = [0u8; MAX_PACKET_SIZE];
        match self.inner.recv(&mut cipher_buf)? {
            None => Ok(None),
            Some(len) => {
                let plaintext = self.crypto.decrypt(&cipher_buf[..len])?;
                buf[..plaintext.len()].copy_from_slice(&plaintext);
                Ok(Some(plaintext.len()))
            }
        }
    }

    fn max_payload(&self) -> usize {
        self.inner.max_payload() - AEAD_OVERHEAD  // 16-byte tag
    }

    fn connect(&mut self, target: &Endpoint) -> Result<(), TransportError> {
        self.inner.connect(target)?;
        // X25519 key exchange → derive AES-256-GCM session key
        // Ed25519 identity binding (D052) signs handshake transcript
        self.crypto = TransportCrypto::negotiate(&mut self.inner)?;
        Ok(())
    }

    fn disconnect(&mut self) { self.inner.disconnect(); }
}
}

RelayLockstepNetwork<T>: NetworkModel Implementation

The struct header is defined in D054. Here are the method bodies proving NetworkModel integration with Transport, the order batcher, frame decoding, and timing feedback:

#![allow(unused)]
fn main() {
/// Full fields for the client-side relay connection.
/// Struct header is in D054; fields shown here for integration clarity.
pub struct RelayLockstepNetwork<T: Transport> {
    transport: T,
    codec: NativeCodec,                          // OrderCodec impl (§ "Wire Format")
    batcher: OrderBatcher,                       // § "Order Batching"
    reliability: AckVector,                      // processed at packet header level (wire-format.md), not as Frame
    session_auth: ClientSessionAuth,                   // per-session Ed25519 signing (vulns-protocol.md V16)
    inbound_ticks: VecDeque<TickOrders>,         // confirmed ticks from relay
    submit_offset_us: i32,                       // adjusted by TimingFeedback
    status: NetworkStatus,
    diagnostics: NetworkDiagnostics,
    // Desync debug: if the relay requests a debug report, store the request
    // so the game loop can collect sim state and respond.
    pending_desync_request: Option<(SimTick, DesyncDebugLevel)>,
    // Post-game frame storage (consumed by run_post_game, not by poll_tick caller):
    pending_credentials: Vec<SignedCredentialRecord>,
    chat_inbox: VecDeque<ChatNotification>,
}

impl<T: Transport> NetworkModel for RelayLockstepNetwork<T> {
    fn submit_order(&mut self, order: TimestampedOrder) {
        // Apply timing feedback offset to submission time.
        // submit_offset_us is adjusted by apply_timing_feedback() based on
        // relay-reported arrival timing. A negative offset shifts submission
        // earlier (orders were arriving late); a positive offset relaxes it.
        let adjusted_sub_tick = (order.sub_tick_time as i32 + self.submit_offset_us)
            .max(0) as u32;
        let adjusted = TimestampedOrder {
            sub_tick_time: adjusted_sub_tick,  // hint only — relay normalizes
            ..order
        };
        // Sign with per-session ephemeral Ed25519 key (V16 defense-in-depth).
        // AuthenticatedOrder wraps TimestampedOrder + signature at the
        // transport layer (ic-net), not in ic-protocol.
        let authenticated = self.session_auth.sign_order(&adjusted);
        self.batcher.push(authenticated);
    }

    fn poll_tick(&mut self) -> Option<TickOrders> {
        // 1. Flush batched outbound orders
        if self.batcher.should_flush() {
            let batch = self.batcher.drain();
            let encoded = self.codec.encode_frame(&Frame::OrderBatch(batch));
            self.transport.send(&encoded).ok();
        }

        // 2. Receive from transport (non-blocking)
        let mut buf = [0u8; MAX_PACKET_SIZE];
        while let Ok(Some(len)) = self.transport.recv(&mut buf) {
            let frame = match self.codec.decode_frame(&buf[..len]) {
                Ok(f) => f,
                Err(_) => continue, // skip malformed frames (vulns-protocol.md)
            };
            match frame {
                Frame::TickOrders(tick_orders) => {
                    self.inbound_ticks.push_back(tick_orders);
                }
                Frame::TimingFeedback(fb) => {
                    self.apply_timing_feedback(fb);
                }
                Frame::DesyncDetected { tick } => {
                    self.status = NetworkStatus::DesyncDetected(tick);
                }
                Frame::DesyncDebugRequest { tick, level } => {
                    // Relay requests desync debug data (desync-recovery.md §
                    // Desync Log Transfer Protocol). Store the request; the
                    // game loop collects sim state via take_desync_request()
                    // and responds with send_desync_report().
                    self.pending_desync_request = Some((tick, level));
                }
                Frame::MatchEnd(outcome) => {
                    self.status = NetworkStatus::MatchCompleted(outcome);
                }
                Frame::CertifiedMatchResult(result) => {
                    // Relay sends this after MatchEnd with Ed25519-signed certificate.
                    // Transitions MatchCompleted → PostGame, preserving the full
                    // certificate (hashes, players, duration, signature) for stats
                    // display and ranked result submission.
                    self.status = NetworkStatus::PostGame(result);
                }
                Frame::RatingUpdate(scrs) => {
                    // Community-server-signed SCRs delivered during post-game.
                    // Typically: rating SCR + match record SCR (D052).
                    self.pending_credentials.extend(scrs);
                }
                Frame::ChatNotification(msg) => {
                    // Post-game / lobby chat, system messages, player status.
                    self.chat_inbox.push_back(msg);
                }
                _ => {}
            }
        }

        // 3. Return next confirmed tick
        self.inbound_ticks.pop_front()
    }

    fn report_sync_hash(&mut self, tick: SimTick, hash: SyncHash) {
        let encoded = self.codec.encode_frame(&Frame::SyncHash { tick, hash });
        self.transport.send(&encoded).ok();
    }

    fn report_state_hash(&mut self, tick: SimTick, hash: StateHash) {
        let encoded = self.codec.encode_frame(&Frame::StateHash { tick, hash });
        self.transport.send(&encoded).ok();
    }

    fn status(&self) -> NetworkStatus { self.status.clone() }
    fn diagnostics(&self) -> NetworkDiagnostics { self.diagnostics.clone() }
}

/// Relay-specific accessors — not part of the NetworkModel trait.
/// LocalNetwork and ReplayPlayback never produce these frames.
impl<T: Transport> RelayLockstepNetwork<T> {
    /// Take all pending credential records (rating SCR + match record SCR).
    pub fn take_credentials(&mut self) -> Vec<SignedCredentialRecord> {
        std::mem::take(&mut self.pending_credentials)
    }
    /// Drain all pending chat/system notifications since last call.
    pub fn drain_chat(&mut self) -> impl Iterator<Item = ChatNotification> + '_ {
        self.chat_inbox.drain(..)
    }
    /// Take a pending desync debug request, if the relay sent one.
    /// The game loop calls this after poll_tick(), collects the requested
    /// sim state (desync-recovery.md § DesyncDebugReport), and responds
    /// via send_desync_report().
    pub fn take_desync_request(&mut self) -> Option<(SimTick, DesyncDebugLevel)> {
        self.pending_desync_request.take()
    }
    /// Send a desync debug report back to the relay.
    pub fn send_desync_report(&mut self, report: DesyncDebugReport) {
        let encoded = self.codec.encode_frame(&Frame::DesyncDebugReport(report));
        self.transport.send(&encoded).ok();
    }
    /// Send out-of-band chat (post-game/lobby). Accepts ChatMessage
    /// (client → relay type), not ChatNotification (relay → client type).
    /// The relay stamps the sender field and broadcasts as
    /// ChatNotification::PlayerChat. In-match chat flows as
    /// PlayerOrder::ChatMessage through submit_order() — see D059.
    pub fn send_chat(&mut self, msg: ChatMessage) {
        let encoded = self.codec.encode_frame(&Frame::Chat(msg));
        self.transport.send(&encoded).ok();
    }
}

impl<T: Transport> RelayLockstepNetwork<T> {
    /// Adjust local order submission timing based on relay feedback.
    fn apply_timing_feedback(&mut self, fb: TimingFeedback) {
        if fb.late_count > 0 {
            // Orders arriving late — shift submission earlier
            self.submit_offset_us -= fb.recommended_offset_us.min(5_000);
        } else if fb.early_us > 20_000 {
            // Orders arriving very early — relax by 1ms
            self.submit_offset_us += 1_000;
        }
        self.diagnostics.last_timing_feedback = Some(fb);
    }
}
}

RelayCore: Accepting Calibration and Seeding State

Shows MatchCalibration (§ “Match-Start Calibration”) entering RelayCore, proving the calibration → relay wiring:

#![allow(unused)]
fn main() {
impl RelayCore {
    /// Called after calibration handshake completes, before tick 0.
    /// Seeds all adaptive algorithms with match-specific measurements
    /// instead of generic defaults.
    pub fn apply_calibration(&mut self, cal: MatchCalibration) {
        // Seed per-player clock calibration from CalibrationPing/Pong results
        for pc in &cal.per_player {
            self.clock_calibration.insert(pc.player, ClockCalibration {
                offset_us: pc.estimated_one_way_us as i64,
                ewma_offset_us: pc.estimated_one_way_us as i64,
                jitter_us: pc.jitter_us,
                sample_count: 0,
                last_update_us: 0,
                suspicious_count: 0,
            });
            // Seed per-player submit offset (timing assist, not fairness)
            self.player_metrics.insert(pc.player, PlayerMetrics {
                avg_latency_us: pc.median_rtt_us / 2,
                jitter_us: pc.jitter_us,
                ..Default::default()
            });
        }

        // Set match-global timing from calibration envelope
        self.run_ahead = cal.shared_initial_run_ahead;
        self.tick_deadline_us = cal.initial_tick_deadline_us;
        self.qos_profile = cal.qos_profile;

        // Initialize QoS adaptation state
        self.qos_state = QosAdaptationState::default();
    }
}
}

QoS Adaptation: State and Per-Window Evaluation

Who holds the EWMA state and what triggers the adaptation loop:

#![allow(unused)]
fn main() {
/// Held by RelayCore. Updated every timing_feedback_interval ticks (default 30).
pub struct QosAdaptationState {
    pub ewma_late_rate_bps: u16,       // EWMA-smoothed late rate (basis points)
    pub consecutive_raise_windows: u8,  // windows above raise threshold
    pub consecutive_lower_windows: u8,  // windows below lower threshold
    pub cooldown_remaining: u8,         // windows until next adjustment allowed
}

impl RelayCore {
    /// Called every timing_feedback_interval ticks.
    /// Evaluates whether to adjust match-global run-ahead / tick deadline.
    /// Returns a QosAdjustmentEvent if an adjustment was made (for replay + telemetry).
    fn evaluate_qos_window(&mut self) -> Option<QosAdjustmentEvent> {
        let qp = &self.qos_profile;

        // 1. Compute raw late rate across all players this window
        let raw_late_bps = if self.window_total_orders > 0 {
            ((self.window_total_late as u32) * 10_000
                / (self.window_total_orders as u32)) as u16
        } else { 0 };

        // 2. EWMA smooth (fixed-point: alpha = ewma_alpha_q15 / 32768)
        let alpha = qp.ewma_alpha_q15 as u32;
        let qs = &mut self.qos_state;
        qs.ewma_late_rate_bps = ((alpha * raw_late_bps as u32
            + (32768 - alpha) * qs.ewma_late_rate_bps as u32) / 32768) as u16;

        // 3. Cooldown check
        if qs.cooldown_remaining > 0 {
            qs.cooldown_remaining -= 1;
            self.reset_window_counters();
            return None;
        }

        let old_deadline = self.tick_deadline_us;
        let old_run_ahead = self.run_ahead;

        // 4. Raise check: EWMA above threshold for N consecutive windows
        if qs.ewma_late_rate_bps > qp.raise_late_rate_bps {
            qs.consecutive_raise_windows += 1;
            qs.consecutive_lower_windows = 0;
            if qs.consecutive_raise_windows >= qp.raise_windows {
                self.tick_deadline_us = (self.tick_deadline_us + 10_000)
                    .min(qp.deadline_max_us);
                self.run_ahead = (self.run_ahead + 1)
                    .min(qp.run_ahead_max_ticks);
                qs.cooldown_remaining = qp.cooldown_windows;
                qs.consecutive_raise_windows = 0;
            }
        }
        // 5. Lower check: EWMA below threshold for N consecutive windows
        else if qs.ewma_late_rate_bps < qp.lower_late_rate_bps {
            qs.consecutive_lower_windows += 1;
            qs.consecutive_raise_windows = 0;
            if qs.consecutive_lower_windows >= qp.lower_windows {
                self.tick_deadline_us = (self.tick_deadline_us - 5_000)
                    .max(qp.deadline_min_us);
                self.run_ahead = (self.run_ahead - 1)
                    .max(qp.run_ahead_min_ticks);
                qs.cooldown_remaining = qp.cooldown_windows;
                qs.consecutive_lower_windows = 0;
            }
        } else {
            qs.consecutive_raise_windows = 0;
            qs.consecutive_lower_windows = 0;
        }

        self.reset_window_counters();

        // 6. Emit event if anything changed
        if self.tick_deadline_us != old_deadline || self.run_ahead != old_run_ahead {
            Some(QosAdjustmentEvent {
                tick: self.tick,
                old_deadline_us: old_deadline,
                new_deadline_us: self.tick_deadline_us,
                old_run_ahead,
                new_run_ahead: self.run_ahead,
                late_rate_bps_ewma: qs.ewma_late_rate_bps,
                reason: if self.run_ahead > old_run_ahead {
                    QosAdjustReason::RaiseLateRate
                } else {
                    QosAdjustReason::LowerStableWindow
                },
            })
        } else { None }
    }
}
}

TimingFeedback: Relay Computes, Client Consumes

The relay side of the feedback loop (client consumption shown in RelayLockstepNetwork::apply_timing_feedback() above):

#![allow(unused)]
fn main() {
impl RelayCore {
    /// Compute per-player TimingFeedback from this QoS window's arrival data.
    /// Called every timing_feedback_interval ticks, once per player.
    fn compute_timing_feedback(&self, player: PlayerId) -> TimingFeedback {
        let pm = &self.player_metrics[&player];
        let stats = &self.arrival_stats[&player];
        TimingFeedback {
            early_us: stats.avg_early_us(),
            late_us: stats.avg_late_us(),
            recommended_offset_us: stats.recommended_adjustment_us(),
            late_count: pm.late_count_window,
        }
    }
}
}

Desync Detection Wiring

Shows the relay collecting and comparing sync hashes from all clients (§ “Desync Detection” describes the protocol; this shows the code path):

#![allow(unused)]
fn main() {
impl RelayCore {
    /// Called when a client reports its sync hash for a completed tick.
    pub fn receive_sync_hash(&mut self, player: PlayerId, tick: u64, hash: u64) {
        let entry = self.sync_hashes.entry(tick).or_default();
        entry.insert(player, hash);

        // All players reported for this tick?
        if entry.len() == self.player_count {
            let first = *entry.values().next().unwrap();
            let all_agree = entry.values().all(|&h| h == first);

            if !all_agree {
                self.broadcast(Frame::DesyncDetected { tick });
                if self.desync_debug_level > DesyncDebugLevel::Off {
                    self.broadcast(Frame::DesyncDebugRequest {
                        tick,
                        level: self.desync_debug_level,
                    });
                }
            }
            // Prune old entries (keep last ~8 seconds at Slower default ~15 tps)
            self.sync_hashes.retain(|&t, _| t > tick.saturating_sub(120));
        }
    }
}
}

Match Lifecycle: Connected End-to-End

The capstone: relay-side session lifecycle and client-side match lifecycle showing how every component wires together through a full match.

Relay side:

#![allow(unused)]
fn main() {
/// Top-level relay match session — shows how all relay-side components
/// connect through the full lobby → gameplay → post-game lifecycle.
pub fn run_relay_session(relay: &mut RelayCore, transport: &mut RelayTransportLayer) {
    // The embedding layer (standalone binary or game client) owns the codec.
    // RelayCore is pure logic — no I/O, no serialization.
    let codec = NativeCodec::new();
    let mut match_outcome: Option<MatchOutcome> = None;

    // ── Phase 1: Lobby ──
    // Accept connections (Connection<Connecting> → Authenticated → InLobby).
    // Ready-check state machine: WaitingForAccept → MapVeto → Loading.

    // ── Phase 2: Loading + Calibration (parallel) ──
    // Exchange CalibrationPing/CalibrationPong with each client (~2 seconds).
    let calibration = relay.run_calibration(transport);
    // Seed adaptive algorithms with match-specific measurements:
    relay.apply_calibration(calibration);
    // Wait for all clients to report loading complete.

    // ── Phase 3: Commit-reveal game seed ──
    // Collect SeedCommitment → broadcast → collect SeedReveal → compute_game_seed()
    let seed = relay.run_seed_exchange(transport);
    transport.broadcast(&codec.encode_frame(&Frame::GameSeed(seed)));

    // ── Phase 4: Countdown → tick 0 ──

    // ── Phase 5: Gameplay tick loop ──
    loop {
        // a. Collect orders from all clients within tick deadline
        //    Late orders → PlayerOrder::Idle + anti-lag-switch strike
        relay.collect_orders_until_deadline(transport);

        // b. Sub-tick sort + filter chain → canonical TickOrders
        let tick_orders = relay.finalize_tick();

        // b2. Hash the full pre-filtering order stream for certification (V13).
        //     order_stream_hash covers ALL orders including all ChatMessage channels.
        //     Per-recipient chat filtering (step c) happens AFTER hashing.
        relay.order_stream_hasher.update(&tick_orders);

        // c. Send per-recipient TickOrders to each client + observers.
        //    Gameplay orders are identical for all recipients (lockstep invariant).
        //    ChatMessage orders are per-recipient filtered by ChatChannel (D059):
        //    Team → same-team only, Whisper → target only, Observer → observers only.
        //    Chat filtering is safe because ChatMessage does not affect game state.
        //    RelayCore produces the data; the embedding layer frames and sends it.
        //    All relay→client messages use Frame::* envelopes so the client's
        //    codec.decode_frame() receives a uniform stream.
        for player in relay.players_and_observers() {
            let filtered = relay.build_recipient_tick_orders(player, &tick_orders);
            let frame = Frame::TickOrders(filtered);
            transport.send_to(player, &codec.encode_frame(&frame));
        }

        // d. Sync hashes arrive asynchronously — relay.receive_sync_hash() handles comparison

        // e. Every timing_feedback_interval ticks: QoS evaluation + timing feedback
        if relay.tick % relay.timing_feedback_interval == 0 {
            if let Some(event) = relay.evaluate_qos_window() {
                relay.replay_writer.record_qos_event(&event);
            }
            for player in relay.players() {
                let fb = relay.compute_timing_feedback(player);
                transport.send_to(player, &codec.encode_frame(&Frame::TimingFeedback(fb)));
            }
        }

        // f. Check termination (MatchOutcome from match-lifecycle.md).
        //    The relay determines outcomes from two sources:
        //    - Protocol-level: surrender votes, disconnects, desync hashes,
        //      remake votes — the relay observes these directly from orders
        //      and connection state without running the sim.
        //    - Sim-determined: elimination, objective completion — the relay
        //      collects GameEndedReport frames from all players (observers
        //      excluded) and verifies consensus (deterministic sim guarantees
        //      agreement). If players disagree, relay treats it as a desync.
        if let Some(outcome) = relay.check_match_end() {
            transport.broadcast(&codec.encode_frame(&Frame::MatchEnd(outcome.clone())));
            match_outcome = Some(outcome);
            break;
        }

        // g. Collect client GameEndedReport frames (sim-determined outcomes).
        //    When all players (excluding observers) report the same MatchOutcome,
        //    the relay accepts the consensus. See wire-format.md § Frame::GameEndedReport.
        for player in relay.players() {
            if let Some(report) = transport.recv_game_ended_report(player) {
                relay.record_game_ended_report(player, report);
            }
        }

        relay.tick += 1;
    }

    // ── Phase 6: Post-game ──
    // Broadcast CertifiedMatchResult (Ed25519-signed by relay).
    // Clients' poll_tick() receives this frame and transitions
    // NetworkStatus from MatchCompleted → PostGame(CertifiedMatchResult).
    let outcome = match_outcome.expect("loop exits only via check_match_end");
    let certified = relay.certify_match_result(&outcome);
    transport.broadcast(&codec.encode_frame(&Frame::CertifiedMatchResult(certified)));
    // 5-minute post-game lobby for stats/chat.
    // BackgroundReplayWriter finalizes and flushes .icrep file.
    // Connections close.
}
}

Client side:

#![allow(unused)]
fn main() {
/// Client-side match lifecycle — shows how GameLoop<N, I> integrates
/// with RelayLockstepNetwork and the transport layer.
/// GameLoop struct and frame() method are defined in architecture/game-loop.md.
pub fn run_client_match<T: Transport>(
    mut transport: EncryptedTransport<T>,
    input: impl InputSource,
    local_player: PlayerId,
    rules: GameRules,
) {
    // 1. Transport is already connected + encrypted (signaling + key exchange)

    // 2. Respond to CalibrationPing → CalibrationPong during loading
    //    (handled by the pre-game connection layer)

    // 3. Participate in seed commit-reveal.
    //    Reads directly from transport (pre-NetworkModel). The relay sends
    //    Frame::GameSeed after collecting all SeedReveal messages.
    //    This function decodes Frame::GameSeed via codec.decode_frame()
    //    on the raw transport — the same codec the NetworkModel will use later.
    let seed = participate_in_seed_exchange(&mut transport, local_player);

    // 4. Construct NetworkModel over encrypted transport
    let network = RelayLockstepNetwork::new(transport);

    // 5. Construct GameLoop — the client-side frame driver (always renders).
    //    Headless consumers (servers, bots, tests) drive Simulation directly
    //    and never instantiate GameLoop. See architecture/game-loop.md.
    let mut game_loop = GameLoop {
        sim: Simulation::new(seed, rules),
        renderer: Renderer::new(),
        network,
        input,
        local_player,
        order_buf: Vec::new(),
    };

    // 6. Frame loop — GameLoop::frame() handles the core cycle:
    //    drain input → submit_order() → poll_tick() → sim.apply_tick()
    //    → report_sync_hash() → report_state_hash() (at signing cadence)
    //    → renderer.draw()
    while game_loop.network.status() == NetworkStatus::Active {
        game_loop.frame();
    }

    // 7. Post-game phase (match-lifecycle.md § Post-Game Flow).
    //    MatchCompleted status breaks the frame loop above.
    //    run_post_game() runs its own event loop that continues calling
    //    network.poll_tick() to drain transport — this is how the
    //    Frame::CertifiedMatchResult arrives and transitions the status
    //    from MatchCompleted → PostGame(CertifiedMatchResult).
    //    The post-game loop also renders the stats screen, processes
    //    lobby chat (MessageLane::Chat), and handles user input
    //    (leave / re-queue / view stats). Connection stays open for
    //    up to 5 minutes (match-lifecycle.md § Post-Game Timeout).
    if let NetworkStatus::MatchCompleted(_) | NetworkStatus::PostGame(_) = game_loop.network.status() {
        run_post_game(&mut game_loop);  // blocks until user leaves or 5min timeout
    }

    // 8. Return to menu, save replay
}

/// Post-game event loop. Keeps pumping the network so late frames
/// (CertifiedMatchResult, RatingUpdate, ChatNotification) arrive.
/// Renders the stats screen, displays rating changes, shows chat.
/// Exits on user action (leave / re-queue) or 5-minute timeout.
///
/// Takes the concrete RelayLockstepNetwork (not trait-generic) because
/// post-game accessors (take_credentials, drain_chat, send_chat) are
/// relay-specific — LocalNetwork and ReplayPlayback never produce these
/// frames. The caller (run_client_match) already holds the concrete type.
fn run_post_game<T: Transport, I: InputSource>(
    game_loop: &mut GameLoop<RelayLockstepNetwork<T>, I>,
) {
    let deadline = Instant::now() + Duration::from_secs(300);
    loop {
        // Keep draining transport — CertifiedMatchResult, RatingUpdate,
        // and ChatNotification arrive here. poll_tick() stores them in
        // their respective fields and transitions status accordingly.
        game_loop.network.poll_tick();

        // Consume post-game credentials (rating SCR + match record SCR).
        let credentials = game_loop.network.take_credentials();
        // Consume post-game chat/system notifications.
        let chat: Vec<_> = game_loop.network.drain_chat().collect();

        // Render stats screen with concrete post-game data.
        game_loop.renderer.draw_post_game(
            &game_loop.network.status(),
            &credentials,
            &chat,
        );

        // Send outbound post-game chat if the user typed a message.
        if let Some(msg) = game_loop.input.drain_chat_input() {
            game_loop.network.send_chat(msg);
        }

        match game_loop.network.status() {
            NetworkStatus::PostGame(_) | NetworkStatus::MatchCompleted(_) => {
                if game_loop.input.wants_leave() || Instant::now() > deadline {
                    break;
                }
            }
            _ => break, // Disconnected or unexpected — exit immediately
        }
    }
}
}

Connection topology — complete data flow through one tick:

Client A                     Relay (RelayCore)                Client B
────────                     ────────────────                ────────
input.drain_orders()
  → TimestampedOrder
    → OrderBatcher.push()
      → Transport.send()
        ─── encrypted UDP ──▶  receive_order()
                                normalize_timestamp()    ◀── encrypted UDP ───
                                check_skew()                  (Client B's orders)
                                order_budget.try_consume()
                                finalize_tick()
                                  → sub-tick sort
                                  → filter chain
                                  → canonical TickOrders
                              send_to(per-recipient filtered)
        ◀── encrypted UDP ──  ─── encrypted UDP ──▶
      Transport.recv()                               Transport.recv()
    codec.decode_frame()                             codec.decode_frame()
  poll_tick() → Some(TickOrders)                     poll_tick() → Some(TickOrders)
sim.apply_tick(&tick_orders)                         sim.apply_tick(&tick_orders)
sim.state_hash()                                     sim.state_hash()
  → report_sync_hash()                                → report_sync_hash()
        ─── encrypted UDP ──▶  receive_sync_hash()   ◀── encrypted UDP ───
                                compare hashes
                                (if mismatch → DesyncDetected)
  (every N ticks: signing cadence)                   (every N ticks: signing cadence)
  sim.full_state_hash()                              sim.full_state_hash()
  → report_state_hash()                                → report_state_hash()
        ─── encrypted UDP ──▶  receive_state_hash()  ◀── encrypted UDP ───
                                store for TickSignature chain
                                (replay signing + strong verification)
renderer.draw()                                      renderer.draw()