[AoNW] One Game, Two Views: Extracting the Rust Engine and Building AoNW2 in Godot

Written by

in

, , ,

The existing Flutter and Flame client received many new gameplay systems, multiplayer improvements, performance fixes, and better release tooling.

But the biggest recent change is architectural.

I started separating the game itself from the technology used to display it.

The long-term structure will have:

  • AoNW1, the existing 2D client built with Flutter and Flame,
  • AoNW2, a new 3D client built with Godot,
  • one shared game engine written in Rust,
  • one Serverpod multiplayer backend,
  • one set of rules, maps, commands, events, saves, and multiplayer contracts.

This is not a plan to abandon AoNW1.

It is a plan to make the game independent from both Flutter and Godot.

The most important product decision is also simple:

AoNW1 and AoNW2 will use the same multiplayer system. Players using the 2D and 3D clients will be able to join the same matches.

From the gameplay point of view, both clients should be the same game.

The difference should be presentation:

  • AoNW1 shows the world as a 2D hex map,
  • AoNW2 shows the same world as a 3D scene.

They will not have separate combat rules, separate balance, separate AI, or separate multiplayer servers.

What Changed Since the Previous Post

The previous post described the Serverpod multiplayer architecture.

At that point, most of the project still looked like one Flutter application with a shared Dart package and a backend.

The project has since moved through several stages:

flowchart LR
    June["June: Serverpod migration - Public testing"]
    July["July: Open-source release - Canonical engine - Multiplayer hardening"]
    August["Augus: Roads and resources - Rust engine foundation - Godot AoNW2"]

    June --> July
    July --> August

AoNW1 continued to grow as a normal game while the new architecture was being prepared.

Recent releases added or improved:

  • world wonders,
  • replay and persistent match history,
  • better multiplayer reconnect and synchronization,
  • abandoned match and stale lobby cleanup,
  • cross-platform Steam, Google, Apple, and email authentication,
  • connected city founding,
  • automatic worker planning,
  • desktop fullscreen support,
  • road construction and road-aware movement,
  • clearer combat targeting and animations,
  • strategic resource deposits,
  • per-player resource stockpiles,
  • resource production costs and refunds,
  • resource trading through diplomacy,
  • resource-aware AI planning,
  • movement and rendering performance,
  • release, architecture, mutation, coverage, and end-to-end test gates.

Two of the newest systems are especially important for the future architecture.

Roads Are Now Real Infrastructure

Roads are no longer only visual decorations.

They form a persistent transport network and change the real movement cost of a route. Units and merchants can prefer operational roads when they provide a genuinely cheaper path.

Road state is preserved in:

  • local games,
  • multiplayer matches,
  • saves,
  • replays,
  • AI simulations,
  • recipient-safe multiplayer projections.

This made roads a good example of a feature that must not be implemented separately in Flutter and Godot.

A road is a game rule.

The 2D line or the 3D model is only its presentation.

Strategic Resources Became a Flow

Strategic resources previously behaved mostly as binary requirements:

I control oil
    -> I can build a tank

The new model is closer to an economy:

A deposit produces oil
    -> oil enters a stockpile
    -> production reserves and consumes oil
    -> cancelled production can refund it
    -> trade can move resources between players

The system includes resource placement, extraction, stockpiles, production requirements, refunds, diplomacy, AI evaluation, and multiplayer projection.

Again, this is not a Flutter feature.

It is a domain feature that both clients must understand through the same engine.

The Main Architectural Decision

The central idea is to separate simulation from presentation.

Flutter should not decide whether a unit can move.

Godot should not calculate combat damage.

A Serverpod endpoint should not contain a second version of the economy.

A renderer should receive an authoritative result and display it.

The target architecture looks like this:

flowchart TB
    subgraph Clients["Presentation Clients"]
        Flutter["AoNW1 - Flutter + Flame - 2D presentation"]
        Godot["AoNW2 - Godot - 3D presentation"]
    end

    subgraph Runtime["Shared Runtime Boundaries"]
        Local["Rust Local Runtime"]
        Remote["Recipient-safe Remote Replica"]
    end

    subgraph Core["Shared Rust Engine"]
        Contracts["Commands, Events and Queries"]
        Domain["Canonical Game State"]
        Engine["Deterministic Game Engine"]
        Content["Maps, Rulesets and Scenarios"]
        Projection["Player-specific Projection"]
    end

    subgraph Online["Online Infrastructure"]
        Serverpod["Serverpod: Auth, Lobby and Match Host"]
        Database[("PostgreSQL")]
    end

    Flutter --> Local
    Godot --> Local

    Flutter --> Remote
    Godot --> Remote

    Local --> Contracts
    Remote --> Serverpod

    Serverpod --> Contracts
    Contracts --> Engine
    Engine --> Domain
    Engine --> Content
    Serverpod --> Projection
    Serverpod --> Database

There are three different responsibilities here.

Rust Owns Game Behavior

The Rust engine will own:

  • canonical game state,
  • movement,
  • combat,
  • cities,
  • production,
  • research,
  • diplomacy,
  • roads,
  • resources,
  • fog of war,
  • AI,
  • commands and events,
  • save and replay rules,
  • deterministic state transitions.

Serverpod Owns Multiplayer Coordination

Serverpod remains the multiplayer application host.

It owns:

  • authentication,
  • accounts,
  • matchmaking,
  • lobbies,
  • match membership,
  • command ordering,
  • database transactions,
  • event offsets,
  • persistence,
  • reconnects,
  • timeouts,
  • post-commit delivery.

I am not creating another multiplayer server for AoNW2.

Serverpod will call the shared engine and send recipient-safe results to both clients.

Clients Own Presentation

AoNW1 owns its Flutter widgets, Flame renderer, 2D sprites, camera, input, panels, animations, and platform integrations.

AoNW2 owns its Godot scenes, 3D meshes, materials, lighting, camera, UI, animations, and input.

Neither client should own an alternative version of the game rules.

One Game, Two Clients

The intended split is:

Shared by AoNW1 and AoNW2Specific to AoNW1Specific to AoNW2
Game rulesFlutter UIGodot UI
Maps and scenariosFlame rendererGodot renderer
Commands and events2D sprites3D models
Turn order2D hex overlays3D terrain
Combat calculations2D camera3D camera
Economy and AIMobile-oriented interactions3D interactions
Fog-of-war rulesFlutter platform adaptersGodot platform adapters
Multiplayer protocol2D effects3D effects
Save and replay contracts2D animation playback3D animation playback

This also defines how future features should be built.

A new feature will not be implemented as two separate game systems.

It should follow this path:

flowchart TD
    Feature["New Gameplay Feature"]

    Feature --> Rules["Implement rules once - in the shared Rust engine"]
    Rules --> Contract["Expose versioned - commands, events and read models"]
    Contract --> Server["Use the same contract - in Serverpod multiplayer"]
    Contract --> Local["Use the same contract - in local sessions"]

    Server --> FlutterUI["AoNW1: 2D presentation"]
    Server --> GodotUI["AoNW2: 3D presentation"]
    Local --> FlutterUI
    Local --> GodotUI

For example, when adding railways:

  1. Rust defines construction requirements, movement costs, ownership, damage, repair, and persistence.
  2. Serverpod accepts the same railway commands and stores the same events.
  3. AoNW1 draws a 2D railway overlay.
  4. AoNW2 creates a 3D railway mesh or scene.
  5. Both clients receive the same logical result.

The feature is developed for both clients, but the rule is written only once.

Only the presentation work is developed in two parallel paths.

This prevents an expensive and dangerous situation where a railway is faster in AoNW1 than in AoNW2 because two different implementations slowly moved apart.

Compatible Multiplayer Between 2D and 3D

Multiplayer compatibility is one of the main reasons for extracting the engine.

The target is that an AoNW1 player and an AoNW2 player can enter the same lobby and play the same match.

One player can see a 2D map.

Another player can see a 3D world.

The server sees neither.

It sees commands, state revisions, players, events, offsets, and recipient visibility.

sequenceDiagram
    participant F as AoNW1 Flutter Client
    participant G as AoNW2 Godot Client
    participant S as Serverpod
    participant E as Shared Rust Engine
    participant DB as PostgreSQL

    F->>S: Versioned game command
    S->>E: Validate and apply command
    E-->>S: New state, events and evidence
    S->>DB: Persist transaction and offset
    S-->>F: Recipient-safe events and state patch
    S-->>G: Recipient-safe events and state patch

    G->>S: Versioned game command
    S->>E: Validate and apply command
    E-->>S: New state, events and evidence
    S->>DB: Persist transaction and offset
    S-->>F: Recipient-safe events and state patch
    S-->>G: Recipient-safe events and state patch

