Skip to content

Backend v2 - Inventory + Selection Authority

Purpose

This document is the current source of truth for: - Nakama-owned inventory snapshot transport (bitset-based). - Server-authoritative selection transport (house/listen server + dedicated server behavior). - Entry points between gameplay code, online services, and Nakama RPC. - Debug workflow and future tooling requirements (XLSX <-> Nakama storage conversion).

Use this as seed context for future conversations that need to debug or extend the system.

The current House freeze, matchmaking-admission, Edgegap deployment, aggregate failure, and committed-travel protocol is maintained in multiplayer/house-lifecycle/launch-protocol.md. That focused page supersedes the historical "Proposed Implementation Spec" below wherever the two differ.

Current Architecture

Runtime owners

  • UTankInventorySubsystem:
  • Owns local inventory snapshot cache and decoded bitsets.
  • Pulls player_get_owned_snapshot after Nakama login success.
  • Indexes UTankItemDef assets by (CategoryId, CatalogIndex).
  • UNakamaConnectionSubsystem:
  • Owns Nakama client/session and RPC wrappers.
  • Exposes selection RPC wrappers and completion delegates.
  • Loads selection feature flags from environment.
  • UOnlineManagerSubsystem + FOnlineSelectionTransportService:
  • Owns server-authoritative selection state.
  • Handles bootstrap on join, confirm path, 1s coalesced fanout, 5s verify batch.
  • UOnlineSelectionPayloadLibrary:
  • Builds deterministic selection JSON payloads from UTankCharacterDef/UTankItemDef references.
  • Supports both equipped object output and full user_selection_current_v1 output.
  • ATankPlayerState:
  • Replicated canonical selection state (FPlayerSelectionCanonicalState).
  • ATankPlayerController:
  • Client/server RPC bridge for selection confirm and remote broadcasts.

Feature flags

Loaded in UNakamaConnectionSubsystem::LoadSelectionFeatureFlagsFromEnvironment(): - ENABLE_SELECTION_AUTHORITY - ENABLE_LS_VERIFY_BATCH - ENABLE_SELECTION_BOOTSTRAP_ON_JOIN

FOnlineSelectionTransportService reads these once and caches behavior gates.

Important behavior: - Missing env vars default to false. - UE reads these flags from the UE process environment (not from Nakama RPC feature_flags payloads). - Listen-server verify batch is force-enabled by runtime netmode (NM_ListenServer) even when ENABLE_LS_VERIFY_BATCH is unset. - Listen-server join bootstrap is force-enabled by runtime netmode (NM_ListenServer) even when ENABLE_SELECTION_BOOTSTRAP_ON_JOIN is unset. - Dedicated server join bootstrap still follows ENABLE_SELECTION_BOOTSTRAP_ON_JOIN.

Env injection source of truth (Nakama -> Edgegap DS)

Operational finding (April 2026): - For Nakama runtime env values in this deployment, docker-compose.yml is the source of truth. - data/config.yml can diverge and create confusion; container-level env overrides can win. - Keep selection flags in one place (docker-compose.yml) to avoid split-brain config.

Current effective flow: 1. Nakama reads process env in main.go via os.Getenv(...) for: - ENABLE_SELECTION_AUTHORITY - ENABLE_LS_VERIFY_BATCH - ENABLE_SELECTION_BOOTSTRAP_ON_JOIN 2. applySelectionFeatureFlagsToDeploymentMetadata(...) injects these values into pending deployment metadata before efm.Create(...). 3. Edgegap FleetManager serializes deployment metadata into DS env NAKAMA_INSTANCE_METADATA. 4. DS reads and parses NAKAMA_INSTANCE_METADATA in UNakamaConnectionSubsystem::LoadSelectionFeatureFlagsFromEnvironment().

Notes: - create_success_callback metadata can be empty/null depending callback payload behavior; do not use callback metadata null alone as failure signal. - Use Nakama probes (debug_selection_flags_seed, selection flags probe logs) plus DS startup log (Selection feature flags loaded: ...) for end-to-end verification.

