Files
shadow-over-bishkek001/GAME_STATE_ARCHITECTURE.md

24 KiB

Game State Architecture

Purpose

The game separates authoritative gameplay state from rendering and runtime resources. The same snapshot-and-command boundary supports the in-process single-player server and a WebSocket deterministic-lockstep client/server.

Architecture Overview

The architecture has six layers:

  1. GameState is the aggregate root for persistent, authoritative state.
  2. InternalGameServer owns GameState, advances the local simulation, handles commands, and produces immutable snapshots.
  3. ClientLocationState owns per-client LocationLocalState records, including camera orientation. Graphical hosts own this state; it is never replicated.
  4. Runtime objects hold rendering resources. Character and Location read immutable server snapshots by stable ID, while InteractiveObject still uses a transitional non-owning binding to server-owned state. Location also translates local input, scripts, and collision results into semantic server commands.
  5. Client-facing systems such as MenuManager read immutable snapshots and submit typed commands through IGameServer.
  6. Protocol-v2 WebSocket hosts assign preconfigured player slots and exchange one initial state followed by ordered 100 ms tick bundles.
Input / UI
    | typed GameCommand
    v
IGameServer
    |
    v
InternalGameServer ---- owns ----> GameState
    |                                  |
    | update                           | stable state objects
    v                                  v
Location / Character / InteractiveObject runtime
    |
    v
Renderer

IGameServer ---- immutable GameSnapshot ----> UI and other clients

Authoritative State Model

GameState

GameState is the top-level state object. It contains:

  • Campaign ID and current location ID.
  • Day, night, dawn, and darklands flags.
  • A map of campaign-level players keyed by stable player ID.
  • A map of typed LocationGameState records keyed by location ID.
  • Inventory, money, player-health HUD values, quests, tutorial progress, chat data, taxi state, and script-global values.

Live Location pointers and rendering objects are no longer stored in GameState. Embedded state models such as Inventory and QuestJournal contain only value data and deterministic operations; they do not retain runtime callbacks.

Campaign-level players

Player CharacterState objects are stored in GameState::players. The current single-player game uses the ID "player".

The player state is no longer owned by a Character or duplicated as a separately authoritative state for each loaded location. This permits additional player IDs to be introduced later without changing the aggregate ownership model.

Every player, including the one controlled by the current client, remains in this same authoritative collection. IGameServer::localPlayerId() identifies which entry is local to a particular client connection; it is not a separate state object.

LocationGameState does not retain a player pointer or back-reference to GameState. Its update boundary receives GameState::players by reference, allowing character lookup and simulation to treat every campaign player by stable ID.

LocationGameState

Each location record owns:

  • One LocationNetworkState.
  • NPC CharacterState objects keyed by configured ID.
  • InteractiveObjectState objects keyed by configured ID.
  • Trigger-zone enabled/inside flags.
  • Teleport-zone active flags.
  • Serialized dialogue runtime state.
  • Serialized Lua runtime globals.

World-mode flags (darklands, night, and dawn) exist only at the aggregate GameState level. Location rendering and scripts read those values from the top-level snapshot rather than maintaining per-location mirrors.

State objects use shared_ptr internally to keep addresses stable for the remaining local runtime bindings. These pointers never appear in public snapshots.

Stable identity

  • Locations use campaign configuration IDs.
  • Players use keys from GameState::players.
  • NPCs use required location-local configuration IDs.
  • Interactive objects use required location-local configuration IDs.
  • Trigger and teleport zones use their configured IDs.

Missing, empty, duplicate, and reserved IDs are content errors. Runtime references, Lua APIs, snapshots, and persistence all use these IDs rather than configuration array positions.

Server Interface

IGameServer is the public state boundary. It exposes:

  • snapshot() for an immutable, revisioned GameSnapshot.
  • submit() for typed GameCommand values.
  • update() for advancing a local fixed tick or polling a remote implementation.
  • save() and load() for server-side persistence.

No public method returns a mutable state pointer or reference.

Snapshots

GameSnapshot and its nested snapshot types are value-only read models. They contain no renderer objects, runtime Location pointers, mutable references, or active callbacks.

