Open-World Narrative · Architecture

Persistence, Online Services & Cross-Play

A focused page within the Open-World Narrative Architecture documentation. The full map and every sibling page live in the Architecture hub.

5sections11 minread1diagram

On this page

V5 is an open-world narrative universe split into cells — an Urban heist city, a three-era Period crime saga, a Frontier outlaw trail, a Hunter's monster-contract world, a Sci-Fi galactic theatre, and the cross-cutting Mind Palace deduction layer. What makes that a universe rather than six unrelated games is identity-shaped, and the shape is the Bureau: one canonical account, one Bureau XP ledger that every cell feeds, one Mind Palace graph, one cosmetic inventory — the same player record on PC, PlayStation, Xbox, Switch, and iOS, surviving a platform switch without losing a clue or a cosmetic. This page is about the machinery that keeps that promise: persistence (the V5Core encrypted save substrate and the V5Persistence ledgers, replays, and New Game+ migration that ride on it), the online backbone (the 16-service V5OnlineServices client catalog, its real HTTP transport, and the @v5/service-shared NestJS tier under apps/v5 that backs it), and cross-play / cross-progression (the policy that decides who can match whom and what travels with an account). The deterministic combat the session services wrap lives one layer down in Netcode, Authority & Determinism; the seasonal cadence layered on top is in Live Service & DLC. The section hub is ../V5_ARCHITECTURE.md.

What ships, honestly#

The split is clean, and stating it up front keeps the rest of the page honest.

The client tier is real Unreal C++. V5/ue/Source/V5OnlineServices ships a 16-endpoint service catalog (UV5_Online_ServiceCatalog::BuildEndpointCatalog) fronted by a family of UBlueprintFunctionLibrary request builders (UV5_Online_AuthService, …MatchmakingService, …AntiCheatClient, and the rest) over a 1,200-line typed surface (V5OnlineServicesTypes.h). Crucially, the transport underneath is genuine network I/O: V5OnlineServices.Build.cs declares the HTTP and Json modules, and FV5OnlineHttp turns a request value into a real IHttpRequest from FHttpModule — the comment in V5OnlineHttpClient.cpp records this as the module's "first real network I/O, replacing the prior 'build a POD struct, never transmit' path that the 2026-05-31 audit flagged." Authentication is real cryptography, not a token template: FV5JwtCrypto is a self-contained FIPS 180-4 SHA-256, RFC 2104 HMAC-SHA256, RFC 7519 HS256 JWT, and RFC 7636 PKCE S256 implementation, unit -tested against published vectors.

The service tier is real TypeScript + NestJS. apps/v5 holds 16 service packages (each with a contract.json, Dockerfile, and k8s/ manifests), and every one delegates its handle() to handleRealRequest in @v5/service-shared, which resolves live pg.Pool + ioredis clients and dispatches to a domain that is not CRUD: a Glicko-2 rating pipeline, an OAuth refresh-rotation flow with reuse detection, a hash-chained balance ledger, a CRDT Mind Palace merge. Each domain carries #[cfg(test)]-equivalent Vitest cases asserting computed answers (Glickman's worked Glicko-2 example, a revoked token family, a verified ledger chain).

Three honest labels travel with the rest of the page:

  • The save cipher is real but not the prose's cipher. UV5_SaveGameSubsystem genuinely encrypts every save with a per-account, per-platform-derived key and validates integrity on load — but it is a SHA-1 keystream XOR, not the AES the design notes imply, and the "Oodle Kraken compression" the architecture names is not in the shipped code. Encryption and the integrity check are real; compression is spec.
  • There are two backend code paths, and the live one is real. evaluateServiceSpecificRule still returns the old jwt.v5.<account>.oauth fixture string for contract tests — but the deployed service.handle() calls handleRealRequestexecuteRealServiceRequest, the Postgres/Redis-backed path. The fixture path is for golden contracts; the real path is what runs.
  • Kernel anti-cheat is a planned client tier. The client builds EAC signal requests and the server keeps a real strike journal, but the EAC kernel driver itself is named, not in-tree — exactly as V4 and V2 label it.

The online backbone#

The service catalog and the HTTP seam#

