Tactical Action · Architecture

Online Services & Persistence

A focused page within the Tactical Action Architecture documentation. The full map and every sibling page live in the Architecture hub.

4sections12 minread1diagram

On this page

V4 is one universe wearing six bodies — a tactical FPS cell, a stealth/Hitman cell, an asymmetric RTS cell, a Wukong-style ARPG cell, a top-down tactics cell, and the shared Battle Hub that stitches them together. The promise that makes that a universe rather than six unrelated games is identity-shaped: one account, one operator roster, one currency ledger, one progression record, every cell, every platform. A confirmed kill in the Hitman cell, a ranked win in the tactical cell, and a unique boss clear in the ARPG cell all have to land in the same profile, mint the same kind of server-validated ledger entry, and survive a platform switch from PS5 to PC without a player losing a single cosmetic. That is what this page is about: the online backbone — the client-side V4OnlineServices subsystems and the Rust service tier under apps/v4 that back them — and persistence — the V4Persistence module that owns save-game, profile, the currency ledger, contract progress, and the Hitman persistent world. The deterministic match runtime these services wrap lives one layer down in Networking & Determinism; the seasonal ranked operations and DLC cadence layered on top are in Game Modes & Live Service. The section hub is ../V4_ARCHITECTURE.md.

What ships, honestly#

The split is clean and worth stating before the detail. Two tiers are real code, and a small set of seams are honestly labelled as deployment substrate rather than wired integrations.

The client tier is real Unreal C++. V4/ue/Source/V4OnlineServices ships fifteen UGameInstanceSubsystems — login, matchmaking, session, presence, friends, party, leaderboard, replay-upload, crossplay, live-service, moderation, compliance, online-contract, and contract-schedule — over a typed surface (V4OnlineTypes.h), and its V4OnlineServices.Build.cs really declares the OnlineSubsystem, OnlineServicesInterface, OnlineServicesCommon, and EOSShared dependencies that make the EOS abstraction load-bearing rather than decorative. V4/ue/Source/V4Persistence ships nine more subsystems over a 20 KB typed model (V4PersistenceTypes.h): save-game, profile, currency ledger, save-sync, save-migration, replay-store, contract-progress, and the Hitman persistent-world subsystem.

The service tier is real Rust + Axum. apps/v4/online-services is a v4-online-services crate whose service-manifest.json enumerates 25 services and whose service_router() (in src/lib.rs) nests every one of them over genuine, tested service structs — Glicko-2 matchmaking, OAuth2 + PKCE login, replay retention, leaderboards, moderation, compliance, store, esports, and the rest. The domain logic is not CRUD: it is a backtracking exact-fill matchmaker, a dependency-free SHA-256/PKCE implementation, a replay-vault retention calculus, and a right-to-be-forgotten scrubber, each carrying inline #[cfg(test)] cases that assert computed answers (a Glicko-2 fairness window, a region that minimises worst-case QoS, a 14-day expiry boundary).

Three honest labels travel with the rest of the page:

  • The shipped router is wider than the architecture note. V4_ARCHITECTURE.md carries a 2026-06-12 note that "only the login router mounts real routes today." That note is now stale: service_router() in lib.rs:24-47 nests all 20 service routers, and src/http.rs backs each with real Arc<Mutex<Service>> state and real handlers. The login router is no longer the exception.
  • The services hold in-memory state. Each Rust service keeps a BTreeMap behind an Arc<Mutex<…>>; the PostgreSQL / Redis / ClickHouse / S3 stores the architecture names are the specified deployment substrate, not yet wired into these crates. The reference implementations are real and tested; the database backends are the next integration step, labelled as such.
  • V4 does not ride the @oshun plane. Unlike V2's event-bus-native backbone, V4's services are a self-contained Rust workspace (v4_shared::ServiceName). There is no @oshun/event-bus or @oshun/identity composition in apps/v4, V4/store, V4/community, or V4/ue — the only @oshun string anywhere in V4 is inside a vendored Next.js build artifact. Where V2 publishes cross-domain events, V4 keeps its own service graph.

The online backbone#

Client subsystems and the EOS abstraction#

Gameplay code never branches on platform. Every menu, lobby, and matchmaking screen calls a V4OnlineServices subsystem whose types speak one enum — EV4OnlinePlatform { EOS, PSN, XBL, NN, Steam, Apple, Google } — and the platform-specific online stacks sit behind it. UV4LoginSubsystem is a clean client-side state machine: BeginLogin moves an account to EV4LoginState::Pending, CompleteLogin resolves it to SignedIn or Failed, and IsPlatformSupported gates the seven launch providers (V4LoginSubsystem.cpp). The heavy cryptographic exchange deliberately does not live in the client — it lives in the Rust login-service, where it can be audited and rotated server-side.