Snapshots include:

  • Campaign and world-mode state.
  • All players.
  • Locations, NPCs, interactive objects, triggers, and teleports.
  • Inventory item IDs.
  • Quest views.
  • Tutorial, chat, taxi, money, and health data.
  • Script-global values.

Snapshots have a monotonically increasing revision. InternalGameServer caches the current immutable snapshot and rebuilds it only after the authoritative state changes.

Commands

GameCommand is a C++17 std::variant of semantic command types. Current commands cover:

  • World mode and current location.
  • Player health and money.
  • Tutorial progression and flags.
  • Chat history and unread state.
  • Taxi state.
  • Inventory additions and removals.
  • Quest status, objective completion/visibility, and active-objective changes.
  • Script globals.
  • Character transforms, attack targets, navigation destinations, stopping, facing, replanning, health, damage, death reset, attack combos, and animation metadata.
  • Interactive-object activation.
  • Trigger-zone state.
  • Quest-definition reloads.

The remote implementation currently serializes only SetCharacterDestinationCommand as player intent. The server derives the character and location from the assigned connection slot; arbitrary state mutation commands are not accepted from the network.

Presentation events

IGameServer::drainEvents() returns value-only events produced by successful authoritative mutations. Inventory add/remove and quest unlock/complete/fail/ objective-complete events contain stable IDs only. Game drains these events and performs local tutorial, toast, and audio presentation through MenuManager. Loading and persistence restoration clear pending events and remain silent.

Internal Server

InternalGameServer is the current IGameServer implementation. It:

  • Exclusively owns the aggregate GameState.
  • Executes typed commands against authoritative state.
  • Advances the active location simulation in exact 100 ms ticks.
  • Builds and caches immutable snapshots.
  • Delegates save/load serialization to the aggregate state model.

LocalGameServerHost is the graphical client's integration adapter. It owns loaded runtime Location objects, forwards simulation events to them, and synchronizes dialogue, Lua, trigger, and teleport auxiliary state. Game uses the explicit IGameServerHost lifecycle interface and is no longer a friend of InternalGameServer. The standalone server does not compile this adapter.

Location loading has two phases. LocationContentLoader parses renderer-free LocationDefinition data, and LocationGameState::setup() creates/reconciles authoritative NPC, interactive-object, navigation, taxi, trigger, teleport, and animation state. The graphical Location::setup() runs afterward and only builds presentation objects from that definition and the prepared state. Lua and dialogue runtime behavior remains client-side.

Animation and combat

Logical animation frame clocks live in CharacterState. The renderer-free content loader reads animation frame metadata before authoritative entities are created. Runtime Character construction only loads models for presentation and does not submit metadata mutations back to the server.

CharacterState::update() returns value-only events containing arrival Lua function names, attack impacts, navigation requests, and death-animation completion. LocationGameState applies queued damage after every character has advanced. Navigation replans are consumed by InternalGameServer as deterministic simulation consequences rather than being resubmitted by presentation code. Runtime Character objects only pose loaded models and produce audio, particles, and presentation callbacks.

Character state operations

Gameplay operations mutate the authoritative CharacterState, not the runtime Character:

  • setTarget() normalizes the destination, avoids redundant requests, stores the optional Lua arrival function name, and asks the authoritative LocationNetworkState to call planPathExceptMyself().
  • forceReplan() recalculates the current requested destination using the same location navigation state.
  • stopInPlace() clears navigation and any pending arrival function.
  • stopAndRotateToTarget(), resetPlayerAfterDeath(), setHp(), and tryAttackCombo() update their respective state directly.

InternalGameServer::submit() resolves the character and location, then invokes these operations for the corresponding semantic command. The runtime Location provides convenience methods for submitting those commands from input, scripts, collision handling, and presentation-event processing.

Runtime and Visual Object Bindings