Gameplay code never hand-rolls a URL. BuildEndpointCatalog enumerates all 16 services — auth, friends, parties, matchmaking, leaderboards, replays, telemetry, anti-cheat, crash-reporting, companion-app-bridge, mindpalace-cloud-sync, workshop, compliance-dsar, live-service-calendar, balance-ledger, faction-rep — each as an FV5OnlineEndpointDefinition carrying its verb, path, bRequiresJwt, and bSupportsOfflineQueue flags. A UV5_Online_* builder fills an FV5OnlineServiceRequest (account, JWT, region, JSON body, per-cell path slug), and FV5OnlineHttp::BuildHttpRequest constructs the real IHttpRequest: it sets the verb, an Authorization: Bearer header when a JWT is present, X-V5-Account and X-V5-Region routing headers, and the JSON body for non-GET methods. Base URL resolution is fail-soft and never fabricated — V5_ONLINE_BASE_URL is treated as an API gateway, else V5_ONLINE_HOST plus the service's catalog port (4210 + service index).

The latent node UV5_Online_HttpRequest::Activate is where the fail-loud discipline shows: a request whose endpoint bRequiresJwt but whose token is empty short-circuits to a 401 before touching the network (auth.jwt.required), a transport failure yields TransportFailureResponse (service.unreachable, status 0), and a 503 sets bQueuedOffline from the endpoint's offline-queue flag. No path fabricates a success it did not receive.

Identity, JWT, and PKCE#

UV5_Online_AuthService::IssueOAuthJwt mints a genuine HS256 token. It assembles RFC 7519 registered claims (iss, sub, aud, provider, iat, nbf, exp, jti) with a 3,600-second TTL, derives the jti from SHA-256(account|provider| iat) so each issuance is unique yet deterministic, signs with FV5JwtCrypto::IssueHs256, and derives an opaque refresh token as HMAC(SHA-256(key.refresh), SHA-256(accessJwt)) — revocable server-side and not user-forgeable. VerifyAccessJwt does the real inverse: a constant-time signature comparison plus exp/nbf enforcement. The tests prove behaviour, not shape: an access token issued at t=1000 is a 3-part compact JWT, verifies at t=1000, is rejected at t=4600 (past its exp), and a tampered signature fails. VerifyPkceChallenge enforces RFC 7636 §4.1 length bounds (43–128) and checks BASE64URL(SHA256(verifier)) == challenge.

Server-side, AuthDomain (domain/auth.ts) is Postgres-backed and stricter still. resolveCanonicalAccount enforces oldest-verified-credential-wins: the first verified (platform, externalSubject) pair binds a canonical v5acct.… id, and later exchanges for the same pair return that id — one human cannot fork into multiple V5 accounts per platform. refreshAccessToken implements RFC 6749 §6 rotation with RFC 6819 reuse detection: presenting an already-rotated token sets every row in the family to revoked = TRUE and returns reuse_detected. Refresh tokens are stored only as SHA-256 hashes, never cleartext. The Vitest suite asserts a stable canonical id across exchanges, a pkce_failed on a bad verifier, and a family-wide revocation on replay.

Matchmaking and the skill model#

The client builders are deliberately thin — BuildQueueRequest and BuildCrossplayQueueRequest carry mode, region, integer SkillRating, ping, and (for cross-play) Platform, ModeId, and bCrossplayOptOut to a per-cell /v5/matchmaking/{cell}/queue path. The authority is server-side and genuine. MatchmakingDomain (domain/matchmaking.ts) runs a real Glicko-2 update (Glickman 2013): glicko2Update computes the g(φ) and expected-score terms, then solves the volatility step with an Illinois (regula falsi) root finder — and the test pins it to the canonical worked example, toBeCloseTo(1464.06, 1) / toBeCloseTo(151.52, 1) / toBeCloseTo(0.05999, 4) for a 1500/200/0.06 player against three opponents. The queue is a Redis ZSET banded by cell:region:mode:skillBand:pingBand (200-wide skill bands, three ping bands), ordered by enqueue time; formMatch uses an atomic ZPOPMIN so two formation passes never claim the same player, and re-enqueues what it took if a rare race leaves it short.

Anti-cheat and integrity#