The command does not contain a sprite position or a 3D transform.

It contains a logical action, such as:

Move unit A to hex B
Build a road on hex C
Select technology D
Start production E
Offer resource trade F
End turn

The engine produces a logical outcome.

AoNW1 translates that outcome into a 2D animation.

AoNW2 translates the same outcome into a 3D animation.

This means multiplayer does not need to synchronize frames, meshes, particles, or camera movement.

It synchronizes game facts.

Fog of War Still Belongs to the Server

Cross-client multiplayer must not weaken hidden information.

The canonical match state cannot be sent to a client and filtered inside Flutter or Godot. A modified client could inspect the full payload.

Instead, Serverpod will use the shared projection policy to prepare a separate view for each player.

A client can receive:

  • visible units,
  • visible cities,
  • known terrain,
  • allowed events,
  • redacted movement evidence,
  • its own resource and diplomacy information,
  • a safe state patch for its current revision.

It must not receive hidden canonical state.

That rule is the same for both clients.

A beautiful 3D renderer does not get more information than the existing 2D renderer.

The Rust Engine Workspace

The new engine lives in a Cargo workspace under engine/.

The current structure is already divided into focused crates:

engine/
└── crates/
    ├── aonw_domain
    ├── aonw_content
    ├── aonw_contracts
    ├── aonw_contract_mapping
    ├── aonw_engine
    ├── aonw_local_runtime
    ├── aonw_godot
    ├── aonw_flutter
    └── aonw_testkit

The responsibilities are intentionally narrow.

aonw_domain

This crate contains canonical domain types.

It includes game state, units, identifiers, map coordinates, state revisions, fixed-point values, and domain invariants.

It does not know about Flutter, Godot, Serverpod, files, databases, or HTTP.

aonw_content

This crate owns strict logical maps, rulesets, scenarios, validation, and deterministic content hashes.

A map used by the engine is not a screenshot or a Godot scene.

It is versioned logical data.

aonw_contracts

This crate defines strict boundary documents for:

  • client requests,
  • client responses,
  • canonical state,
  • saves,
  • replays,
  • queries,
  • commands,
  • events,
  • execution evidence.

The current contracts reject unknown, incomplete, or incompatible data instead of trying to guess.

aonw_contract_mapping

This crate converts validated boundary DTOs into domain types and back.

The domain does not depend on transport models.

aonw_engine

This is the deterministic rule boundary.

It currently owns the implemented movement slice and several unit actions. It accepts a complete state and explicit context, then returns an accepted or rejected transition.

aonw_local_runtime

This crate owns a local game session around the engine.

It manages:

  • opening and closing a session,
  • revisions,
  • player snapshots,
  • route and reachable queries,
  • command dispatch,
  • recipient-safe patches,
  • save export and restore,
  • replay recording and verification.

aonw_godot

This is a thin GDExtension adapter.

It translates Godot requests into the framework-independent client protocol. It does not contain game rules.

aonw_flutter

This is the native boundary prepared for Flutter.

It exposes the same protocol through a panic-contained C ABI and Flutter Native Assets integration.

The bridge exists, but normal AoNW1 production sessions still use the Dart engine.

aonw_testkit

This crate runs shared fixtures and compares complete outcomes.

It helps answer the most important migration question:

Does Rust produce the same result as the current Dart engine?

This Is Not a Big-Bang Rewrite

The current Dart engine in packages/aonw_core is still the production authority.

The Rust implementation is a working foundation, but it is not yet the production backend for the whole game.

That is intentional.

Removing the Dart engine first and rebuilding everything later would be fast only until the first compatibility bug appeared.

Instead, I am using an incremental migration.

flowchart LR
    Dart["Dart aonw_core\nCurrent production reference"]
    Fixtures["Reviewed parity fixtures"]
    Rust["Rust GameEngine"]
    Godot["Godot AoNW2"]
    Flutter["Flutter AoNW1"]
    Server["Serverpod"]

    Dart --> Fixtures
    Fixtures --> Rust

    Dart --> Flutter
    Dart --> Server

    Rust --> Godot
    Rust -. "after local cutover gates" .-> Flutter
    Rust -. "shadow and canary stages" .-> Server

