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, butrankedXpandrankedGamesare 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 (maybeEndMatch → awardMatchXp):
- 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 matchendedstate). - 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: appliedonly after that commit; failures are labeledrecovery_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(defaultserver/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 aplayerUid(16-64 chars of[A-Za-z0-9_-], generated once client-side and kept secret) in thehellomessage. Profiles are then keyeduid:<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): requiredgameVersionandprotocolVersion, plus optionalplayerUid; mismatched protocols are disconnected before lobby use.helloAck(server → client): includesprofile,ranks,integrityMode: "peer_unranked", andrankedResultsEnabled: 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 →
matchEndedawards → duplicate submission rejected → server restart → profile still present.
Unity client integration
MultiplayerClientgenerates aplayerUidGUID once (PlayerPrefsmp_player_uid) and sends it inhello, 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.profileand refreshed viagetProfileafter 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.