DOMINATION

Multiplayer Ranking, XP, and Stats

Server-computed casual progression for online matches. Implemented in server/src/ranking.js and wired into the match lifecycle in server/src/server.js. Clients never submit XP, ranks, or win counts — the server computes every award from its own validated match result.

1.0 containment: public matches are currently peer_unranked. Casual profile XP/records still update after a normal result, but rankedXp and rankedGames are frozen for every identity, including accounts. A relay that does not simulate the match cannot defensibly operate a ranked ladder. See multiplayer-integrity-containment.md.

Ranks

Ten ranks, cumulative XP thresholds:

Rank Name Total XP required
1 Recruit 0
2 Private 300
3 Corporal 800
4 Sergeant 1,500
5 Staff Sergeant 2,500
6 Lieutenant 4,000
7 Captain 6,000
8 Major 8,500
9 Colonel 11,500
10 General (cap) 15,000

XP rules

Awarded once per completed match, at the moment the server declares the result (maybeEndMatchawardMatchXp):

  • Match completed (participation): +50 XP
  • Victory bonus: +100 XP
  • Loss consolation: +25 XP
  • Match length: +1 XP per full minute, capped at 30
  • Abandon (a running-match disconnect reaches the server's disconnect-policy timeout): flat +10 XP and nothing else. Leaving early never grants full rewards.

Examples: a 10-minute win = 50 + 100 + 10 = 160 XP; the same match lost = 50 + 25 + 10 = 85 XP.

Integrity

  • XP is computed and applied only inside the server's awardMatchXp, which runs exactly once per match (guarded by the match ended state).
  • Each profile stores the last 100 awarded match IDs; a match ID that was already counted is skipped and logged as duplicate_award_blocked.
  • Result submissions after a match has ended are rejected (match_not_running) and never touch profiles.
  • Lobby/loading connection recovery does not create a second award — awards are keyed to the match player, not the connection. Running-match recovery is disabled until authoritative snapshot restore exists.
  • There is no message that lets a client set XP, rank, or win counts.
  • An integrity stop never calls awardMatchXp. It records an idempotent, separate integrity incident and cooldown without changing games, wins, losses, XP, or ranked counters.
  • Every participant incident from one stop is committed in one atomic profile replacement after an atomic per-match recovery journal. A result may claim persistenceState: applied only after that commit; failures are labeled recovery_pending, disable new match starts, and do not reopen the lobby. Unfinished journal plans replay idempotently on startup.

Identity and storage

  • Profiles persist to <dataDir>/profiles.json (default server/server-data/profiles.json), written atomically on every change and loaded at startup. Lightweight by design; move to a real database only if scale demands it.
  • Clients may sign in to a password-bound server account (acct:<username>), or send a playerUid (16-64 chars of [A-Za-z0-9_-], generated once client-side and kept secret) in the hello message. Profiles are then keyed uid:<playerUid>.
  • A client that sends no account or uid falls back to a name:<lowercased name> key, which anyone can claim by using the same name. Guest progression remains advisory.

Protocol additions

  • hello (client → server): required gameVersion and protocolVersion, plus optional playerUid; mismatched protocols are disconnected before lobby use.
  • helloAck (server → client): includes profile, ranks, integrityMode: "peer_unranked", and rankedResultsEnabled: false.
  • getProfile (client → server): returns { type: "profile", profile, ranks }.
  • matchEnded.result.awards (server → client): one entry per player — playerId, name, faction, team, outcome (win/loss/abandon), xpEarned, xpBreakdown, totalXp, rankBefore, rankAfter, rankUp, gamesPlayed, wins, losses. This is everything a post-game screen needs.

The public profile summary contains: displayName, totalXp, rankId, rankName, nextRankName, nextRankXp, xpIntoRank, xpForNextRank, gamesPlayed, wins, losses, winRate, disconnects, reconnects, surrenders, abandons, totalPlayTimeSeconds, favoriteFaction, favoriteMap, lastMatch, matchHistory (last 10), lastPlayedAt, ranked counters, integrity incident counts/history, and integrityCooldownUntil.

Stats tracked per profile

Games played, wins, losses, win rate, total XP, disconnects, reconnects, surrenders, abandons, total play time, per-faction plays/wins/losses, per-map plays, favorite faction/map, last match record, and a 20-entry match history (map, faction, outcome, XP, duration, ended-at timestamp). Integrity incidents use a separate idempotent 20-entry history.

Testing

cd server && npm test runs server/test/ranking.test.mjs:

  • rank ladder validity and threshold boundaries,
  • XP rule math (win/loss/abandon/time-bonus cap),
  • profile-store duplicate-match blocking and disk persistence,
  • a full integration pass — real server process, two TCP clients, lobby → ready → start → load → defeat report → matchEnded awards → duplicate submission rejected → server restart → profile still present.

Unity client integration

  • MultiplayerClient generates a playerUid GUID once (PlayerPrefs mp_player_uid) and sends it in hello, so progression follows the install even when the display name changes.
  • The Multiplayer screen's SERVICE RECORD panel (connection column) shows rank, an XP progress bar with XP-to-next-rank, W/L, favorite faction, play time, and the last match — populated from helloAck.profile and refreshed via getProfile after every match.
  • After an online match, the OPERATION REPORT modal shows Victory/Defeat, map/duration/faction and UNRANKED, the XP breakdown lines, total XP earned, a RANK UP banner when the rank changed, the career line, and every commander's award. CONTINUE dismisses it; the server has already reopened the lobby for a rematch.
  • Screenshot-only state for these screens can be staged with Build.CaptureMenu -screen multiplayer -mpseed 1 [-mpresult 1].

Current limitations / follow-ups

  • Name-keyed fallback identity is spoofable; use an account for durable identity.
  • Existing leaderboard rows are historical/frozen. Ranked updates require an authoritative simulation or independently verifiable result protocol.