The migration rules are strict:

  1. The working Flutter game remains releasable.
  2. Rust first reproduces current behavior.
  3. Rule redesign is separated from language migration.
  4. A live session uses one primary engine for its whole lifetime.
  5. A match never sends movement to Dart and combat to Rust.
  6. New writers are enabled only when both migration and rollback readers understand their output.
  7. Existing matches are not switched to another engine in the middle of the game.
  8. Dart is removed only after all commands, AI, saves, replays, projections, platforms, and recovery paths have passed their gates.

Later Serverpod rollout can use complete backend modes such as:

dart_only
dart_primary_rust_shadow
rust_primary_dart_shadow
rust_primary_dart_standby
rust_only

In shadow mode, the second engine can calculate the same command for comparison, but only the primary result is persisted and shown to players.

That provides evidence without creating two authoritative truths.

Current Rust Progress

The Rust engine is no longer an empty directory or only an architecture document.

The current implementation includes:

  • strict versioned map loading,
  • immutable rulesets and scenarios,
  • deterministic map, ruleset, scenario, and state hashes,
  • canonical game state foundations,
  • complete unit entities for the current migration slice,
  • odd-q hex topology,
  • fixed-point terrain movement costs,
  • route and reachable-tile queries,
  • road-aware movement,
  • occupancy checks,
  • fog-of-war updates,
  • diplomatic contact updates,
  • city and artifact state required by movement,
  • exact authoritative movement evidence,
  • revision-bound command execution,
  • unit cancel, skip, and fortify actions,
  • recipient-safe snapshots and patches,
  • canonical saves,
  • replay recording and verification,
  • stable state digests,
  • Godot and Flutter native adapters.

The current shared parity corpus contains 44 fixtures.

They cover accepted and rejected movement, roads, terrain, fog, cities, occupied targets, hidden blockers, queued movement, unit actions, event ordering, rejection precedence, and exact execution evidence.

Both Dart and Rust run against the same reviewed expected outcomes.

Neither engine is allowed to silently update its own test oracle.

Performance Without Premature Complexity

The new runtime prepares map topology and movement costs once.

It uses:

  • row-major map indexes,
  • compact occupancy and visibility structures,
  • reused search buffers,
  • revision-scoped query caches,
  • deterministic multi-target path search,
  • owned-state transitions that avoid cloning the full state on the main path.

I deliberately did not start with ECS, GPU pathfinding, custom allocators, SIMD, or unsafe optimizations.

The measured workload does not currently justify them.

On my development Mac, the diagnostic 40×30 map benchmark with 512 units keeps an accepted runtime dispatch around 1.5 ms p95. That includes the state digest, replay entry, recipient patch, and JSON response.

This is not a universal performance promise.

It is evidence that the current architecture can stay simple for now.

AoNW2 in Godot

The new Godot project lives under:

clients/aonw2_godot/

AoNW2 is currently an early technical client, not a complete replacement for AoNW1.

It already has two important parts:

  1. a map authoring and generation workflow,
  2. a runtime preview connected to the Rust local runtime.

The AoNW Map Workbench

The Godot editor contains an AoNW Map Workbench.

It discovers strict shared maps, validates them through the shared content boundary, and generates a 3D Godot scene.

The generated map contains separate layers:

  • elevated base terrain,
  • an optional texture created from the existing AoNW map artwork,
  • an independent hex grid,
  • hover overlays,
  • selection overlays,
  • reachable-tile overlays.

The source flow looks like this:

flowchart LR
    Map["Versioned logical map - content/maps"]
    Rust["Rust validation - and content hash"]
    Workbench["Godot Map Workbench"]
    Generated["Generated terrain, textures and grid"]
    Scene["Stable authored - Godot scene"]
    Models["Hand-authored models, props and effects"]

    Map --> Rust
    Rust --> Workbench
    Workbench --> Generated
    Generated --> Scene
    Models --> Scene

The generated terrain is replaceable.

This is important because I want to regenerate the map after changing logical terrain or source artwork without deleting models, cities, props, or other nodes added manually in Godot.

The Workbench writes immutable generations and publishes a new generation only after all resources are saved successfully.