Safe rollout procedure (Nakama module/env changes): 1. Rebuild Go plugin (edgegap.so) after any main.go change. 2. Rebuild Nakama container image. 3. Recreate Nakama container (do not only restart), so new image/env/module are definitely loaded. 4. Verify new container boot logs include init_module_env probe and expected flag values.

Rationale: - restart alone can keep stale runtime state/image assumptions. - recreate is the safest path for env + module consistency.

Authoritative Data Model

Owned inventory tuple

  • Freshness key: (owned_epoch:int32, owned_rev:int32).
  • owned_epoch is global from Nakama system/owned_epoch.
  • owned_rev is per-player.

Selection tuple

  • Freshness key: (selection_epoch:int32, selection_rev:int32).
  • Runtime server-side stale check currently compares selection_rev against current canonical per-player revision.

Bitset encoding contract

  • encoding: bitset_b64_lsb0.
  • Each category entry carries:
  • n: bit count (catalog size for that category).
  • bits_b64: exactly ceil(n/8) bytes when decoded.
  • Bit order: LSB0 in each byte.
  • Ownership test: bytes[index / 8] & (1 << (index % 8)).

Nakama RPC Surface

Current RPC names used by UE: - player_get_owned_snapshot - house_set_selection_batch - house_set_selection_lock - begin_house_freeze - cancel_house_freeze - confirm_final_selection - get_house_freeze_status - sv_get_selection_by_eos

Nakama Module Source Of Truth (DevOps)

Backend behavior must be verified against: - DevOps/Nakama/nakama/data/modules/edgegap/main.go

Important alignment points from the current Go module: - RPC names are defined as constants in the module and registered in InitModule. - rpcPlayerSetSelectionConfirm persists to player storage object user_selection_current_v1/current. - rpcServerGetSelectionByEOS resolves EOS -> Nakama user id, then reads user_selection_current_v1/current for that user. - rpcServerGetSelectionByEOS request currently accepts eos_puid + optional secret only (no house_id or instance_id). - rpcServerGetSelectionByEOS and rpcLsVerifySelectionBatch authorize server-side calls against DS_RPC_SECRET when set. - Edgegap create callback emits Nakama notification connection-info (code 1001) with fields: - IpAddress - DnsName - Port - InstanceId - Selection feature flags are injected into deployment metadata in OnMatchmakerMatched(...) via: - pendingMetadata = applySelectionFeatureFlagsToDeploymentMetadata(pendingMetadata)

Payload Contracts (Current)

player_get_owned_snapshot response (consumed by UTankInventorySubsystem)

{
  "schema_version": 1,
  "catalog_version": 1,
  "owned_epoch": 2,
  "owned_rev": 37,
  "encoding": "bitset_b64_lsb0",
  "owned": [
    {
      "category": "weapons",
      "n": 128,
      "bits_b64": "...."
    },
    {
      "category": "cosmetics_hat",
      "n": 320,
      "bits_b64": "...."
    }
  ]
}

Notes: - Parser is strict for owned[] entries (category, n, bits_b64 required). - Cache is replaced atomically only after full parse + full decode succeeds.

Live selection persistence

Owning clients no longer write selections directly to Nakama. The LS assigns canonical revisions and persists all dirty House members through one house_set_selection_batch RPC per five-second dirty window.

player_set_selection_confirm and its UE submission API have been removed.

user_selection_current_v1 object (builder output)

Generated by UOnlineSelectionPayloadLibrary::BuildUserSelectionCurrentJsonV1FromDefs(...):

{
  "schema_version": 1,
  "catalog_version": 1,
  "selection_epoch": 2,
  "selection_rev": 14,
  "locked": false,
  "equipped": {
    "character_category": "characters",
    "selected": [
      {
        "category": "characters",
        "entry": "character",
        "index": 4
      },
      {
        "category": "weapons",
        "entry": "slot_0",
        "index": 101
      }
    ]
  }
}

Builder mapping behavior (BuildEquippedJsonFromDefs(...)): - Character selection is emitted as one flattened selected entry: - category = normalized CharacterDef.CategoryId when provided, else fallback character. - entry = character. - index = CharacterDef.CatalogIndex. - equipped.character_category is emitted for deterministic character extraction on UE runtime. - Item selections are emitted as flattened selected entries: - category = BucketOverride when provided, else normalized ItemDef.CategoryId. - entry = EntryKeyOverride, else SlotKey, else deterministic fallback slot_<index>. - index = ItemDef.CatalogIndex. - ItemSlots entries with null ItemDef are treated as empty slots and omitted. - Duplicate (category, entry) is rejected. - Legacy selection shapes (for example equipped.character, equipped.weapons, or bitset-style selected[]) are rejected.

house_set_selection_lock request

{
  "house_id": "<house>",
  "locked": true
}

Only the authenticated Nakama user recorded as the House host may call this RPC. Nakama derives the targets from its authoritative House membership roster and updates every member in one version-checked storage operation.

sv_get_selection_by_eos request/response

Request:

{
  "eos_puid": "<puid>",
  "secret": "<optional>"
}

Response fields consumed by UE: - catalog_version - selection_epoch - selection_rev - locked - equipped (object) - nakama_user_id (optional)

ls_verify_selection_batch request/response

Request:

{
  "house_id": "<house>",
  "secret": "<optional>",
  "entries": [
    {
      "nakama_user_id": "<optional>",
      "eos_puid": "<optional>",
      "catalog_version": 1,
      "selection_epoch": 2,
      "selection_rev": 14,
      "equipped": {}
    }
  ]
}

Response:

{
  "violations": [
    {
      "nakama_user_id": "...",
      "eos_puid": "...",
      "code": "UNOWNED_ITEM",
      "message": "item not owned",
      "bad_field": "selected[1].index"
    }
  ]
}

Transport Entry Points (UE)

Inventory

  1. Login success event:
  2. UNakamaConnectionSubsystem::OnNakamaLoginSucceeded.
  3. Subscriber:
  4. UTankInventorySubsystem::HandleNakamaLoginSucceeded().
  5. Fetch:
  6. UTankInventorySubsystem::RequestOwnedSnapshotFromNakama() -> RPC player_get_owned_snapshot.
  7. Apply:
  8. OnGetOwnedSnapshotSuccess() parses + decodes + commits cache.
  9. Notify:
  10. OnInventorySnapshotUpdated(bool bSuccess, FString ErrorMessage).
  11. Blueprint async wrapper:
  12. UAsyncAction_RequestInventorySnapshot.

Selection (client -> server authority)

  1. UI/gameplay call:
  2. Raw path: UOnlineManagerSubsystem::SubmitSelectionConfirm(CatalogVersion, SelectionRevision, EquippedJson).
  3. Def-based path: UOnlineManagerSubsystem::SubmitSelectionConfirmFromDefs(CatalogVersion, SelectionRevision, CharacterDef, ItemSlots).
  4. Controller RPC:
  5. ATankPlayerController::SubmitSelectionConfirmToServer() -> ServerSubmitSelectionConfirm.
  6. Server service:
  7. UOnlineManagerSubsystem::HandleSelectionConfirmRequestFromServer(...).
  8. FOnlineSelectionTransportService::HandleSelectionConfirmRequest(...).
  9. Ack to owner:
  10. ClientSelectionConfirmAck(bAccepted, Reason, SelectionEpoch, SelectionRevision).
  11. Broadcast to other clients:
  12. ClientSelectionBroadcast(...), excluded owner.

Selection bootstrap on join (LS->LS and LS->DS unified path)

Lifecycle hooks: - ATankGameMode::GenericPlayerInitialization -> HandleSelectionPlayerInitialized. - ATankGameMode::OnControllerOnRepPlayerState -> HandleSelectionPlayerStateReady. - ATankGameMode::Logout -> HandleSelectionPlayerLogout.

Flow: 1. Enqueue bootstrap request per joining player. 2. sv_get_selection_by_eos fetch by EOS PUID. 3. Apply canonical state to ATankPlayerState. 4. Resume spawn/restart flow after bootstrap completes/fails.

Gate: - Listen server: always active. - Dedicated server: active when ENABLE_SELECTION_BOOTSTRAP_ON_JOIN=true in the UE process environment. - The LS host player is included in this flow.

LS -> DS canonical selection handoff (intended product flow)

Intended: 1. Listen server finalizes each player's character/loadout for that room before DS travel. 2. Canonical selections for that specific room/deployment are persisted to Nakama. 3. DS reads those canonical selections and assigns them during join bootstrap. 4. DS does not accept live selection mutation.

Current implementation: 1. LS validates live selection intents and assigns canonical revisions. 2. LS persists dirty selections in house_set_selection_batch windows. 3. Match launch creates a backend-authoritative House freeze attempt. 4. Every owning client confirms the replicated final attempt once. 5. Nakama atomically locks the full roster and writes the frozen snapshot. 6. DS bootstrap reads the deployment-scoped frozen selection. 7. DS rejects live selection updates.

Proposed Implementation Spec: Room-Scoped LS -> DS Selection Handoff

This section is the implementation target for the intended flow: - LS freezes canonical per-player selection for a specific room/deployment. - DS reads only that frozen snapshot and assigns loadouts on join.

1) Nakama storage schema (room/deployment scoped)

Use houses_meta (system user) for deployment snapshots.

Key A (deployment snapshot): - selection_snapshot:<house_id>:<deployment_id>

Value (example):

{
  "schema_version": 1,
  "house_id": "20260326-0001",
  "deployment_id": "f68e011bfb01",
  "snapshot_id": "20260326-0001:f68e011bfb01",
  "created_at": "2026-03-26T23:07:11Z",
  "created_by_nakama_user_id": "<host_nakama_user_id>",
  "expected_player_count": 6,
  "frozen_player_count": 6,
  "players": [
    {
      "nakama_user_id": "<uuid>",
      "eos_puid": "<puid>",
      "catalog_version": 1,
      "selection_epoch": 2,
      "selection_rev": 14,
      "locked": true,
      "equipped": {}
    }
  ]
}

Key B (active deployment alias for house): - selection_snapshot_active:<house_id>

Value:

{
  "schema_version": 1,
  "house_id": "20260326-0001",
  "deployment_id": "f68e011bfb01",
  "snapshot_id": "20260326-0001:f68e011bfb01",
  "updated_at": "2026-03-26T23:07:11Z"
}

Rules: - Snapshot object is immutable after finalize. - Alias object can move only when a new deployment is created for the same house. - players[] must contain unique eos_puid and unique nakama_user_id. - Snapshot should expire/cleanup with house lifecycle cleanup.

2) New RPC contracts

Keep current RPCs unchanged for backward compatibility. Add two new server RPCs in Nakama module.

RPC 1: sv_freeze_house_selection_snapshot - Caller: LS authority (server-side HTTP-key RPC path). - Auth: DS_RPC_SECRET (same pattern as sv_get_selection_by_eos and ls_verify_selection_batch). - Purpose: persist a frozen candidate snapshot for house players before DS create/travel.

Request:

{
  "house_id": "<house_id>",
  "expected_player_count": 6,
  "players": [
    {
      "nakama_user_id": "<optional>",
      "eos_puid": "<required if nakama_user_id missing>",
      "catalog_version": 1,
      "selection_epoch": 2,
      "selection_rev": 14,
      "locked": true,
      "equipped": {}
    }
  ],
  "secret": "<optional>"
}

Response:

{
  "freeze_ok": true,
  "house_id": "<house_id>",
  "freeze_id": "<opaque_id>",
  "frozen_player_count": 6,
  "created_at": "<rfc3339>"
}

RPC 2: sv_get_deployment_selection_by_eos - Caller: DS join bootstrap path. - Auth: DS_RPC_SECRET. - Purpose: fetch room/deployment frozen canonical selection for one joiner.

Request:

{
  "house_id": "<house_id>",
  "deployment_id": "<instance_id / ARBITRIUM_REQUEST_ID>",
  "eos_puid": "<puid>",
  "secret": "<optional>"
}

Response:

{
  "house_id": "<house_id>",
  "deployment_id": "<deployment_id>",
  "nakama_user_id": "<uuid>",
  "catalog_version": 1,
  "selection_epoch": 2,
  "selection_rev": 14,
  "locked": true,
  "equipped": {}
}

Error behavior: - return not_found when deployment snapshot missing or player missing in snapshot. - DS should reject spawn/join when snapshot is required and not found. - no fallback to global user_selection_current_v1/current when strict mode enabled.

3) LS pre-travel freeze barrier sequence

Target sequence: 1. LS enters match-start transition and stops accepting further selection changes. 2. LS locks player selection state (locked=true) in authoritative runtime state. 3. LS collects canonical selection from each active player runtime state. 4. LS calls sv_freeze_house_selection_snapshot with full roster and versions. 5. On freeze success, LS triggers DS deployment create request. 6. Nakama create callback receives deployment_id (InstanceId) and finalizes snapshot under: - selection_snapshot:<house_id>:<deployment_id> - updates alias selection_snapshot_active:<house_id> 7. Nakama sends connection-info notifications (already implemented). 8. Clients travel to DS with ?instance_id=<deployment_id> (already implemented). 9. DS join bootstrap calls sv_get_deployment_selection_by_eos and applies canonical state before spawn.

Required UE updates: - FOnlineSelectionTransportService bootstrap request must include house_id + deployment_id. - DS bootstrap should read deployment_id from ARBITRIUM_REQUEST_ID (or server-cached instance id). - Add a strict gate: if deployment-scoped selection is required and RPC fails, do not spawn player.

4) Rollout and compatibility

Recommended feature flags: - ENABLE_MATCH_SCOPED_SELECTION_SNAPSHOT - REQUIRE_MATCH_SCOPED_SELECTION_ON_DS

Rollout steps: 1. Ship new Nakama RPCs and storage writes first. 2. Ship UE changes to call new RPC on DS bootstrap, keep fallback temporarily. 3. Enable strict mode (REQUIRE_MATCH_SCOPED_SELECTION_ON_DS=true) after validation. 4. Remove legacy fallback path once stable.

Selection lock integration

  • UOnlineManagerSubsystem::SetSelectionLockForReadyFlow(bLocked, bPersistToNakama).
  • Server updates canonical lock on each player.
  • Live selections are persisted by the LS in one house_set_selection_batch request per dirty five-second window, regardless of how many players changed.
  • Match launch uses begin_house_freeze; Nakama derives and closes the roster, then each owning client calls confirm_final_selection once for the replicated attempt id.
  • Nakama atomically locks all selections and writes the frozen snapshot only after every frozen roster member confirms.
  • A failed freeze requests a complete House unlock; successful rehost requests the unlock after the replacement listen server is confirmed ready.

Breaking Change: Intentional Disconnect and House Travel Ownership

As of August 31, 2026, project-controlled House recovery and rehost transitions must not use GEngine->HandleDisconnect(...) as a transport teardown primitive. HandleDisconnect also starts Unreal's default disconnect travel, which can schedule GameDefaultMap/MainMenu while the project state machine is already preparing House_Level. The two travel requests can race and can also incorrectly start the TravelToMainMenu UI transition.

The following intentional paths now call GEngine->ShutdownWorldNetDriver(World) instead:

  • Nakama fail-closed recovery in UOnlineManagerSubsystem:
  • shuts down the active world net driver without scheduling a map change;
  • waits for the existing EOS cleanup completion;
  • then opens House_Level through the fail-closed recovery state machine.
  • DS -> LS host rehost in FOnlineMatchEndService:
  • shuts down the dedicated-server client transport without scheduling a map change;
  • retains the existing net-driver-clear retry gate;
  • then opens House_Level as a listen server through the match-end rehost state machine.

Integration consequences:

  • These two paths no longer run HandleDisconnect side effects and do not trigger TravelToMainMenu. Code or Blueprint that depended on those side effects must instead attach to the owning fail-closed or match-end rehost lifecycle.
  • Each intentional path has exactly one map-travel owner. Do not add a second OpenLevel, disconnect command, return-to-menu call, or default-map fallback alongside it.
  • ShutdownWorldNetDriver is transport-only for these flows: Unreal detaches and destroys the world's net drivers and their connections, while the owning project state machine chooses the next destination.
  • A missing active net driver is a valid no-op; recovery/rehost must continue through its existing state machine.
  • Genuine unexpected network failures, travel failures, explicit disconnect commands, and server-directed return-to-menu behavior remain owned by UTankGameEngine/ATankPlayerController and still use the normal TravelToMainMenu behavior.

Future rule: use HandleDisconnect only when the engine's default-map travel is actually the intended outcome. If a project state machine owns the next map, tear down its transport explicitly and let that state machine perform the sole travel.

Server Behavior Guarantees

Stage 4 behavior (authoritative confirm path)

On server, confirm is rejected when: - no authority/player state not ready. - join bootstrap still pending for that player. - catalog_version <= 0. - selection_rev <= 0. - equipped JSON invalid. - canonical state currently locked. - stale revision (incoming <= current).

Stage 5 behavior (1s multicast coalescing)

  • Window: SelectionBroadcastCoalesceSeconds = 1.0.
  • First accepted change: immediate broadcast.
  • During active window: only latest pending broadcast retained.
  • Nakama live-selection writes are latest-value coalesced across every dirty player in the LS five-second batch window.
  • At window boundary:
  • if pending exists -> broadcast latest and open next 1s window.
  • if no pending -> close window.
  • Before OnServerSelectionBroadcastTick is emitted, the server now synchronously resolves the selected UTankCharacterDef primary asset and loads UI + Gameplay bundles plus referenced soft assets so Tick.CharacterDef is fully usable by listeners.

This matches expected B/D/E pattern under rapid toggling.

Stage 6 behavior (5s LS verify batch)

  • Window: SelectionVerifyBatchSeconds = 5.0.
  • Listen server only.
  • Dirty players are batched to ls_verify_selection_batch.
  • Batch verify enforces selection version hardening against Nakama current selection:
  • stale versions are rejected.
  • when incoming version is ahead, multi-step advances are allowed within the batch window.
  • forward jumps above max delta (16) are rejected.
  • Violations disconnect players via kick/return-to-menu, with server log reason.
  • Nakama remains final disconnect authority: violating sessions are disconnected by backend after verify response is produced.
  • Trust boundary: ls_verify_selection_batch is a listen-server report for selection correctness only. It is not a request-rate check.
  • Do not extend verify batch, or any LS-reported telemetry, into account/session-level punishment for alleged request spam unless the abuse is independently observable by Nakama.
  • If future LS telemetry says a player exceeded an impossible accepted-selection rate after official LS-side rate limiting exists, treat that as suspicious host/house telemetry, not proof against the alleged target player.

Stage 7 behavior (unified join bootstrap)

  • Both listen and dedicated servers fetch joiner canonical selection from Nakama on join.
  • Applied before player becomes active in normal enter-map flow.

Stage 8 behavior (DS runtime restriction)

  • Dedicated server rejects live selection changes:
  • reason: Live selection updates are not supported on dedicated server.
  • DS still supports join bootstrap fetch/apply.

Inventory/Selection APIs You Should Call

Gameplay/UI code

  • Pull owned snapshot:
  • UAsyncAction_RequestInventorySnapshot::RequestInventorySnapshot(WorldContext).
  • Query cached ownership:
  • UTankInventorySubsystem::IsOwned(CategoryId, CatalogIndex) or TryIsOwned(...).
  • Resolve/load item defs from catalog address:
  • TryResolveItemDefAssetId(...)
  • LoadItemDefByAddressAsync(...).
  • Submit live selection change:
  • Preferred def-based path: UOnlineManagerSubsystem::SubmitSelectionConfirmFromDefs(...).
  • Raw JSON path: UOnlineManagerSubsystem::SubmitSelectionConfirm(...).
  • Build selection payload JSON from defs (if you need JSON before submit):
  • UOnlineSelectionPayloadLibrary::BuildEquippedJsonFromDefs(...).
  • UOnlineSelectionPayloadLibrary::BuildUserSelectionCurrentJsonV1FromDefs(...).
  • Lock/unlock ready flow:
  • UOnlineManagerSubsystem::SetSelectionLockForReadyFlow(...).

Important integration note

Server accept/broadcast and Nakama persistence are separate operations. Direct owning-client live-selection persistence has been removed.

Normal live selection flow: 1. Owning client predicts immediately and trailing-edge debounces UI changes for 0.5 seconds; every newer change replaces the older unsent value and restarts the timer. 2. It sends one selection intent to the LS; the LS validates it and assigns the canonical revision. 3. The LS coalesces accepted changes from all players into one authenticated house_set_selection_batch RPC per dirty five-second window. 4. The LS separately latest-value coalesces UE broadcasts so a client cannot force rapid multicast traffic.

Match-launch finalization is deliberately separate from live persistence: 1. LS calls Start House Game (Async), which begins begin_house_freeze; Nakama derives the authoritative roster and returns an attempt ID. 2. LS atomically replicates {bLocked, FreezeAttemptId, canonical selection} in each PlayerState. 3. Each owner calls confirm_final_selection once per attempt. Individual success does not enter matchmaking. 4. After the whole frozen roster confirms, the LS reliably fans OnHouseFreezeSucceeded; each owning Blueprint creates its native ticket. 5. Nakama MatchmakerAdd before/after hooks gate aggregate admission and record successful ticket IDs for match validation. Ticket removal remains owning-client Blueprint cleanup on aggregate failure. 6. The LS polls get_house_freeze_status every two seconds until committed connection-info or aggregate failure. 7. Pre-commit failure invokes OnHouseGameLaunchFailed once per attempt on every client; committed connection-info forbids aggregate rollback.

Rate-limit policy: - LS-side rate limiting protects the house/listen server from ServerSubmitSelectionConfirm churn and should reject/drop excess local requests before accept/apply/ack. - Nakama-side rate limiting protects authenticated batch/freeze/confirmation RPCs. - Do not rely on UE or Nakama coalescing as request-rate protection; the token buckets remain independent guards. - Do not use LS-attributed controller pointers or LS-only spam reports as global proof against another player. A modified player-hosted LS can pass the wrong controller or submit false reports.

Design Findings (February 28, 2026)

  • SubmitSelectionConfirmFromDefs(...) must load the selected UTankCharacterDef before payload build.
  • Current implementation now synchronously resolves the character def primary asset (UI + Gameplay) and soft references before building equipped JSON.
  • This prevents payload build from reading stale/unloaded def state.
  • SubmitSelectionConfirmFromId(...) was removed from UOnlineManagerSubsystem.
  • Selection submit path is now strictly def-driven (or raw JSON via SubmitSelectionConfirm(...)), which avoids category/index drift between runtime assets and payload callers.
  • Server-side OnServerSelectionBroadcastTick continues to enforce fully loaded character defs before emitting listeners.

Item/Character Definition Pipeline

Def assets

  • UTankItemDef (primary asset type: TankItemDef):
  • identity: ItemId, CategoryId, CatalogIndex.
  • presentation: DisplayName, Icon, StaticMesh, SkeletalMesh.
  • UTankCharacterDef (primary asset type: TankCharacterDef):
  • identity/progression/presentation fields for character roster.

Import tooling

  • Location: Source/Tools/TankInventoryTools.
  • Commandlets:
  • TankItemDefCsvImport
  • TankCharacterDefCsvImport
  • Scripts:
  • Run-TankItemImport.ps1/.bat
  • Run-TankCharacterImport.ps1/.bat
  • Current character import assumption:
  • single sheet named 1, output under <OutputPath>/Character.

Debug Playbook

Inventory fetch issues

  1. Verify Nakama login succeeded and session exists.
  2. Verify feature flag path if auto-fetch is expected (IsSelectionAuthorityEnabled() gate currently used by inventory auto-fetch).
  3. Confirm RPC payload has required owned[] shape.
  4. Check decode errors: expected bytes = ceil(n/8).
  5. Check category key normalization (lowercase, trimmed).

Selection replication issues

  1. Confirm submit path hits ServerSubmitSelectionConfirm.
  2. Verify server rejection reason from OnSelectionConfirmAck.
  3. Check local client build failures before RPC:
  4. SubmitSelectionConfirmFromDefs: Failed to build equipped JSON (...).
  5. Example seen on February 28, 2026: ItemSlots[0]: ItemSlots contains a null ItemDef.
  6. If join flow stalls, check bootstrap pending state and sv_get_selection_by_eos responses.
  7. For missing remote updates, inspect coalescing timers and owner-exclusion behavior.
  8. For unexpected kicks, inspect ls_verify_selection_batch violation payload + disconnect reason.

Future Tooling Spec (for standalone app/script work)

Goal: enable offline/ops workflows without touching UE runtime.

Tool A: XLSX -> Nakama storage object generator

Inputs: - Catalog workbook(s) from design (item/character defs). - Optional per-user unlock workbook (human-readable rows). - Output mode: one user or batch users.

Output: - Valid user_inventory_owned_v1/current JSON: - schema_version, catalog_version, owned_epoch, owned_rev, encoding, owned[].

Required rules: - Category key normalization must match runtime. - n must equal category catalog size used for bit packing. - Bit packing must be LSB0. - bits_b64 length must exactly match ceil(n/8).

Tool B: Nakama storage object -> human-readable XLSX exporter

Inputs: - Raw Nakama storage JSON per user (user_inventory_owned_v1/current). - Catalog workbook for label resolution.

Output workbook (recommended): - Sheet Summary: - user id, owned tuple, catalog version, parse status. - One sheet per category: - columns: catalogIndex, itemId/characterId, displayName, unlocked(bool). - Optional sheet UnknownBits: - bits set outside known local catalog map.

Use cases: - Support CS/debug requests ("show this player's unlocks"). - Audit deltas before/after grants. - Build reports without querying custom DB tables.

Validation checklist for both tools

  • Reject malformed base64.
  • Reject mismatched byte length.
  • Reject negative n or index out of range.
  • Emit deterministic ordering for stable diffs.

Current Gaps

  1. Core selection/runtime character flow is now UTankCharacterDef driven; legacy UCharacterClassInfo assets remain in source but are no longer the active selection authority path.
  2. Def-based submit from UI can include empty BP array entries; null ItemDef slots are now omitted from payload generation, but UI should still prefer sending only populated slots.
  3. Client-side Nakama persist timing after server selection ack is still a product-rule decision point.
  4. Server stale-check still compares revision only; full tuple compare (selection_epoch, selection_rev) remains open.
  5. Sessions with missing loaded item defs (for example RebuildItemDefIndex: indexed 0 item defs) can still produce empty/partial selection payloads and should be monitored in startup validation.
  6. Intended LS -> DS room/deployment canonical handoff is not fully modeled in Nakama storage yet:
  7. no room/deployment-scoped selection object exists,
  8. sv_get_selection_by_eos does not include house_id/instance_id,
  9. travel does not currently enforce an LS-side "all players persisted/frozen" barrier before DS handoff.