[AoNW] The Refactor So Far: From a Rust Movement Slice to Integrated Turns

Written by

in

, , ,

A few days ago, the Rust engine was mainly a successor path with movement, local sessions, contracts, and a growing set of isolated gameplay slices.

Now it is becoming something much bigger: https://engine.aonw.net

The latest changes add turn processing for production, research, diplomacy, economy, objectives, and match outcome resolution. Combat, cities, workers, roads, logistics, artifacts, save/replay contracts, and recipient-safe views are also part of the Rust workspace.

This changes the shape of the migration. The Rust engine is no longer only proving that one command can cross a native boundary. It is starting to prove that a complete turn can live behind the same deterministic boundary.

But there is still one important limit:

Rust is still the successor engine, not the production authority.

The production reference remains packages/aonw_core in Dart.

The migration plan keeps existing games on one primary engine for their full lifetime. Before production can move to Rust, I still need parity testing, platform qualification, shadow testing, canary rollout, observability, and a safe rollback path.

So this is a big milestone, but it is not the final cutover.

The engine is becoming the whole game

The biggest change is that the Rust code is starting to behave like a complete game engine instead of a collection of translated features.

The migration started with strict contracts, canonical state, movement, local sessions, and native Flutter/Godot boundaries.

Then more gameplay systems moved behind the same boundary:

  • movement and logistics,
  • combat,
  • cities,
  • workers and roads,
  • production,
  • artifacts,
  • research,
  • diplomacy,
  • economy,
  • objectives,
  • match outcome resolution.

The newest work also adds an integrated-turn verification target.

This means I can test the turn kernel, runtime, mappings, contracts, and match outcome together.

That is much more interesting than testing every migrated feature separately.

How the engine looks now

The architecture is based on one simple rule:

game rules belong in Rust.

Flutter and Godot should not calculate game rules themselves. They send commands and receive state, events, and recipient-safe views.

A simplified version looks like this:

flowchart LR
    Flutter["Flutter / Flame"] --> Protocol["Versioned client protocol"]
    Godot["Godot"] --> Protocol

    Protocol --> Runtime["aonw_local_runtime"]
    Runtime --> Engine["aonw_engine"]

    Engine --> Domain["aonw_domain<br/>canonical GameState"]
    Engine --> Content["aonw_content<br/>rules + validated content"]

    Engine --> Evidence["events + evidence + digest"]
    Evidence --> Runtime

    Runtime --> Views["recipient snapshot / patch"]
    Views --> Flutter
    Views --> Godot

    Serverpod["Serverpod<br/>future Rust authority"] -.-> Engine
    Dart["packages/aonw_core<br/>production reference today"] -. parity / shadow .-> Engine

The canonical GameState contains the data that affects the rules of the game.

This includes things like:

  • players,
  • map,
  • units,
  • cities,
  • economy,
  • diplomacy,
  • research,
  • fog of war,
  • match lifecycle,
  • RNG state.

Things like camera position, animations, or network connections do not belong there.

I want the engine state to contain only information required to reproduce the game. That becomes especially important for multiplayer and replay.

One command, one transaction

Another important part of the architecture is how the runtime handles commands. The runtime does not immediately modify the committed game state.

Instead, the operation works on temporary state first. Only after the complete operation succeeds is the new state committed.

flowchart LR
    Request["client command"] --> Decode["decode + validate"]
    Decode --> Revision["check revision"]
    Revision --> Execute["deterministic transition"]
    Execute --> Validate["validate result"]

    Validate -->|accepted| Commit["commit state<br/>revision +1"]
    Commit --> Replay["record replay + evidence"]
    Replay --> Project["recipient snapshot / patch"]

    Decode -->|error| Reject["reject"]
    Revision -->|stale| Reject
    Execute -->|rejected| Reject
    Validate -->|invalid| Reject

    Reject --> Same["committed state unchanged"]

If decoding fails, nothing changes. If the revision is stale, nothing changes. If validation fails, nothing changes. If the engine rejects the command, nothing changes.

Only a completely valid operation can replace the committed state.

This sounds like a small implementation detail. For a multiplayer strategy game, it is not.

There is a big difference between:

the command failed

and:

the command failed after changing half of the game state.

I want the first one.

Player commands are not system commands

The Rust turn kernel also introduced an important separation. There are now two different types of mutations.