A failed save should not leave the scene connected to half-written meshes.

The current implementation uses built-in Godot meshes and textures. It does not require Terrain3D or Tree3D.

Godot Already Calls the Rust Runtime

The Godot runtime preview can open a scenario through the Rust engine.

It can:

  • receive a recipient-safe player snapshot,
  • display units from that snapshot,
  • select a unit,
  • ask Rust for reachable hexes,
  • ask Rust for a route,
  • send a movement command,
  • receive authoritative movement evidence,
  • animate the accepted path,
  • save and restore the local session,
  • export and verify replay data.

GDScript does not calculate movement legality.

The reachable overlay is only a view of a Rust query result.

Selection remains presentation-only.

The Godot client never creates a synthetic canonical unit or reconstructs hidden game state.

A Shared Client Protocol

The Godot GDExtension currently exposes one strict request operation around the shared client protocol.

Conceptually, both native clients will follow the same path:

Client request
    -> version check
    -> local runtime or Serverpod
    -> shared engine
    -> recipient-safe response
    -> client read models
    -> renderer

Responses include identity information such as:

  • behavior version,
  • state revision,
  • state digest,
  • map hash,
  • ruleset hash.

The client checks protocol compatibility before reading the payload.

Shared golden protocol documents are tested by Rust, Dart, and Godot.

This gives AoNW1 and AoNW2 a common language that does not depend on Flutter widgets or Godot nodes.

What Is Still Missing

There is still a lot of work between the current preview and a complete AoNW2 release.

The main missing parts include:

  • the remaining command families in Rust,
  • complete turn advancement,
  • queued multi-turn movement,
  • combat,
  • production and city management,
  • research,
  • diplomacy,
  • strategic resources,
  • AI,
  • full recipient projection,
  • the Godot remote multiplayer replica,
  • final Serverpod-to-Rust authority integration,
  • production Flutter local-session cutover,
  • Rust packaging for every supported platform,
  • historical save and replay migration,
  • full 3D units, cities, improvements, effects, UI, and audio.

The current cross-client multiplayer architecture is therefore a committed target, not a finished public feature.

Today, the Godot client is mainly using the local Rust runtime.

The important part is that its boundaries are already being designed for the same recipient-safe protocol that will be used by Flutter and multiplayer.

What Comes Next

The next engine work will continue with complete vertical slices.

The order matters.

I do not want to create empty crates or interfaces for systems that do not exist yet. A new module should be added together with real behavior and tests.

The expected sequence is:

  1. complete turn-driven and queued movement,
  2. port more unit actions,
  3. port combat,
  4. port cities, production, research, diplomacy, and resources,
  5. port AI and recipient projection,
  6. connect the complete Rust engine to Flutter behind the existing local-engine boundary,
  7. run Dart and Rust in shadow mode,
  8. move new local sessions to Rust,
  9. move new multiplayer matches through Serverpod canary stages,
  10. complete the AoNW2 multiplayer replica and 3D presentation,
  11. retire the Dart rules only after rollback and compatibility gates pass.

AoNW1 development will continue during this work.

Bug fixes, balance changes, multiplayer improvements, and new gameplay features will not wait for AoNW2 to become complete.

For behavior already migrated to Rust, fixes will be checked against both implementations until the rollback lane can be safely removed.

The Direction

The biggest change is not that I started using Rust or Godot.

The biggest change is that Age of New Worlds is becoming independent from its renderer.

AoNW1 and AoNW2 are not intended to become two games that slowly drift apart.

They are two ways to interact with the same game.

They will share:

  • the same mechanics,
  • the same balance,
  • the same maps,
  • the same civilizations,
  • the same AI,
  • the same multiplayer backend,
  • the same lobbies and matches,
  • the same authoritative state,
  • the same future gameplay features.

Those features will be developed through one shared engine and exposed through two presentation paths.

AoNW1 will continue to present the world in 2D with Flutter and Flame.

AoNW2 will present the same world in 3D with Godot.

A road will still be the same road.

A unit will still have the same movement points.

A battle will still have the same result.

A multiplayer turn will still belong to the same match.

Only the way the player sees and controls that world will change.

That is the architecture I want for the next stage of Age of New Worlds:

One game. One engine. One multiplayer world. Two different views.

Comments

Leave a Reply

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