Set up basic deterministic lockstep

This commit is contained in:
Vladislav Khorev committed 2026-11-05 23:02:53 +06:00
1 parent 28c6e83eda
commit 23fef6f349
26 files changed
+1172 -239

No files matched your search

+73 -52
View File
@@ -2,19 +2,21 @@
## Purpose
The game now separates authoritative gameplay state from rendering and runtime resources. The current implementation is an in-process server, but public consumers use a snapshot-and-command API designed to support a future network client.
This refactor does **not** add multiplayer networking. It establishes the state ownership and API boundaries needed before networking, replication, multiple local player representations, or server-side synchronization are implemented.
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 five layers:
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.
```text
Input / UI
@@ -103,7 +105,7 @@ array positions.
- `snapshot()` for an immutable, revisioned `GameSnapshot`.
- `submit()` for typed `GameCommand` values.
- `update()` for advancing or polling the implementation.
- `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.
@@ -143,7 +145,10 @@ Snapshots have a monotonically increasing revision. `InternalGameServer` caches
- Trigger-zone state.
- Quest-definition reloads.
A future network implementation can serialize equivalent requests instead of mutating local state.
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
@@ -159,7 +164,7 @@ Loading and persistence restoration clear pending events and remain silent.
- Exclusively owns the aggregate `GameState`.
- Executes typed commands against authoritative state.
- Advances the active location simulation through `update(deltaMs)`.
- Advances the active location simulation in exact 100 ms ticks.
- Builds and caches immutable snapshots.
- Delegates save/load serialization to the aggregate state model.
@@ -184,9 +189,12 @@ 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. Runtime `Character` objects only
pose loaded models and produce audio, particles, and presentation callbacks.
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
@@ -263,26 +271,23 @@ Configuration remains the source of visual assets, animation paths, meshes, text
## State Update Flow
The normal frame flow is:
Render frames and simulation ticks are separate:
1. SDL input is processed.
2. UI code submits semantic commands through `IGameServer`.
3. The internal server freezes location character observations and calls
`CharacterState::update()` for every present player and NPC. An empty player
collection is valid.
4. Attack impacts are queued, then damage is applied after all characters advance.
5. The runtime location consumes value-only events. Navigation requests are sent
back as semantic destination commands, whose server handler calls
`CharacterState::setTarget()` with the authoritative `LocationNetworkState`; arrival
function names are resolved through `ScriptEngine`.
6. The server invalidates its cached snapshot and advances the revision so runtime
presentation can observe the completed simulation tick.
7. Runtime characters read one immutable snapshot per operation, apply the
authoritative animation frame to loaded models, and
play audio, particles, and presentation callbacks.
8. Auxiliary dialogue, Lua, trigger, and teleport state is captured into `LocationGameState`.
9. The server publishes those auxiliary changes in a later revision.
10. Rendering, UI, and external-style consumers read immutable snapshots.
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.
@@ -335,7 +340,8 @@ 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). WebSocket replication sends only the authoritative document.
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.
@@ -394,29 +400,35 @@ 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 Readiness
## Multiplayer Lockstep
The refactor provides:
Protocol v2 uses server-sequenced deterministic lockstep:
- One authoritative aggregate state owner.
- Multiple player slots keyed by stable ID.
- Stable location, character, object, and zone identities.
- Immutable state snapshots suitable for replication.
- Semantic commands suitable for request serialization.
- A replaceable public `IGameServer` contract.
- A single active-location model matching the initial multiplayer scope.
- 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.
Still required for real multiplayer:
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.
- Network transport and command serialization.
- Authentication and player/session assignment.
- Snapshot delta generation or other replication strategy.
- Latency handling, interpolation, reconciliation, and prediction.
- Command validation, authorization, and anti-cheat rules.
- Multiplayer spawning/despawning and location membership.
- Presentation events delivered over the network.
Current boundaries remain:
The current local implementation intentionally preserves synchronous Lua and animation behavior. A remote implementation must define which of those behaviors remain server-authoritative and which become client presentation.
- 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
@@ -432,8 +444,9 @@ 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 and run the 50 ms fixed-step loop.
Networking remains outside this milestone.
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:
@@ -455,8 +468,11 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -
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.
- A remote client still needs a client-side runtime-location factory driven by
configuration/definitions and replicated snapshots.
- 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
@@ -469,6 +485,11 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -
- `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.