The crossplay subsystem is the clearest example of V4's honesty discipline. UV4CrossplaySubsystem::BuildDefaultPlatformIntegrations() returns the launch contract — the capabilities V4 commits to per platform — and every row is stamped bMeasured = false with a comment that reads, verbatim, "a target statement, not a probe … it must never be read as 'the SDK is wired'." The live truth comes from a separate path: ProbePlatformIntegration calls IOnlineSubsystem::Get(...) and reads GetIdentityInterface().IsValid(), GetPurchaseInterface().IsValid(), GetFriendsInterface(), GetPartyInterface(), and GetSessionInterface() on the real subsystem, returning a struct with bMeasured = true; when no SDK is registered on the host, every capability honestly reads false. BuildIntegrationGapReport diffs contract against probe and emits a line for each "contracted capability not wired on this host." That is exactly the fail-loud seam the codebase asks for: the contract never masquerades as the measurement.

Matchmaking and the ranked ladder#

Matchmaking is the backbone's most fairness-sensitive job, and V4 implements it on both sides of the wire. On the client, UV4MatchmakingSubsystem carries FV4MatchmakingTicket rows (mode, region, ladder, integer SkillRating) and a BuildMatchmakingDiagnostic call that powers the WBP_RankedMatchmakingDiagnostic explanation panel — it computes PlayerMMR, OpponentAverageMMR, MMRDelta, bWithinTolerance, and a human-readable explanation so a player can see why a match formed.

The authoritative skill model is server-side and genuine. MatchmakingService (src/matchmaking.rs) runs a Glicko-2 expected-score fairness window: glicko2_expected_score applies the q = ln(10)/400 scale factor — with a checked-in comment recording the 2026-06-12 fix for a missing q that had flattened every pairing toward even — and a candidate joins the match only when its expected score against the seed stays within FAIR_MATCH_EXPECTED_SCORE_WINDOW = 0.15 of 0.5. find_match is a real depth-first backtracking exact-fill: it seeds on the longest-waiting solo (prefer_solo_queue, FIFO within party size), recurses through fill_exact to assemble exactly team_size players from mixed party sizes, and requires one region every member can play in — then picks the region with the best worst-case latency. The tests prove the behaviour rather than the shape: a 2400-rated smurf is excluded from a 1500-lobby and stays queued; a 3+2 backtrack fills a 5-stack while a lone solo waits; and SA wins over EU because its shared worst-case QoS is 45 ms versus 80 ms.

The ranked ladder on top is seasonal and data-driven. launch_season_cadence() encodes a 90-day season, a 10-day pre-season reset, 5 placement matches, and a day-45 mid-season balance patch; DA_RankedSeasonArchitecture defines the five year-one ladders (Ranked.Tactical.R6Modern, Ranked.Stealth.SpiesVsMercs, Ranked.RTS.Asymmetric, Ranked.RTS.Historical, Ranked.ARPG.BossRush) with Diamond+ 14-day decay and public /ranked MMR histograms. The launch SLO the architecture commits to is concrete enough to test: matchmaking p99 ≤ 35 s under 5× expected launch concurrency.

Identity, login, and cross-progression#

LoginService (src/lib.rs) is the account root. Its OAuth surface (login_router) exposes /oauth/providers, /oauth/authorize, /oauth/token, and /oauth/refresh, and the flow underneath is real RFC 6749 + RFC 7636 PKCE: src/oauth.rs carries a dependency-free, FIPS 180-4 SHA-256 used for the S256 code-challenge binding, so the authorization-code exchange is genuinely cryptographic and deterministically testable rather than a synthesised token string. Refresh is single-use with replay defense — redeem_refresh_token validates the presented token against its expected derivation, inserts it into a consumed_refresh_tokens set, and rejects a replay with RefreshGrantError::ReplayedRefreshToken before rotating to the next generation.

Cross-progression roots every player in one first-party account with link/unlink semantics on both sides of the wire. LoginService::link_platform_account refuses to bind a provider identity already attached to a different account and keeps at least one primary link alive on unlink; the in-engine mirror, UV4CrossplaySubsystem::LinkPlatformAccount / UnlinkPlatformAccount, enforces the same invariants on FV4FirstPartyAccount and promotes a new primary if the old one is removed. Currency portability is encoded as policy, not prose: BuildDefaultCurrencyRules() makes OperatorTokens and EliteTokens cross-platform soft currencies while CombatPoints is per-platform hard currency, and CanCurrencyMoveCrossPlatform returns true only for a soft, cross-platform, non-per-platform currency. Per-surface session policy is real too — cross_surface_session_policies() gives the companion app, wiki, roadmap, and spectator portal each its own idle and absolute timeout with rotation on.