Runtime visual objects do not own their authoritative state objects.

  • Location keeps its IGameServer reference, stable location ID, and a shared LocationLocalState supplied by the graphical host. It resolves its immutable LocationNetworkState view from IGameServer::snapshot(), while rendering and camera input access the local state directly.
  • Character keeps only its IGameServer reference plus stable entity and location IDs. It resolves the player-first character view from IGameServer::snapshot().
  • InteractiveObject keeps a private non-owning reference to its InteractiveObjectState; its configured object name is its stable ID.

Character::stateSnapshot() and Location::stateSnapshot() return aliasing shared_ptr views. Each pointer addresses a state copy inside a GameSnapshot, while shared ownership keeps that whole immutable snapshot alive. Update, input, editor, and draw operations capture one such pointer and use it consistently throughout the operation. A missing or unloaded entity produces an empty pointer and presentation or input work is skipped.

The authoritative state objects are owned by GameState or LocationGameState. The internal server advances them before publishing a new snapshot revision. Runtime input, scripts, and collision handling submit semantic commands that invoke authoritative character and location operations. Location commands cover pending interaction targets, navigation switching and area changes, editor obstacles, and taxi reset. Camera orientation is deliberately not a server command. No whole-location commit command is exposed.

Navigation target selection, replanning, stopping, facing, death reset, health assignment, and attack-combo clock changes are implemented by CharacterState. Character no longer exposes gameplay mutation helpers or a generic state mutation callback. Character construction is presentation-only; frame ranges are already present in CharacterState after content setup. It focuses on model posing, rendering, audio, particles, and runtime callbacks.

Death-animation and health-change callbacks remain runtime-only on Character. Arrival behavior is represented solely by the serialized Lua function name in CharacterState; Location resolves and invokes that name through ScriptEngine when it consumes the arrival event.

Location owns presentation-only runtime characters for all non-local entries in GameSnapshot::players. It reconciles them by stable ID as snapshots change, reuses the local-player assets and animation set, and includes them in all render, shadow, lighting, animation, damage, and name-tag passes. The local entry is excluded using IGameServer::localPlayerId() because its visual is already owned by Game.

GameObjectLoader now:

  1. Receives immutable object/NPC definitions parsed by LocationContentLoader.
  2. Resolves already-prepared authoritative entities by stable ID.
  3. Constructs meshes, textures, VBOs, InteractiveObject, and Character presentation wrappers without creating or overwriting authoritative state.

Configuration remains the source of visual assets, animation paths, meshes, textures, and fixed tuning data.

State Update Flow

Render frames and simulation ticks are separate:

  1. SDL input and UI presentation use the real elapsed render-frame time.
  2. A local host accumulates steady_clock elapsed time and runs zero or more exact 100 ms simulation ticks. A remote host polls transport every frame but advances only from contiguous server tick messages.
  3. Commands assigned to tick N are applied in server sequence order before simulation tick N.
  4. The server freezes location character observations and updates every player, NPC, taxi, and interactive object with exactly 100 ms. An empty player collection remains valid.
  5. Attack impacts are queued and applied only after all characters advance. Navigation recovery is applied inside the deterministic state path.
  6. The server invalidates its cached snapshot and advances its revision.
  7. Runtime locations consume the completed tick's value-only events and perform rendering, audio, particles, and supported presentation callbacks.
  8. Rendering can occur between ticks and never changes authoritative tick time.

Commands that update bound character or object state do not reload the whole location. This avoids replaying dialogue-node effects or Lua callbacks.

Campaign and Location Lifecycle

Only one location is normally loaded as a runtime object.

Campaign loading

When a campaign starts:

  • Existing campaign state and runtime locations are cleared.
  • Quest definitions are loaded.
  • The graphical single-player host requests a campaign-level "player" state and creates its visual wrapper.
  • A standalone host may deliberately create no players; locations continue to tick.
  • The battle-zone test bot is an explicit host option rather than an unconditional campaign side effect.

Location loading