Player-controlled gameplay uses PlayerCommand. Trusted lifecycle operations use SystemCommand.

Conceptually, the boundary looks like this:

// Player-controlled gameplay.
engine.apply_owned(
    /* canonical state, player command, context */
);

// Trusted host lifecycle.
// This is not exposed through the player protocol.
engine.apply_system_owned(
    /* canonical state, system command, context */
);

This is important because some operations should never be available to a normal client. A player can move a unit or select research.

But a player should not be able to pretend to be the trusted match host and execute lifecycle operations. SystemCommand is therefore not part of the normal player client protocol.

I want this separation to stay when Serverpod eventually starts hosting the Rust engine.

A turn now changes much more state

The first Rust turn kernel was intentionally small. It could handle only a limited part of the game lifecycle.

If the game required a processor that had not been migrated yet, Rust rejected the operation instead of trying to guess or silently use another implementation.

I still think that was the correct approach. Failing clearly is much safer than having two engines secretly modify the same game. But the turn processor is much larger now.

As production, research, diplomacy, economy, and objectives moved into Rust, one turn started updating much more of the canonical state.

A simplified shape looks like this:

pub struct TurnKernelStateUpdate {
    pub revision: StateRevision,
    pub turn: u32,
    pub lifecycle: MatchLifecycle,
    pub units: Vec<Unit>,
    pub economy: EconomyState,
    pub fog_of_war: FogOfWar,
    pub diplomacy: Diplomacy,
    pub objectives: ObjectiveState,
    pub interaction: InteractionState,
}

I like this direction. There is one authoritative result of turn processing. Not one result for economy, another for diplomacy, another for visibility, and then some later code trying to make all of them agree. The whole turn is becoming one deterministic transaction.

Research is now calculated by the engine

Research is a good example of why this migration is not just about rewriting Dart code in Rust.

The Rust engine now calculates science income during the turn.

It also exposes a structured breakdown explaining where that science came from.

The API is roughly:

pub struct ScienceYieldBreakdownDto {
    pub total: i64,
    pub by_city_id: BTreeMap<String, i64>,
    pub sources: Vec<ScienceYieldSourceDto>,
}

And each source has an explicit type:

pub enum ScienceYieldSourceKindDto {
    CityScience,
    CityResearchProject,
    WorldArtifact,
    WorldWonder,
}

The engine can therefore tell the client something like:

Capital: +2 science
Great Library: +4 science

Flutter does not need to calculate these numbers. Godot does not need to calculate them either. They only display the result produced by the engine.

The engine also emits ResearchPointsGained as a client and replay event. This is exactly the boundary I want. The engine owns the rule. The client explains the result to the player.

Diplomacy produces authoritative events

Diplomacy is following the same direction.

The Rust engine now handles more of the diplomacy lifecycle, including things like proposals, messages, war, gifts, trades, proposal expiration, and broken promises.

The interesting part for me is that important changes become explicit events.

For example:

ClientEventDto::DiplomaticProposalExpired {
    /* ... */
}

ClientEventDto::DiplomaticPromiseBroken {
    /* ... */
}

This means a broken promise is not something the UI has to discover by comparing random pieces of state.

The engine decides that the promise was broken.

It records the fact.

The client presents it.

That also makes replay much easier to reason about.

Objectives work the same way

Objectives now follow a similar model.

The Rust engine can advance authored map objectives and domination progress during turn processing.

Important changes are represented as events:

ClientEventDto::MapObjectiveSecured {
    /* ... */
}

ClientEventDto::DominationThresholdReached {
    /* ... */
}

Again, the UI does not watch territory for several turns and independently decide that something important happened.

The engine decides.

The client displays it.

This sounds obvious, but it is one of the main goals of the refactor.

I do not want multiple clients implementing slightly different versions of the same game.

Match outcome closes another large gap

The latest engine work also adds match outcome resolution.

The engine now owns scoring values for things such as:

  • cities,
  • population,
  • territory,
  • buildings,
  • technologies,
  • improvements,
  • experience,
  • gold,
  • units.

More importantly, there is now an integrated check:

make rust-integrated-turn-check

This runs outcome invariants, outcome tests, turn-kernel tests, runtime tests, mappings, and contract tests together.

For me, this is a much bigger milestone than adding another isolated feature.