Anti-cheat is defended in depth and labelled by tier. The client UV5_Online_AntiCheatClient builds eight distinct, dedicated-endpoint requests — EAC session signal (requiresEAC: true), aim plausibility, sub-tick aimbot snap, line-of-sight wall-hack history, position-delta speed-hack, auto-fire interval pattern, a weighted classifier carrying modelId: V5_AntiCheat_ML, and an appeal routed to /companion/support/anti-cheat-appeal. Server-side, AntiCheatStore (domain/anti-cheat.ts) makes this stateful: recordSignal journals each flagged detector to Postgres and recomputes the account's running strike count over verdicts ≥ strike, driving the three-strike ladder (warningranked-suspensionban-review); fileAppeal queues a queued-human-review row on the companion-app path. The governing rule matches the platform posture: model output prioritises a review queue, it never silently disciplines.

flowchart LR subgraph Client["V5 client · Unreal C++"] CAT["V5OnlineServices<br/>16-endpoint catalog · UV5_Online_*"] JWT["FV5JwtCrypto<br/>SHA-256 · HMAC · HS256 · PKCE S256"] HTTP["FV5OnlineHttp / UV5_Online_HttpRequest<br/>real FHttpModule transport"] SAVE["V5Core save<br/>UV5_SaveGame · encrypted envelope · 3 generations"] PERS["V5Persistence<br/>CurrencyLedger · MindPalace · Replay · NG+"] end JWT --> CAT CAT --> HTTP HTTP -->|Bearer JWT · X-V5-Account · region| GW[API gateway · V5_ONLINE_BASE_URL] SAVE -. IOnlineUserCloud .-> CLOUD[(platform cloud save)] GW --> SVC["@v5/service-shared · NestJS<br/>handleRealRequest → executeRealServiceRequest"] SVC --> AUTH[auth · AuthDomain<br/>PKCE · JWT · refresh rotation] SVC --> MM[matchmaking · Glicko-2<br/>Redis ZSET queue] SVC --> AC[anti-cheat · strike journal] SVC --> MORE[friends · parties · leaderboards<br/>replays · workshop · …16] AUTH & MM & AC & MORE -->|configured?| STORE[(Postgres · Redis<br/>else 503 not_configured)]

The diagram's last edge is the most important honesty seam. resolveBackingClients hands a service live clients only when its catalog- declared V5_POSTGRES_URL / V5_REDIS_URL resolve to non-empty values; otherwise it returns configured: false, and executeRealServiceRequest serves the 503 outage contract. buildHealth reports not_configured in that case rather than the old hardcoded serviceHealthy: true that made the outage branch dead code. Absence is reported loudly, never faked.

Persistence and save#

The save model and the encrypted envelope#

UV5_SaveGame (in V5Core) is the model behind every cell's save: a USaveGame at CurrentSaveVersion = 2 (down to MinimumSupportedSaveVersion = 1) whose FV5SaveDocument namespaces an FV5CellSaveSlot per cell — Urban, Period, Frontier, Hunter, Sci-Fi, Steampunk, and Mind Palace — alongside the account id, active cell, completed-cells set, and the shared Bureau XP ledger. ImportFromJsonString runs MigrateLoadedDocument, so a schema bump is a versioned migration, not an ad-hoc rewrite.

Durability is real. UV5_SaveGameSubsystem::SaveProfile serialises to JSON, then EncryptJsonPayload builds an FV5EncryptedSaveEnvelope: a GUID nonce, a plaintext SHA-1 for integrity, a KeyId, and a hex cipher produced by ApplyKeyStream XOR-ing the bytes against a counter-mode SHA-1 keystream. The key material is per-account and per-platformBuildPlatformKeyMaterial derives it from the IOnlineSubsystem identity (the unique net id, falling back to a hashed auth token, falling back to a local device id) prefixed by the account. On load, DecryptEnvelopeBytes rejects an envelope whose account/slot, KeyId, or recomputed plaintext SHA-1 does not match — a save encrypted under a different platform key fails loudly rather than loading garbage. RotateGenerations keeps the triple-redundant Current → Previous → Fallback chain the architecture specifies, and LoadProfile walks the three in order, auto-promoting a recovered older generation back to current. Cloud sync is a real IOnlineUserCloud::WriteUserFile, and it fails loud — "platform cloud unavailable: no online subsystem" — when no platform cloud is registered.

The currency ledger and Bureau XP#

