[AoNW] The Domain and Application Layers: Where the Game Actually Lives

A 4X has many different kinds of decisions. Some are game rules. Some are player workflow. Some are persistence. Some are rendering. Some are network concerns. If I let all of them live in the same place, every new feature becomes harder than it should be.

So the project uses a DDD-inspired split between the domain layer and the application layer.

The domain layer answers: what is true in the game?

The application layer answers: how do I perform a use case around that truth?

The Simplified Shape

The client game code is roughly organized like this:

lib/game/
  domain/
    game_state.dart
    game_save.dart
    reducer/
      game_state_reducer.dart
      movement_reducer.dart
      turn_reducer.dart
      combat_reducer.dart
      city_production_reducer.dart
      research_reducer.dart
    movement/
    hex_assessment/
    city.dart

  application/
    ports/
      command_transport.dart
      game_repository.dart
      event_log.dart
      snapshot_store.dart
      clock.dart
      id_generator.dart
    use_cases/
      dispatch_command_use_case.dart
      bootstrap_game_state_use_case.dart
      end_turn_use_case.dart
    services/
      end_turn_strategy.dart
      player_control_coordinator.dart
      game_session.dart

  infrastructure/
    persistence/
    transport/
    system/

  presentation/
    screens/
    widgets/
    providers/
    engine/

This is not “clean architecture cosplay”. The split exists because the game has multiple runtimes:

  • local hotseat play
  • future/server-backed multiplayer
  • AI turns
  • save/load
  • map rendering
  • Flutter HUD workflows
  • tests and balance tools

All of those need to talk about the same game, but they should not all own the same responsibilities.

The Layer Diagram

flowchart TD
  Presentation["Presentation: Flutter, Riverpod, Flame"]
  Application["Application: use cases, services, ports"]
  Domain["Domain: state, reducers, rules"]
  Infrastructure["Infrastructure: JSON, HTTP, WebSocket, system adapters"]
  Core["aonw_core: shared commands, events, rules"]

  Presentation --> Application
  Application --> Domain
  Application --> Core
  Domain --> Core
  Infrastructure --> Application

The arrows matter.

The domain should not know about Flutter widgets, Riverpod providers, HTTP clients, PostgreSQL, WebSockets, local files, or Flame components.

The application layer may coordinate work, but it should depend on ports rather than concrete storage or network code.

Infrastructure implements those ports.

Presentation starts use cases and renders results.

What Belongs in the Domain Layer

The domain layer contains the rules and state transitions of the game.

This is where I put things like:

  • movement rules
  • selection rules
  • combat resolution
  • city founding
  • production
  • research
  • worker actions
  • turn progression
  • fog-aware interaction rules
  • score and objective-related state
  • game state transitions

The central idea is that a command enters the domain and a new state comes out.

class GameStateReducer {
  GameStateTransition reduce(
    GameState state,
    GameCommand command, {
    GameCommandContext context = const GameCommandContext(),
  }) {
    return switch (command) {
      MoveUnitCommand() => MovementReducer.moveUnit(
        state,
        command,
        mapData,
        context: context,
      ),
      FoundCityCommand() => CityFoundingReducer.confirmCityFounding(
        state,
        mapData,
        command: command,
        context: context,
        cityRuleset: ruleset.city,
      ),
      SelectTechnologyCommand() => ResearchReducer.selectTechnology(
        state,
        command,
        context: context,
        mapData: mapData,
        ruleset: ruleset.technology,
      ),
      _ => GameStateTransition(state: state),
    };
  }
}

The reducer does not save files. It does not call HTTP. It does not render animation. It does not navigate screens.

It only answers: given this state, this command, this map, and this ruleset, what should the game state become?

Domain Transitions

Reducers return a transition:

class GameStateTransition {
  final GameState state;
  final List<GameEvent> events;
  final List<UiEffect> uiEffects;

  const GameStateTransition({
    required this.state,
    this.events = const [],
    this.uiEffects = const [],
  });
}

This is a useful compromise.

The domain can say: “this command moved a unit, produced these events, and should cause an abstract visual effect.” But it does not play the animation itself.

A reducer may return an AnimateUnitMoveEffect, but the renderer decides how to animate it. A reducer may return a GameEvent, but the event log decides how to persist it. That keeps the domain expressive without making it depend on UI or storage.

What Belongs in the Application Layer

The application layer is where use cases live.

It does not decide the rules of movement or combat. Instead, it coordinates things around the domain:

  • loading a save
  • bootstrapping game state
  • dispatching a command
  • choosing the correct end-turn strategy
  • asking a repository to reload state
  • deciding whether hotseat or multiplayer flow applies
  • converting snapshots into active game state
  • calling ports such as repositories, transports, logs, and clocks

For example, bootstrapping a game is not a pure domain rule. It is an application workflow:

class BootstrapGameStateUseCase {
  final GameRepository repository;
  final DispatchCommandUseCase dispatchCommand;

  Future<BootstrapGameStateResult> executeWithResult({
    required String saveId,
    String? preferredPlayerId,
  }) async {
    final snapshot = await repository.load(saveId);
    final control = PlayerControlCoordinator.initialForPlayer(
      save: snapshot.save,
      preferredPlayerId: preferredPlayerId,
    );

    final initialState = snapshot.toGameState(
      activePlayerId: control.activePlayerId,
      activePlayerCanAct: control.canAct,
    );

    final result = await dispatchCommand.execute(
      saveId: saveId,
      currentState: initialState,
      command: SetActivePlayerCommand(
        control.activePlayerId,
        canAct: control.canAct,
      ),
    );

    return BootstrapGameStateResult(
      state: result.state,
      offset: result.offset,
    );
  }
}

