[AoNW] Building AI That Uses the Same Commands as the Player

AI in a strategy game can easily become a second game hidden inside the first one. That is dangerous.

If the AI has its own movement rules, its own production shortcuts, its own visibility model, or its own private way of mutating state, then every new feature has to be implemented twice: once for the player and once for the AI. Worse, bugs become harder to reason about because the AI is no longer playing the same game.

In Age of New Worlds, I want the AI to use the same language as the player. That language is commands.

The Core Idea

The AI does not click the UI.

It does not press buttons, tap tiles, or drive Flutter widgets. It looks at a game view, builds a plan, and returns game commands.

abstract interface class AiStrategy {
  AiTurnPlan plan(GameView view, AiContext context);
}

A plan is just a list of commands:

class AiTurnPlan {
  final List<GameCommand> commands;
  final AiDebugInfo? debug;

  AiTurnPlan({
    Iterable<GameCommand> commands = const [],
    this.debug,
  }) : commands = List.unmodifiable(commands);
}

That means an AI can decide to move a unit, found a city, choose research, start production, assign a worker, or attack by returning the same command types a human action would eventually dispatch.

flowchart TD
  View["GameView"]
  Strategy["AiStrategy"]
  Plan["AiTurnPlan"]
  Commands["GameCommand[]"]
  Runner["AiTurnRunner"]
  Transport["CommandTransport"]
  Reducer["Domain / Server"]

  View --> Strategy
  Strategy --> Plan
  Plan --> Commands
  Commands --> Runner
  Runner --> Transport
  Transport --> Reducer

The AI is not a separate rules engine. It is another source of player intent.

GameView: What the AI Is Allowed to Know

The AI does not receive the raw full game state directly. It receives a GameView.

class GameView {
  final String forPlayerId;
  final int turn;
  final List<GameUnit> ownUnits;
  final List<GameCity> ownCities;
  final int ownGold;
  final PlayerResearchState ownResearch;
  final List<FieldImprovement> ownImprovements;
  final List<GameUnit> visibleEnemyUnits;
  final List<GameCity> rememberedEnemyCities;
  final FogVisibilityQuery visibility;
  final MapData mapData;
  final GameRuleset ruleset;
}

This is important because AI should be able to operate with partial information.

The view includes:

  • own units
  • own cities
  • own research
  • own improvements
  • visible enemy units
  • remembered enemy cities
  • fog visibility
  • map data
  • ruleset
  • strategic context

That gives the AI enough to make decisions without automatically granting it perfect knowledge.

The view is built from persistent state:

factory GameView.fromPersistentState(
  PersistentGameState state, {
  required String forPlayerId,
  required int turn,
  required MapData mapData,
  required GameRuleset ruleset,
  bool ignoreFogOfWar = false,
}) {
  final visibility = FogVisibilityQuery(
    playerId: ignoreFogOfWar ? '' : forPlayerId,
    state: state.fogOfWar,
  );

  return GameView(
    forPlayerId: forPlayerId,
    turn: turn,
    ownUnits: state.units.where((u) => u.ownerPlayerId == forPlayerId),
    ownCities: state.cities.where((c) => c.ownerPlayerId == forPlayerId),
    visibleEnemyUnits: [
      for (final unit in state.units)
        if (unit.ownerPlayerId != forPlayerId &&
            visibility.canSeeDynamicAt(unit.col, unit.row))
          unit,
    ],
    rememberedEnemyCities: [
      for (final city in state.cities)
        if (city.ownerPlayerId != forPlayerId &&
            visibility.canRememberStaticAt(
              city.center.col,
              city.center.row,
            ))
          city,
    ],
  );
}

That distinction mirrors the multiplayer projection model: the AI should reason from a view, not from omniscience, unless I explicitly choose otherwise for testing or balance.

The Runner Owns Execution

The strategy plans. The runner executes.

That is another boundary I care about.

A strategy should not submit the turn itself. It should not decide how commands are delayed, dispatched, logged, or rejected. It should simply produce a plan.

The AiTurnRunner takes that plan and dispatches each command through the normal command path:

for (final command in plan.commands) {
  final result = await _dispatch(
    saveId: saveId,
    currentState: state,
    command: command,
    context: _commandContext(
      playerId: playerId,
      aiContext: context,
    ),
  );

  if (result.state == state) {
    rejected.add(command);
    continue;
  }

  state = result.state;
  dispatched.add(command);
}

Then the runner owns the terminal command:

final terminal = _terminalFor(terminalCommand, playerId);
await _dispatch(
  saveId: saveId,
  currentState: state,
  command: terminal,
  context: _commandContext(
    playerId: playerId,
    aiContext: context,
  ),
);

This avoids a common AI problem: strategies trying to control the entire turn lifecycle. The AI can suggest actions, but the runner owns execution.

Rejections Are Normal

The AI can be wrong.

A unit may become stale. A tile may become occupied. A command may fail validation. A plan may include something that was legal when generated but is no longer legal when dispatched.

So the runner tracks dispatched, rejected, skipped terminal, and stale commands.