Replay vault, right-to-be-forgotten, and speedrun ghosts#

ReplayService is one of the most domain-specific structs in the tree. Uploads default to a 14-day retention (DEFAULT_REPLAY_RETENTION_DAYS); starring moves a replay into a lifetime vault capped at 500 per account (MAX_STARRED_REPLAYS_PER_ACCOUNT), with star_archive_for_account_at refusing a wrong-owner star and expired_replays computing expiry as created + retention_days × 86 400 ≤ now while skipping anything starred. The right-to-be-forgotten path, scrub_deleted_account_from_replay, walks a replay's participant pawns, clears the deleted account's identifiers, swaps in the Skin.Anonymous.DeletedAccount skin, and flips privacy_scrubbed — a deleted player still appears in preserved replays, but only as an anonymous silhouette. The shared speedrun ghost archive accepts only verified_by_server submissions carrying replay, input-stream, and checkpoint hashes; a faster ghost replaces the active route ghost while SpeedrunGhostStatus::Superseded and superseded_by_ghost_id preserve the full lineage for audits. On the client, UV4ReplayStoreSubsystem and UV4ReplayUploadSubsystem hold the local FV4ReplayRecord model and the upload Progress field that the UI watches.

Moderation, anti-cheat, and compliance#

Anti-cheat and moderation deliberately share one tested CaseService: the anti_cheat_router and moderation_router both delegate to case_router(). Report intake requires real evidence — submit_player_report rejects any report whose attached recent_gameplay_clip is missing a cloud URI or has zero duration — and scan_content is a graded classifier, not a coin flip: nudity ≥ 85 or violence ≥ 90 blocks distribution, lower scores route to human review, and a Lilith rights match triggers a takedown cascade plus an appeal. Appeals resolve at https://support.v4.game/appeal?sanctionId=…. The architecture is explicit that ML behavioural detection flags for review and that permanent bans require replay evidence + telemetry evidence + manual review — model output prioritises a queue, it never silently disciplines. Kernel-mode EAC/BattlEye is named as the client anti-cheat tier; like V2, that driver is a planned surface, while the replay-validation integrity check rides the determinism the engine already guarantees. Compliance is its own tested service: ComplianceService exposes DSAR export, right-to-delete, statement-of-reasons logging, an age-gate, and per-region residency, and the in-engine UV4ComplianceSubsystem resolves regional privacy defaults across EU/UK/US/BR/JP/KR/IN/CN (China voice-off; CN/KR/IN cosmetic-only economy).

flowchart LR subgraph Client["V4 client · Unreal C++"] OS["V4OnlineServices<br/>Login · MM · Session · Crossplay · Replay"] PS["V4Persistence<br/>SaveGame · Profile · CurrencyLedger"] end OS -->|OnlineSubsystem / EOSShared| EOS[(EOS · PSN · XBL · NN<br/>Steam · Apple · Google)] OS -->|REST / gateway| GW[API gateway] PS -. cloud save .-> GW GW --> R["v4-online-services · Rust + Axum<br/>service_router()"] R --> MM[matchmaking · Glicko-2] R --> LOGIN[login · OAuth2 + PKCE] R --> REPLAY[replay · vault · RTBF scrub] R --> LB[leaderboard · per cell/region/season] R --> MOD[moderation / anti-cheat] MM & LOGIN & REPLAY & LB & MOD -.specified store.-> DS[(PostgreSQL · Redis<br/>ClickHouse · S3)]

Persistence and save#

Save game and cloud sync#

UV4SaveGameBase is the model behind every cell's save. A slot (FV4CellSaveSlot) is keyed CellId.SlotId, versioned, and carries its payload as PayloadJson, a ChapterId, and a set of honest booleans — bAutoSave, bQuickSave, bCloudSyncQueued, bCloudSynced. The save policy is real: BuildDefaultAutoSavePolicy is a 60-second interval, PvE-only, save-on-chapter-transition; ShouldAutoSave enforces all three; and PutQuickSave flatly refuses a non-PvE quick-save (if (!bPvE) return false) so competitive modes cannot save-scum. MigrateSlotsToVersion walks every slot and lifts it to a target version, and UV4SaveMigrationSubsystem registers FV4SaveMigrationStep from/to pairs so a schema bump is a registered migration, not an ad-hoc rewrite.