This code is not a game rule. It is orchestration.

It asks the repository for a snapshot, chooses the active player, converts durable state into runtime state, and dispatches an initial command.

That belongs in application code.

Ports Define the Boundary

The application layer talks through ports.

abstract interface class GameRepository {
  Future<String> create(NewGameRequest request);
  Future<List<GameSaveIndex>> list();
  Future<SaveSnapshot> load(String saveId);
  Future<void> save(SaveSnapshot snapshot);
  Future<void> delete(String saveId);
}
abstract interface class CommandTransport {
  Future<CommandTransportResult> dispatch({
    required String saveId,
    required GameState currentState,
    required GameCommand command,
    GameCommandContext context = const GameCommandContext(),
  });
}

These ports are important because local play and network play are different infrastructure choices, not different game architectures.

A local repository can read JSON. A network repository can call HTTP. A local command transport can reduce commands in-process. A network command transport can send commands to the server.

The application layer does not need to care which one is active.

End Turn as a Boundary Example

Ending a turn sounds like a domain rule, but the full end-turn workflow is not only domain logic.

In hotseat, ending a turn may advance to another local human player and trigger a handoff screen. In multiplayer, ending a turn submits the player’s turn and waits for the server.

That difference belongs in the application layer:

abstract interface class EndTurnStrategy {
  Future<EndTurnResult> endTurn({
    required GameSave save,
    required PlayerControlState control,
    required DispatchGameCommand dispatch,
    required ReloadGameSave reloadSave,
  });
}

The strategy can differ by game mode:

class EndTurnStrategies {
  static EndTurnStrategy forMode(GameMode gameMode) => switch (gameMode) {
    GameMode.hotSeat => const HotSeatEndTurnStrategy(),
    GameMode.multiplayer => const MultiplayerEndTurnStrategy(),
  };
}

Hotseat dispatches an EndTurnCommand. Multiplayer dispatches a SubmitTurnCommand.

That is an application decision. The actual effect of those commands still belongs to the domain or server-side command handling.

The Boundary Rule

The rule I try to follow is:

Domain code should be able to run without the app.

That means domain code should not require:

  • Flutter
  • Riverpod
  • Flame
  • local file paths
  • HTTP clients
  • WebSocket connections
  • screen navigation
  • platform APIs

Application code can coordinate those concerns through interfaces, but the domain should stay portable.

That portability is what lets the same command and rules language support local play, server play, AI, tests, and tooling.

The Shared Domain

There is one extra wrinkle: Age of New Worlds has a shared package, aonw_core.

That package contains domain concepts that must be shared by both the Flutter client and the Dart server:

packages/aonw_core/lib/
  game/domain/
    command/
    event/
    fog/
    runtime/
    state/
    unit.dart
    city.dart
    ruleset.dart
  map/domain/
  ai/
  protocol/

This shared kernel keeps the client and server speaking the same language.

Commands and events are especially important. If the client sends MoveUnitCommand, the server must parse and understand the same command shape. If the server emits UnitMoved, the client must decode and display the same event.

What Does Not Belong in the Domain

Some things are tempting to put in the domain, but I try to keep them out.

Rendering does not belong there. The domain may say that a unit moved, but it should not know how the unit slides across the map.

Persistence does not belong there. The domain may define serializable state, but it should not know whether the state is saved to JSON, PostgreSQL, or returned over HTTP.

Screen workflow does not belong there. The domain may say the active player changed, but it should not know whether Flutter shows a handoff overlay.

Network authority does not belong there. The domain can define legal commands, but the server decides who is allowed to submit them and whether a tick is stale.

The Practical Benefit

The benefit is not theoretical cleanliness. The benefit is that features have somewhere to go.

When I add a new combat rule, it belongs in the domain.

When I add a new way to save or load a match, it belongs behind an application port and infrastructure adapter.

When I add a new HUD panel, it belongs in presentation.

When I add a different end-turn flow for multiplayer, it belongs in an application strategy.

When I add a server projection, it belongs in the server boundary, not in the renderer.

This makes the project easier to extend because I do not have to ask the whole codebase for permission every time a feature grows.

The Trade-Off

This architecture adds files.

There are reducers, use cases, services, ports, adapters, snapshots, commands, events, providers, and render layers. For a tiny prototype, that would be too much.

But Age of New Worlds is a 4X game. The systems overlap constantly.

A single turn can involve movement, production, research, fog of war, combat, objectives, AI, saves, events, UI effects, and multiplayer state. Without boundaries, that becomes one giant knot.

The domain/application split is how I keep the knot from tightening.

The Rule I Keep Coming Back To

The domain should describe the game.

The application layer should describe how the app uses the game.

That distinction sounds small, but it has shaped almost every important part of Age of New Worlds. It lets the renderer stay visual, the server stay authoritative, the save system stay replaceable, and the rules stay testable.

For a strategy game, that is the difference between code that merely works today and code that can survive the next system I add tomorrow.

Comments

Leave a Reply

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