When a location is loaded:

  • LocationContentLoader parses a renderer-free LocationDefinition.
  • LocationGameState::setup() creates or reconciles navigation, taxi, NPC, interactive-object, trigger, teleport, and animation state.
  • Saved mutable fields are overlaid on configuration defaults by stable ID.
  • Location::setup() builds static and entity visuals from the prepared definition without mutating authoritative state.
  • The shared campaign player is attached.
  • Runtime visuals are created for other player IDs currently present in the snapshot and are subsequently spawned or removed as snapshots change.
  • Dialogue, Lua, trigger/teleport visuals, and presentation callbacks are initialized.
  • The runtime Location is owned and registered by LocalGameServerHost; InternalGameServer remains headless.

Location unloading

When leaving a location:

  • Auxiliary runtime state is captured.
  • The visual/runtime Location is destroyed.
  • Its typed LocationGameState remains in GameState.

The player state remains campaign-level and survives location changes.

Save and Load Schema

Authoritative persistence uses one strict, versioned schema. schemaVersion is required and unsupported versions are rejected rather than migrated. A single-player save is a separate versioned envelope containing networkState (the authoritative GameState JSON) and localState (the client-local location map). Lockstep sends that authoritative document only in initial_state; later messages contain numbered command bundles.

  • Campaign players are stored once in an ID-keyed players object.
  • Locations are keyed by location ID.
  • Each location stores NPC and interactive-object state in ID-keyed objects.
  • Pending interaction targets and character references use stable IDs.
  • Immutable content such as meshes, initial taxi configuration, and object definitions is reconstructed from campaign configuration.
  • Configuration is loaded first and mutable saved state is overlaid in place. Snapshot-backed runtime objects then resolve the resulting state by stable ID.

Old user save slots are intentionally unsupported. Bundled start-state snapshots must be regenerated with Game::saveInitialState() whenever the schema changes.

UI, Scripts, Dialogue, and Quests

UI

MenuManager no longer holds GameState& or a mutable Inventory*.

It now:

  • Reads inventory, quests, tutorial state, chat, money, health, location, and world mode from GameSnapshot.
  • Submits typed commands for mutations.
  • Continues to own presentation-only UI trees, screen state, textures, and callbacks.

Scripts

Lua remains an in-process single-player runtime. Character, inventory, quest, and campaign-global bindings submit semantic commands through Location; the server invokes operations on centrally owned state. Snapshot queries provide global values, inventory counts, item presence, and synchronous quest validation for the existing Lua API. NPC, interactive-object, trigger-zone, and teleport-zone bindings address entities by configured ID. Other object and global-state bindings remain trusted local integrations.

Each location script registers initialization, enter, and exit callbacks. Initialization runs only when the location has no retained state; loading the Lua file again for rendering does not repeat state-resetting setup. Enter and exit callbacks continue to run for every location transition.

The scalar Lua runtime state for each location is stored under LocationGameState::scriptGlobals. Multiplayer location scripts are expected to be empty, so remote script execution is intentionally outside the current scope.

Dialogue and cutscenes

Dialogue runtime state is captured in each LocationGameState. Dialogue global flags use snapshot getter and command setter callbacks rather than retaining a mutable map pointer. Dialogue and cutscene rendering remain local presentation systems.

Dialogues and cutscenes are outside the planned multiplayer gameplay mode, but their existing single-player save and callback behavior is retained.

Quests, inventory, and tutorial

These callback-free state systems remain part of GameState for single-player compatibility. UI access, mutations, and presentation notifications cross the server interface. They can remain empty or disabled in multiplayer mode as planned.

Multiplayer Lockstep

Protocol v2 uses server-sequenced deterministic lockstep:

  • The server assigns a connection to a preconfigured campaign player slot.
  • Connection receives one authoritative state at a completed tick plus its assigned player ID, tick duration, input delay, and compatibility fingerprint.
  • Destination input is normalized and scheduled two ticks in the future.
  • The server broadcasts every numbered tick, including empty ticks. Clients buffer out-of-order messages and advance only contiguous tick IDs.
  • All replicas apply the same globally sequenced commands before one exact 100 ms update.
  • Post-tick canonical hashes exclude persistence timestamps and local presentation state. A mismatch is diagnosed but not automatically repaired.

No wall-clock timestamp participates in simulation. steady_clock paces the server and local render accumulator only, so different machine clocks do not affect command placement or state. The current missing-packet policy is to stall; there is no prediction or rollback.