UV5_Persist_CurrencyLedger is an audit-trailed, per-cell ledger. BuildStartingLedger seeds each cell with its native currency — Urban/Period Cash, Frontier Gold, Hunter Crowns, Sci-Fi Credits, Mind Palace Bureau Scrip — and ApplyTransaction mints a deterministic TransactionId, rejects any transaction that would drive a balance negative, updates lifetime earned/spent, and appends every movement (accepted or not) to a Journal so the balance is reconstructable. The connective progression record is FV5BureauXPLedger: a TotalXP, a per-cell XP map, and a tier computed monotonically as CurrentTier = max(1, TotalXP / 2500 + 1) — which is exactly why New Game+ gates on tier 20 (47,500 XP). This is the single object cross-progression syncs as the account's shared spine.

Replays, New Game+, and save archaeology#

UV5_Persist_ReplaySerialization builds a versioned deterministic replay packet whose exact binary canonical form binds replay/account/key identifiers, issuance and expiry, a base64url nonce, every frame field, IEEE float bits, event kind, and ordered tags. The packet carries a SHA-256 payload digest and HMAC-SHA256 signature; verification uses the shared V5IntegrityCore and constant-time digest comparison. UV5ReplayIntegritySubsystem fails closed unless an external key-custody resolver and an atomic durable nonce consumer are configured, clears transient key bytes after use, rejects future/expired packets and consumed nonces, and retains bounded receipt-linked audit events. This makes the in-memory nonce window defense-in-depth rather than the durable source of truth.

UV5_Persist_NewGamePlus encodes the Year-1 NG+ plan as data and validates it in code: five launch cells, ≥ 2 story branches and ≥ 3 difficulty modifiers each (≥ 10 and ≥ 15 totals), a strict carry-over set (Bureau XP, cosmetics, codex, photo mode, Mind Palace archive) against a reset set (critical-path state, economy, wanted heat, bounty debt), and bNoPaidPowerCarryover so purchased power never crosses a reseed. UV5_Persist_SaveSlotArchaeology imports an old v1/v2 save, reads its ending.* flags, and triggers the matching NG+ branch — ten authored scenarios, each verified to read an ending flag and target an ngplus.* branch — so a finished campaign measurably reshapes the replay.

Cross-play & cross-progression#

Cross-play decides who matches whom; cross-progression decides what travels. Both are policy-rich, and the policy is a checked-in artifact: V5/crossplay/crossplay-progression-policy.json. It names a single canonical account (idPrefix: v5acct, singleAccountIdRequired: true, conflictPolicy: oldest_verified_credential_wins) with nine platform credentials — Steam on Windows/Linux, Apple on Mac/iOS, PSN on PS5/PS5 Pro, Xbox Live on XSX/XSS, Nintendo on Switch 2 — each with its credential type and required OAuth scopes. That policy is not prose: AuthDomain.resolveCanonicalAccount enforces the oldest-credential rule server-side, and the client mirror BuildPlatformCredentialLinkRequest binds an external subject to the canonical v5acct.… id.

Four progression domains carry explicit, service-owned merge policies: profile (auth, last-verified-write-wins), cosmetics (companion-app-bridge, entitlement union), bureauXp (companion-app-bridge, monotonic ledger sum), and mindPalace (mindpalace-cloud-sync, node last-writer-wins with accusations append-only). The last is backed by real CRDT code: mindpalace-cloud-sync.ts merges evidence nodes by version-vector causal dominance with a deterministic (updatedUnix, lastWriter) tiebreak, and unions accusations — commutative and idempotent, so re-syncing the same diff is a no-op. Cross-play is on by default across all nine platforms; the single per-cell opt-out is Sci-Fi spaceship PvP (mode.scifi.spaceship-pvp), and the runtime honours it precisely — an opted-out ticket routes to a platform-only pool, every other mode stays crossplay, gated for input parity rather than blanket console segregation. Companion save transfer is a signed, encrypted-at-rest bundle (v5-save-transfer-bundle-v1) with a newer_slot_version_wins_with_manual_review import policy, exercised by five named QA scenarios.

Where this connects#

  • Down to the match: Netcode, Authority & Determinism — the deterministic sim the replay-determinism digest and server-validated score writes depend on, and the dedicated-server authority the cross-cell world runs under.
  • Sideways to live service: Live Service & DLC — the seasonal calendar, persistent world events, workshop marketplace, and faction-rep decay that ride this same backbone and ledgers.
  • Platform foundations: Persistence & Data — the Postgres / Redis / object-storage substrate these services are specified to deploy onto, and the account-root and data-residency posture they inherit.