UV4SaveSyncSubsystem owns cross-platform sync metadata: QueueCloudSyncForSave mints a PlatformSlotId of the form platform.cell.slot, MarkSynced records a LastSyncUtc and a ConflictToken for last-writer reconciliation, and IsCloudSyncEnabled gates per slot. Honest scope note: this subsystem is the sync ledger and conflict-token tracker — the actual durable transport is the platform-layer OnlineServices SaveGame surface the architecture names, not bytes this class writes itself.

Profile, operator XP, and the codex#

UV4ProfileSubsystem implements the cross-cell progression math. The model is a linear 1000 XP per level (DA_ProgressionModel): operator levels run 1-100 and clamp at 99,000 XP, account levels run 1-1000 and clamp at 999,000 XP, and the anti-grind rule is enforced in code — ClampOperatorDailyXPGrant returns 0 once a day's operator XP reaches the 5000 XP cap, and otherwise clamps the grant to the remaining headroom. UpsertProfile sanitises on the way in (drops None keys, floors XP at zero). The codex is genuinely populated: BuildCodexCategories returns the eight unlockable categories (Operator, Weapon, Gadget, Map, Faction, Civilization, Ruleset, Vehicle) and BuildLaunchCodexEntrySamples ships real, authored entries — Cobra's Shadow War dossier, a modular service rifle's recoil identity, the MH-6 Little Bird's insertion routes — each with lore text, gallery ids, design commentary, and account/operator-level gates that CanUnlockCodexEntry checks. XP is server-authoritative by design: the client reports a result, the server validates against telemetry, and the grant lands as a ledger entry.

The currency ledger#

UV4CurrencyLedger is a three-tier, audit-trailed ledger. BuildDefaultCurrencyDefinitions defines Operator Tokens (soft, earned, cross-platform), Combat Points (hard, purchased, per-platform), and Elite Tokens (premium, earned through ranked, cross-platform). ApplyTransaction rejects an unsupported currency or a transaction that would drive the balance negative, mints a GUID TransactionId, records BalanceAfter, and appends to an AuditTrail — every movement is reconstructable. ApplyServerValidatedTransaction adds the integrity gate the architecture promises: it requires a non-empty ServerValidationToken, demands a PlatformId for any per-platform currency, and sets bServerValidated. The cross-platform flag is computed from the currency, not asserted — bCrossPlatform = CurrencyId != "CombatPoints" — so the per-platform hard currency can never silently leak across a store boundary.

Cosmetic inventory and the persistent world#

Cosmetics are owned, not just listed. UnlockCosmeticItem validates the category against the nine launch categories (operator/weapon skins, charms, banner and calling cards, emotes, stickers, badges, sprays), records a FV4CosmeticInventoryItem with its SourceId and GrantedAtUtc, and HasCrossPlatformCosmetic confirms an item travels with the account. The FV4ProfileState that carries all of this — XP, per-operator XP, unlocked origin comics and codex entries, the cosmetic inventory, and settings — is the single object cross-progression syncs.

The most distinctive persistence surface is the Hitman cell's living world. UV4HitmanPersistentWorldSubsystem records per-profile NPC state (RecordNpcObserved, RecordNpcKilled with story-mode and primary-target flags) and decides, via ShouldSpawnNpcForMode, whether a story-dead NPC stays gone or a Quick Mission resets it (ResetQuickMissionRuntimeState). On top of that per-player state it builds the community layer the wiki publishes: RecordSeasonalNpcKill rolls up all-map kills, BuildReadOnlyWorldStateFeed emits the read-only season snapshot (bReadOnly = true), BuildWorldStateRippleEvents maps Hitman assassinations to campaign branch flags, and RecordCommunityRaidContribution / BuildSeasonalWorldBossRaids drive the async Eclipse Leviathan world-boss. Critically, ripple events carry bDeterministicBeforeLoad = true: cross-cell hooks resolve before a mission loads, so live community telemetry can change the fiction around a match but never the deterministic combat simulation inside it.

Where this connects#

  • Down to the match: Networking & Determinism — the deterministic sim the server-side replay-validation integrity check depends on, and the dedicated-server allocation the session-service drives.
  • Sideways to live service: Game Modes & Live Service — the seasonal ranked operations, the store and battle-pass entitlements, and the DLC GameFeature cadence that ride this backbone.
  • Platform foundations: Persistence & Data — the Postgres / Redis / ClickHouse / object-storage substrate these services are specified to deploy onto, and the account-root and data-residency posture they inherit.