Current boundaries remain:

  • only SetCharacterDestinationCommand crosses the network;
  • player characters must already exist in the initial state and campaign slot list; dynamic spawning and reconnect history are not implemented;
  • authentication and automatic checkpoint resynchronization are absent;
  • multiplayer Lua/dialogue shared behavior is unsupported;
  • deterministic compatibility targets identical executable and content builds. Floating-point simulation is not guaranteed bit-identical across platforms.

Platform Build Integration

The new state and server files are registered in:

  • proj-windows/CMakeLists.txt
  • proj-linux/CMakeLists.txt
  • proj-web/CMakeLists.txt
  • proj-android/app/jni/src/CMakeLists.txt

The refactor uses portable C++17 and introduces no Windows-only gameplay code.

The standalone target lives under server/. It links the authoritative state, campaign, content-definition, and portable resource sources only; SDL, OpenGL, audio, Lua, dialogue presentation, and rendering sources are absent. It can prepare a selected campaign/location with zero players. The Windows server adds Boost.Beast transport and runs a fixed 100 ms lockstep loop; other platforms continue to compile the portable state core without that transport.

The Windows Debug target was verified with:

cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -- /m

Important Constraints

  • Runtime C++ callbacks are not part of CharacterState or public snapshots; serializable Lua arrival function names are state data.
  • InteractiveObjectState animation callbacks are also removed from public snapshots.
  • Character target and attacker references use stable IDs ("player" or a unique location-local NPC ID).
  • Some top-level values remain mirrors for compatibility, notably player health used by the HUD and scripts.
  • Dialogue and Lua are synchronized as auxiliary location state rather than fully networked subsystems.
  • IGameServerLifecycle is the graphics-free campaign/location administration boundary. LocalGameServerHost adds client-only runtime construction and auxiliary-state synchronization without making Game a server friend.
  • Empty player maps are valid. Player-specific interactions remain inactive until a player entity is added.
  • Pathfinding priority queues use cell index as a stable secondary key.
  • Gameplay randomness must be seeded and serialized; the random_device battle bot is disabled for multiplayer sessions.
  • A lockstep compatibility fingerprint must match before the client accepts the initial state.

Main Files

  • src/GameState.h / src/GameState.cpp — aggregate state and top-level persistence.
  • src/LocationState.h / src/LocationState.cpp — separate local camera and authoritative network location records, plus single-player persistence composition.
  • src/LocationGameState.h / src/LocationGameState.cpp — authoritative typed location state, simulation, and ID-keyed persistence.
  • src/content/LocationDefinition.h / .cpp — renderer-free content definitions, parsing, and animation metadata loading.
  • src/game_server/IGameServer.h — public snapshot and command contract.
  • src/game_server/IGameServerLifecycle.h — graphics-free campaign/location lifecycle.
  • src/game_server/InternalGameServer.h / .cpp — in-process authoritative implementation.
  • src/game_server/LocalGameServerHost.h / .cpp — graphical-client runtime adapter.
  • src/game_server/NetworkProtocol.h / .cpp — protocol-v2 messages, lockstep timeline, validation, and canonical hashes.
  • src/game_server/WebsocketGameServer.h / .cpp and WebsocketGameClient.h / .cpp — slot assignment, input scheduling, tick broadcasting, buffering, and replica advancement.
  • src/Game.h / src/Game.cpp — application host, campaign lifecycle, and runtime wiring.
  • src/Location.h / src/Location.cpp — loaded location runtime, command routing, interactions, collisions, scripts, and presentation-event consumption.
  • src/Character.h / src/Character.cpp — passive character presentation resolving immutable CharacterState snapshots by stable ID.
  • src/CharacterState.h / src/CharacterState.cpp — deterministic character simulation, navigation operations, combat state, and logical animation clock.
  • src/items/InteractiveObject.h / .cpp — visual object runtime bound to central object state.
  • src/items/GameObjectLoader.h / .cpp — presentation builders for prepared definitions.
  • src/MenuManager.h / src/MenuManager.cpp — snapshot-driven UI and command submission.