DOMINATION

DOMI-NATION Multiplayer — Security & Threat Model

Honest accounting of what the online architecture protects, what it cannot, and the roadmap. Written for the deterministic-lockstep design (every client runs the full simulation; the server validates and relays commands).

The fog-of-war question, answered directly

Nothing prevents a determined player from removing fog of war, and in this architecture nothing can. Lockstep means every enemy unit, building, and position exists in every client's memory — fog is a local rendering decision. A modified client or a memory reader sees everything. This is the same tradeoff every classic RTS (StarCraft, Age of Empires, C&C) made: lockstep buys tiny bandwidth (commands only), byte-perfect replays, and 16-player scalability at the price of client-side information exposure being unpreventable. The industry answer — and ours — is protect what the server owns, detect what it can't prevent:

  • Prevention would require server-side visibility filtering: the server runs the sim and sends each client only what it can see. That is a different architecture (per-client state streaming), with 100–1000× the bandwidth, no deterministic replays, and a full server-side game simulation to build and pay for. Not worth it before the game has a competitive community worth protecting.
  • Detection is viable and cheap later: the server archives every match's full command log (replays). Map-hackers betray themselves behaviorally — orders that snap to units the player never scouted, pre-emptive counters to invisible tech. Post-accounts, a replay-audit pass can flag suspicion scores for review. Planned after real accounts land.

What IS protected today (server-authoritative)

Asset Protection
Match results, XP, ranks The server computes casual awards from authenticated self-surrender/forfeit state and never accepts a client-submitted win. An immutable, private result WAL binds every participant profile mutation to the exact canonical replay; one atomic profile replacement, a durable intent commit marker, strict startup recovery, and match-ID dedup prevent partial or duplicate awards across crashes. Pending replays stay private, and unrecoverable evidence makes readiness fail closed. Release containment: all peer-simulated matches advertise integrityMode=peer_unranked, ranked=false; awardMatchXp cannot mutate rankedXp/rankedGames. The public ladder is historical/frozen until results are authoritative or independently verifiable.
Command stream integrity Per-kind schema table: every command kind declares exactly the fields the shipping client sends; unknown fields are rejected (unknown_field), known fields are type/range-checked by a field registry (bad_field) — id arrays capped at 200, coordinates finite+bounded, strings length-capped, enums ranged. Size cap (8KB) runs first as the backstop. Sender identity enforced (wrong_player). A coverage test sends every kind's canonical exemplar, so a new kind without a schema fails in CI, not production.
Unit ownership Commands are applied as the sender's faction on every client (ApplyCommand filters unit ownership deterministically); you cannot order enemy units by forging ids.
State tampering A client that mutates its sim diverges from peers. Three divergent comparisons in a rolling five-report window stop the match, including alternating D/A/D/A/D abuse. The stop never awards a winner, XP, win, loss, or ranked result. It does create a durable integrity incident and cooldown, so a losing client cannot create a consequence-free ranked no-loss.
Checksum evidence hello must negotiate the exact game and lockstep-0.1 protocol versions. A required canonical hash is then bound by the server to the authenticated connection, frozen match cohort/seat/id, map hash, exact tick, and receipt time. Duplicate reports are idempotently rejected; conflicting/stale/future/out-of-sequence/malformed reports cannot overwrite evidence and count through the same invalid-report policy. A disconnect cannot remove an accepted report.
Stop attribution With 3+ reporters, only repeated minority reports against a strict majority inside the triggering rolling window create a reported-outlier profile incident; a one-off/evicted outlier remains audit evidence. A tie and every 1v1 split remain explicitly ambiguous only for contributing reporters. Majority is not proof of truth, so attribution can impose a review hold but can never award the majority a win. One atomic profile transaction follows an atomic, restart-recoverable <dataDir>/integrity/<matchId>.json evidence journal. Public results omit private profileKey values.
Transport abuse Bounded UTF-8 lines and aggregate parsed byte/node/message queues, per-client rate limiting with sustained-abuse disconnect, loading-phase timeout (stalled clients forfeit), per-client lobby-creation caps, and a separately bounded/time-limited pre-auth TLS handshake pool (MAX_PENDING_TLS_HANDSHAKES, default 64; 10s deadline). Each Unity receive thread captures its own socket/stream generation; reconnect invalidates the old generation and releases/discards stale envelopes instead of letting an old thread consume the replacement connection.
Transport confidentiality/authentication Native TLS 1.2+ for public/LAN servers. Unity validates the operating-system trust chain, certificate lifetime, and hostname through SslStream; the server fails before listening without a certificate/key. Plain TCP requires explicit opt-in on both ends and is hard-restricted to numeric loopback, never a hostname. Passwords, bearer tokens, lobby credentials, chat, commands, and match state therefore do not cross public networks as readable JSON. The real Unity multiplayer harness uses an exact localhost certificate pin gated to MP_AUTOTEST plus a verified loopback peer; interactive/public connections cannot activate it.
Reconnect hijacking Reconnect tokens are per-player scoped (were briefly broadcast to all — fixed in #1292).

What is NOT protected (accepted, by architecture)

  • Information exposure (maphack / memory reading): see above. Cannot prevent; plan to detect.
  • Input automation (macros/bots): indistinguishable from a fast human at the server; behavioral detection territory, far future.
  • Identity: three tiers, strongest-first. Accounts (server-side, MV): register/login with a scrypt-hashed password (node:crypto, per-account salt, timing-safe verify) issue an expiring, rotated auth token; a hello carrying a valid token binds the connection to acct:<username>, which no one can claim without the password. Below that, the possession-based playerUid (a GUID in PlayerPrefs), then the claimable lowercased name for bare LAN guests. Accounts persist to <dataDir>/accounts.json (hashes only, never plaintext); tokens are in-memory (a restart requires re-login). Accounts do not make peer results trustworthy: ranked updates remain disabled even for acct: identities until the result authority changes.
  • Denial of service beyond per-connection limits (distributed floods): out of scope for a LAN/friends-scale server; front with standard infra if it ever goes public.
  • Endpoint compromise / client memory: TLS protects traffic in transit, not secrets after either endpoint decrypts them. Auth/session tokens currently live in process memory and Unity PlayerPrefs; OS credential storage and token revocation remain separate hardening work.

Rules of engagement for new code (the determinism contract)

Any new ONLINE-reachable behavior must be:

  1. Relayed — player intent goes through the command stream (executeTick order), never applied locally first.
  2. Symmetric — no playerFaction-gated sim effects; per-faction state lives in per-faction structures.
  3. Seeded — sim randomness only from game.srand; UnityEngine.Random is for visuals.
  4. Sim-clocked — no Time.time / frame-count / wall-clock in sim decisions.
  5. Offline-first features must gate with an honest refusal online (the research/doctrine/powers pattern) rather than silently no-op.

Violations of 2–4 are exactly the bug classes that produced every desync in the campaign log (docs/multiplayer-testing.md).

Enforcement (unity/sync-lint.sh): rules 3–4 are now mechanically checked. The lint scans the sim-critical files (Unit.cs, GameController*.cs, Combat.cs, Pathfinding.cs, GridMap.cs) for client-divergent inputs (UnityEngine.Random, Time.*, Camera.*, Environment.TickCount, DateTime.Now/UtcNow) as a ratchet: every current occurrence is grandfathered in sync-lint-baseline.txt; a NEW unmarked one fails with its file:line. To add one deliberately, put #sync-ok:<reason> on the line — the safety argument (visual-only, offline-gated) lives in the code, at review time. playerFaction is reported advisory-only (too legitimately common to hard-fail). Fast (static grep, no build); runs in CI on every PR touching unity/Assets/Scripts (.github/workflows/sync-lint.yml). The lint catches the divergent input. The sync gate (mp-sync-test) catches the effect — but it needs a player build and a live server, so it is not in CI and remains a manual / pre-release step (#2539). Rule 1 (relayed) stays a manual audit: grep interactive game.<Mutator>( call sites in PlayerControl/HudUI for a missing relay (this is how the sell/repair landmine was caught).