class AiTurnReport {
  final List<GameCommand> plannedCommands;
  final List<GameCommand> dispatchedCommands;
  final List<GameCommand> rejectedCommands;
  final List<GameCommand> skippedTerminalCommands;
  final List<GameCommand> skippedStaleCommands;
}

I like this because it treats AI failure as observable data, not mystery behavior.

If the AI keeps trying illegal moves, I want to see that in reports and logs. If a planner generates terminal commands when the runner owns turn submission, I want that to be visible too.

Strategy Is Layered

The basic AI strategy is not one magic function. It is a sequence of planners and scorers.

At a high level, it does things like:

  • assess the empire
  • build or reuse a strategic plan
  • plan city founding
  • plan founder escorts
  • react to combat threats
  • choose defensive stances
  • clear frontier threats
  • select research
  • choose production
  • assign workers
  • apply military pressure
  • explore

The shape looks like this:

final assessment = AiEmpireAssessment.fromView(view, context);

final strategicPlan = context.strategicPlan ??
    const StrategicPlanner().build(
      view: view,
      context: context,
      assessment: assessment,
    );

final foundings = _planFoundings(view, planningContext, assessment);
final combat = _planCombatReactions(view, planningContext, usedUnitIds);
final research = _planResearch(view, planningContext, assessment);
final production = _planProduction(view, planningContext, assessment);
final workerActions = _planWorkerActions(view, planningContext, usedUnitIds);

This is not meant to be “perfect AI”. It is meant to be understandable AI.

I would rather have an opponent whose decisions I can inspect and improve than a black box that sometimes behaves well and sometimes makes bizarre choices for reasons I cannot locate.

Strategic Plans and Caching

Some AI decisions should not be recomputed from scratch every tiny moment.

The project has a strategic plan provider that can cache longer-term plans and recompute them only when the relevant strategic signal changes or a checkpoint interval passes.

class AiStrategicPlanProvider {
  final StrategicPlanner planner;
  final int recomputeInterval;

  StrategicPlan resolve({
    required SaveSnapshot snapshot,
    required Player player,
    required GameView view,
    required AiContext context,
  }) {
    // return cached plan or rebuild when strategic triggers change
  }
}

This matters because a 4X AI needs both tactical reactivity and strategic continuity.

If the AI changes its entire plan every turn because one score changed slightly, it feels erratic. If it never updates its plan, it feels blind.

Caching gives me a middle ground: stable intentions with explicit triggers for recomputation.

MCTS as a Layer, Not the Whole Brain

The project also has MCTS-related pieces: action generation, simulation, evaluation, search budgets, and strategy-aware candidates.

But I do not think of MCTS as replacing the rest of the AI.

Instead, it can sit on top of the same command model.

The action generator produces candidate actions that are still based on game commands:

_addCommand(
  candidates: candidates,
  seen: seen,
  state: state,
  command: SelectTechnologyCommand(view.forPlayerId, technologyId),
);

This keeps search connected to the same game language. Whether an action comes from a hand-written heuristic, a strategic planner, or an MCTS candidate generator, it still eventually becomes a command.

That is the architectural point.

Why This Matters for Testing

Because AI produces commands, it can be tested without the UI.

I can test whether a strategy selects research when research is empty. I can test whether it founds cities under the right conditions. I can test whether it avoids moving into occupied tiles. I can test whether fog changes visible enemies. I can test whether the runner reports rejected commands.

This is much easier than testing an AI that drives UI gestures.

It also means AI improvements become normal domain/application work, not special frontend automation.

AI and Fog of War

Fog of war is one of the most important constraints on AI.

The GameView separates visible enemy units from remembered enemy cities. That means the AI can remember where a city was discovered, while still only reacting tactically to units it can currently see.

visibleEnemyUnits: [
  for (final unit in state.units)
    if (unit.ownerPlayerId != forPlayerId &&
        dynamicVisibility.canSeeDynamicAt(unit.col, unit.row))
      unit,
],

This makes AI behavior more honest.

It can respond to visible threats. It can plan around remembered geography. But it does not automatically need to know every hidden unit.

There are escape hatches, like ignoreFogOfWar, for simulation or development. The key is that omniscience is explicit.

The Trade-Off

This architecture makes AI slower to write at first.

It would be faster to give the AI direct access to all state and let it mutate whatever it wants. It would also be faster to add special-case shortcuts like “just create a unit here” or “just set production directly”.

But that would create a second version of the game.

By forcing AI through the same commands, I get a few benefits:

  • rules stay centralized
  • invalid decisions are rejected normally
  • AI can work locally or against the server model
  • fog of war can be respected
  • plans can be logged and inspected
  • tests can run without rendering
  • balance tooling can reuse the same strategy code

The cost is more structure. For this project, that cost is worth it.

The Rule I Use

The AI should not play through the UI, and it should not play outside the rules.

It should look at a player-specific view of the game, build a plan, and express that plan as commands.

That makes AI another participant in the same architecture as the human player. It can be smarter, faster, or more analytical, but it still speaks the same language.

In a strategy game, that matters. The opponent should feel like it is playing the game, not editing the save file.

Comments

Leave a Reply

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