In the previous update, AoNW already had one canonical turn pipeline and a growing set of shared game rules. Local play, the multiplayer server, AI simulations, and turn processing were starting to use the same implementations instead of maintaining similar versions of the same behaviour.
Movement was one of the largest parts of that work. Manual movement, auto-exploration, queued paths, fog updates, and AI movement had moved toward one shared model. The engine could calculate the exact route taken by a unit, including every step and its movement cost.
One important gap still remained: the engine knew the route, but multiplayer transport did not always carry it to every client.
A remote client could receive the state before the move and the state after the move, but not the exact path used between them. It could sometimes infer a simple visual jump to the destination, but that was not enough for queued movement, auto-exploration, multiple units moving during turn finalization, or a unit moving more than once in one sequence.
That gap is now closed.
A Smaller Server Boundary
The work started by making the multiplayer boundary more explicit.
RunningMatchSnapshotCodec now owns the decoding and encoding of active multiplayer snapshots. It preserves the original wire representation and only replaces the save or state when that part actually changed. This avoids rewriting optional fields or changing JSON that was not touched by the command.
Several server-only decisions have also moved away from persistence models. Outcome detection now works on explicit domain collections, while timeout actor selection uses the ordered participant list. Resignation updates canonical session state and decides whether the match continues, finishes, or becomes abandoned.
Resource trade and diplomacy now follow the same pattern as the earlier city and worker migrations. The local game and server can call one persistence-neutral rule, while their adapters remain responsible for UI state, storage, networking, and server lifecycle.
This does not make the complete server canonical yet, but it reduces the number of reasons why server orchestration needs to understand the old state model.
One Movement Rule for Different Runtimes
Manual movement now enters through MovementCommandResolver. The resolver receives a small movement state, the command, the actor, a traversal view of the map, the visibility mode, and optional path restrictions.
It validates the command, plans the route, checks whether the destination can eventually be reached, applies the movement, updates fog and diplomacy when needed, and returns the changed state slices.
The same rule is used by the local reducer, multiplayer server, MCTS simulation, auto-exploration, and turn continuation. These callers can still use different information models, but they no longer need separate movement algorithms.
Auto-exploration is now built on top of the same resolver. It chooses a target, prepares the scout, applies its path restrictions, and delegates the actual movement. When the next turn starts, automatic continuation uses that same path again instead of keeping another turn-specific implementation.
Every accepted move can produce a MovementCommandExecution. This is a renderer-neutral record containing the unit ID, its exact origin, and the ordered steps that were actually travelled. It represents what happened, not what the renderer should guess from the final state.
flowchart LR
Command["Move command or<br/>turn finalization"]
Rules["Shared movement rules"]
Execution["MovementCommandExecution<br/>origin + ordered steps + costs"]
Store["Snapshot and canonical event"]
Projection["Recipient-safe projection"]
Ack["ACK for command caller"]
Live["Live event for other clients"]
Validation["Client chain validation"]
Renderer["Ordered renderer queue"]
Command --> Rules
Rules --> Execution
Execution --> Store
Store --> Projection
Projection --> Ack
Projection --> Live
Ack --> Validation
Live --> Validation
Validation --> Renderer
Movement Is Now Part of the Protocol
The next step was to carry this execution record through the multiplayer protocol.
Every protocol-v3 WireCommandAck and WireEvent now contains a required movementExecutions list. Each execution includes the unit, its starting position, every travelled coordinate, the entry cost of each step, and the cumulative cost. A normal movement command usually produces one execution, while turn finalization can produce a longer, globally ordered sequence for several units.
An empty list has an explicit meaning: there is no visible movement to animate for this recipient. A missing field, null, or malformed value is not interpreted as an older fallback. It is an invalid protocol-v3 message.
This removes an important ambiguity. The client no longer needs to decide whether an absent movement plan means “nothing moved,” “the server is old,” “the route was hidden,” or “the route was lost.” The producer must always provide a clear answer.
Accepted commands store the new snapshot and canonical event at one offset. The command caller receives the projected movement through the direct ACK, while the other connected players receive it through the live event with its attached snapshot.
A retry using the same clientMessageId and the same command reuses the stored result instead of executing the command again. Reusing the identifier for a different command is rejected. This prevents a network retry from creating another state transition or playing the same movement twice.
Fog of War Protects the Complete Route
Sending exact routes creates a privacy problem. A unit may finish on a visible tile while part of the route crossed an area that another player could not see.
The server therefore projects movement as a complete chain per unit.
The owner can receive the full chain. Another player receives it only when the origin and every executed coordinate were visible in both the previous and next fog state. When continuity, endpoints, visibility, or audience metadata do not match, the complete chain for that unit is removed instead of sending only the visible part.
This is important because even a partial path could reveal hidden information about direction, route length, terrain, or movement cost.
The server stores audience information as internal metadata, but removes it before the movement crosses the network boundary. If a recipient cannot see any complete movement chain, the server sends an explicit empty list.
flowchart TB
Canonical["Canonical executions<br/>A1 → B1 → A2"]
Canonical --> Owner["Unit owner<br/>receives the complete own chain"]
Canonical --> Observer["Other player"]
Observer --> Visible{"Origin and every step visible<br/>before and after?"}
Visible -->|Yes| Full["Send the complete chain"]
Visible -->|No| Empty["Remove the whole unit chain<br/>and send no partial route"]
The order of the remaining executions is preserved. The projection does not sort, regroup, merge, or recreate routes after filtering.
The Client No Longer Guesses
The client now has two authoritative movement entry points.
For the player who sent the command, the movement plan arrives in the ACK. It replaces any local movement animation rather than being appended to it. Non-movement UI effects remain, but the server-provided route is the source of truth for the move.
For other players, the plan arrives through the live event. The client only presents it when the event is exactly the next offset and its attached snapshot has the same offset. If there is a gap, a stale event, a missing snapshot, or a mismatched snapshot, the state may still be recovered, but the movement animation is suppressed.
The movement chain is also checked against the previous and next units. Its first origin must match the previous snapshot, its final destination must match the next snapshot, ownership must stay consistent, and multiple segments for the same unit must connect correctly. Invalid evidence is removed for the complete unit chain.
This means recovery never invents an animation from a snapshot difference. After reconnect, the latest snapshot is installed first, and only newer events are requested. A route that is already represented by the recovered state is not replayed.
UnitMovedEvent can still exist for activity and notification purposes, but it is no longer a second source of movement animation when authoritative executions are present. The exact route and costs come from movementExecutions.
Chained Movement Keeps Its Global Order
Turn finalization can move several units, and one unit can appear more than once in the same sequence.
For example, the authoritative order may be:
A1 → B1 → A2
The renderer must not group that sequence into A1 → A2 → B1, even though grouping by unit could look simpler internally. The supplied order is part of the result.
The renderer now processes effects through one transition queue. When the first movement of unit A ends, its marker can remain at the intermediate destination while unit B moves. When the second movement of A starts, it continues from that retained position. Only the final segment for that unit releases the temporary animation state and synchronizes the marker with the final snapshot.
The same ownership and final synchronization rules apply when reduced-motion mode is enabled. The animation may finish immediately, but state cannot jump to a different position between chained segments.
This also prevents snapshot synchronization, camera preparation, and movement playback from fighting with each other. The renderer waits for the ordered batch instead of applying every visual transition independently.
What Is Still Missing
The route and relative order are now authoritative, but the exact timing is not.
Protocol v3 does not contain a shared animation start tick or a synchronized clock. Camera movement, reduced-motion settings, device load, and recipient filtering can still cause two players to begin or finish the same visible animation at slightly different moments.
The later presentation milestone still needs a versioned AnimationPlan, stable event identity, a sequence number, an authoritative start tick, and a controlled multi-client clock. It should also test duplicate and reordered delivery, latency, reconnect, replay, late join, and exactly-once playback.
Exact movement history is another separate boundary. listEvents can expose safely projected movement evidence, but NetworkEventLog remains an activity and command-history reader rather than the renderer’s durable animation replay source. A complete replayable animation plan still needs its own contract.
The server itself is also not fully canonical yet. Its running snapshot boundary can expose canonical state, and many command families use shared kernels, but the reducer still has legacy save and persistent-state consumers. Direct combat is one of the larger remaining command paths.
The broader goals remain unchanged: one canonical server input and output, one GameEngine, a clearer split between UI intents and domain commands, and persistence or wire conversions limited to infrastructure boundaries.
Where the Refactor Stands Now
The previous version of this post ended with a clear transport problem. The movement rules were shared, the engine knew the exact route, and the canonical turn result already carried it, but another multiplayer client could still lose that information.
That is no longer the current state.
The route now moves from the shared rule, through server storage and recipient-safe projection, into either an ACK or a live event, through client validation, and finally into one ordered renderer queue.
The system does not rebuild the route, infer it from a snapshot, or send a hidden part of it.
The remaining question is no longer:
Which path did the unit take?
The engine and every authorized client now have the same answer.
The next question is:
How do all clients present that answer at the same logical time?
That is a better boundary for the next stage of the refactor.
Leave a Reply