It means the successor engine can increasingly be tested as one game.

Determinism is still the main goal

I am not trying to make claims like:

Rust made the game 40% faster.

I do not have evidence for that yet.

Performance matters, but correctness matters more during this migration.

The engine has structural performance checks for things like:

  • work counters,
  • allocations,
  • allocated bytes,
  • payload sizes,
  • stable result signatures.

Wall-clock performance can still be measured, but it is not the only signal. Another important part is avoiding floating-point calculations for authoritative game values where possible. Economy and research use integer or fixed-point rules.

For example, research multipliers can use basis points instead of floating-point percentages. That helps keep results deterministic between different machines.

For multiplayer and replay, this is much more important than saving a few lines of code.

Recipient-safe state stays inside the engine

The engine also owns recipient projection.

There is a difference between:

canonical GameState

and:

what player A is allowed to see

A client should never receive the complete canonical state and then hide some of it locally.

That would be especially dangerous for:

  • fog of war,
  • hidden units,
  • private diplomacy,
  • multiplayer information.

Instead, the trusted engine creates recipient-specific snapshots and patches.

The client receives only the state it is allowed to know.

This also means that a remote client should not be able to reconstruct the canonical GameState from projected data.

That boundary becomes more important as more of the game moves into Rust.

CI is becoming stricter too

The migration is not only adding gameplay code.

The quality gates around the successor engine are also becoming stricter.

The repository now has checks for:

  • Rust tests,
  • coverage,
  • dependencies,
  • architecture,
  • determinism,
  • structural performance,
  • native boundaries,
  • integrated turn behaviour.

Some of the main commands are:

make rust-check
make successor-engine-check
make successor-engine-evidence-check
make rust-integrated-turn-check

There are also focused checks:

make rust-turn-kernel-check
make rust-movement-logistics-check
make rust-combat-check
make rust-city-check
make rust-worker-check

And native client checks:

make rust-flutter-test
make rust-godot-build
make godot-check

For deeper validation:

make rust-coverage-check
make rust-performance-check
make rust-architecture-check
make rust-dependency-check
make rust-determinism-check

The important thing is that these are becoming normal parts of the migration.

Not checks that I plan to add after the engine is finished.

Rust is still not production authority

This part is important enough to repeat.

The Rust engine is much more complete now.

But production has not moved to Rust yet.

packages/aonw_core in Dart is still the compatibility reference.

The migration follows a strangler approach.

The planned direction is roughly:

flowchart TD
    A["Dart only"] --> B["Dart primary<br/>Rust shadow"]
    B --> C["Rust primary<br/>Dart shadow"]
    C --> D["Rust primary<br/>Dart standby"]
    D --> E["Rust only"]

A running save or multiplayer match should not randomly switch between engines. One game should use one primary engine for its complete lifetime.

Shadow output is only comparison data. It must not become authoritative state.

Before changing the default engine for new games, I still want parity, packaging, observability, and rollback to be proven. And I definitely do not want to delete Dart too early.

Right now Dart still has an important job. It is the production reference and the rollback path.

What is next?

There is still a lot to do.

Serverpod is not yet using Rust as the production authority. Flutter local play has not been globally switched to Rust. Historical Rust save migrations are still something for later.

Shadow testing, canary rollout, observability, and rollback still need to prove that the new engine behaves like the old one where it matters.

The documentation also needs to keep up with the code because the engine is currently moving quite quickly. But the shape of the final architecture is becoming much easier to see.

The goal is:

command
    ↓
Rust engine
    ↓
canonical state
    ↓
events + replay evidence
    ↓
recipient-safe view
    ↓
Flutter / Godot

One deterministic implementation of the game rules.

Multiple clients.

And eventually the same engine locally and on the server.

Conclusion

The Rust migration has crossed an important boundary.

Earlier work proved that AoNW could have canonical state, deterministic commands, native clients, and shared engine contracts in Rust.

The recent work is proving something larger. Production, research, diplomacy, economy, objectives, and match outcome can now participate in the same deterministic turn model.

The runtime can commit that state atomically. The engine can produce replay evidence and recipient-safe views. And the integrated checks can test much more of this as one system.

There is still work left.

Rust is not the production authority yet. And that is fine. A safe engine migration should probably look slightly boring at the end.

The interesting part should be inside the engine. For the player, the same game should simply keep working.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *