After the map exists, the next question is simple: how does a unit move through it?
In a strategy game, movement looks like a visual feature. A unit slides from one hex to another. A path appears. The camera follows. Fog reveals new terrain.
But movement is not animation.
Movement is a domain rule. Animation is only how the result becomes visible.
In Age of New Worlds, that distinction shapes the whole movement system.
Movement Starts as Intent
A player does not directly move a sprite.
A player expresses intent. In code, that intent becomes a command:
MoveUnitCommand(unitId, targetCol, targetRow)
That command may come from a tap on the map, a HUD action, queued movement, auto-explore, AI planning, or eventually the network server.
The command does not say how to animate. It only says: this unit wants to move to this tile.
The reducer decides whether that can happen.
flowchart TD Input["Player tap / AI plan"] Command["MoveUnitCommand"] Reducer["MovementReducer"] Pathfinder["UnitMovementPathfinder"] State["New GameState"] Effects["Animation Effect"] Fog["Fog Recompute"] Input --> Command Command --> Reducer Reducer --> Pathfinder Pathfinder --> Reducer Reducer --> State Reducer --> Effects Reducer --> Fog
The Pathfinder
The core pathfinding object is UnitMovementPathfinder.
class UnitMovementPathfinder {
final MapData mapData;
final List<GameUnit> units;
final bool Function(TileData tile)? canEnterTile;
UnitMovementPathfinder({
required this.mapData,
required Iterable<GameUnit> units,
this.canEnterTile,
});
}
It needs three things:
- the map
- the units currently occupying the map
- an optional rule for whether a tile can be entered
That last parameter matters because movement is not always the same in every context. Normal movement may respect fog. Auto-explore may have different assumptions. AI simulation may use a slightly different view. Tests may inject special rules.
The pathfinder itself returns a UnitMovementPlan.
class UnitMovementPlan {
final String unitId;
final int targetCol;
final int targetRow;
final int totalCost;
final int availableMovementPoints;
final bool canMoveNow;
final List<UnitMovementStep> steps;
}
A movement plan is not yet a mutation. It is an answer to a question:
Given this unit and this target, what path exists and how much would it cost?
Movement Cost
Movement cost comes from terrain.
abstract final class UnitMovementCostRules {
static MovementCost costToEnterTile(TileData tile) {
return costToEnter(TileTerrainProfileRules.fromTile(tile));
}
}
Some tiles are blocked. Some are cheap. Some are expensive.
static int? _baseCost(TerrainType? terrain) {
return switch (terrain) {
TerrainType.grassland || TerrainType.plains || TerrainType.coast => 1,
TerrainType.desert || TerrainType.tundra || TerrainType.wetlands => 2,
TerrainType.snow => 3,
TerrainType.ocean || TerrainType.lake => null,
_ => null,
};
}
Then terrain features can add cost:
if (profile.hasForest) cost += 1;
if (profile.hasJungle) cost += 1;
if (profile.hasWetlands) cost += 1;
if (profile.hasHills) cost += 1;
So movement is not only about distance. The map matters.
A route through plains is different from a route through hills, forest, jungle, wetlands, snow, or blocked terrain.
Occupancy and Blocking
The pathfinder also accounts for units.
final blockingUnit = _unitAt(next.col, next.row);
if (blockingUnit != null && blockingUnit.id != unit.id) continue;
This makes the path depend on the current board state. A tile may be passable terrain but still unavailable because another unit occupies it.
There is also a helper for blocked targets:
UnitMovementPlan? planTowardBlockedTarget({
required GameUnit unit,
required TileData targetTile,
})
That is useful when the player or AI wants to approach something occupied. The unit may not be able to enter the target tile, but it can still choose the best neighboring tile.
Preview Is Not Execution
Human movement has a preview flow.
The first tap can create a movement preview. The second tap confirms it.
static GameStateTransition handleMoveTargetTile(
GameState state,
TileData tileData,
MapData mapData,
) {
final preview = state.movePreview;
final isConfirmation =
preview != null &&
preview.unitId == selected.id &&
preview.targetCol == tileData.col &&
preview.targetRow == tileData.row;
if (isConfirmation) {
return _confirmMovePreview(state, mapData);
}
return _setMovePreview(state, selected, tileData, mapData);
}
This is a UX layer on top of the domain.
The preview lets the player see the path and cost before committing. But execution still replans when the move is confirmed. That matters because the world may have changed between preview and confirmation.
The preview is helpful. The reducer is authoritative.
Partial Movement and Queued Paths
A unit may not have enough movement points to reach the target this turn.
That does not mean the command is useless. The movement plan can identify the furthest reachable step:
UnitMovementStep? get furthestReachableStep {
final reachable = reachableSteps;
if (reachable.isEmpty) return null;
return reachable.last;
}
If the full path is too expensive, the unit can move as far as it can and keep a queued path for later:
final movedWithPath = reachable
? moved.copyWithQueuedPath(null)
: moved.copyWithQueuedPath(_queuedPathFor(plan));
This is important for a 4X game because movement often spans multiple turns.
A player should be able to say “go there”, even if “there” is not reachable immediately.
New Turns Continue Movement
At the start of a new turn, movement points reset and queued paths can continue.
The reducer handles that in resetUnitMovementForNewTurn.
Conceptually:
flowchart TD Turn["New Turn"] Reset["Reset Movement Points"] Validate["Validate Queued Path"] Replan["Replan Against Current Units"] Move["Move Reachable Steps"] KeepQueue["Keep or Clear Queue"] Fog["Recompute Fog"] Turn --> Reset Reset --> Validate Validate --> Replan Replan --> Move Move --> KeepQueue KeepQueue --> Fog
The queued path is not blindly trusted. It is validated and replanned against the current unit positions. That prevents a unit from walking through a tile that became blocked.
This is one of those quiet rules that saves the game from strange edge cases.
Fog of War and Movement Planning
Movement also touches fog of war.
The game has visibility rules for path planning:
abstract final class UnitMovementVisibilityRules {
static const hiddenPathingRange = 3;
static bool canPlanThroughTile({
required GameUnit unit,
required TileData tile,
required FogVisibilityQuery visibility,
}) {
final tileVisibility = visibility.visibilityForTile(tile);
if (tileVisibility.isKnown) return true;
final distance = HexDistance.between(
HexCoordinate(col: unit.col, row: unit.row),
HexCoordinate.fromTile(tile),
);
return distance <= hiddenPathingRange;
}
}
This is a design choice.
The player can plan through known territory. The player can also plan a short distance into hidden territory so exploration is not painfully constrained. But fog still limits perfect long-range planning into the unknown.
After movement, fog is recomputed:
final newFog = fogOfWarService.recompute(
current: state.fogOfWar,
mapData: mapData,
playerIds: knownPlayerIds(state),
units: updatedUnits,
cities: state.cities,
);
Movement changes what the player can see. That makes movement and fog tightly connected.
State Changes vs Animation
When a unit moves, the reducer updates the unit position and movement points.
final moved = unit.copyWith(
col: destinationStep.col,
row: destinationStep.row,
movementPoints: unit.movementPoints - destinationStep.cumulativeCost,
posture: UnitPosture.active,
);
Then it returns a UI effect:
AnimateUnitMoveEffect(
unitId: unit.id,
fromCol: unit.col,
fromRow: unit.row,
steps: stepsForAnimation,
)
This is the boundary I want.
The domain says: the unit moved from here to there along these steps.
The renderer decides how that looks.
The movement rule should not know about Flame components. The renderer should not decide whether movement was legal.
Auto-Explore Uses the Same System
Scouts can auto-explore, but auto-explore is not a separate movement engine.
The ScoutAutoExplorePlanner chooses a MoveUnitCommand.
return MoveUnitCommand(unit.id, tile.col, tile.row);
It scores candidates based on things like newly discovered hexes, whether the target itself is undiscovered, visible hex count, movement cost, and distance.
int get score =>
newlyDiscoveredHexes * newlyDiscoveredHexScore +
(targetUndiscovered ? undiscoveredTargetScore : 0) +
visibleHexes * visibleHexScore;
Then the normal movement reducer executes the command.
That means auto-explore still respects the same command path, movement rules, fog recomputation, events, and animation effects.
AI Uses the Same Movement Language
The AI also uses MoveUnitCommand.
It uses pathfinding to evaluate where units can go, whether a settler should relocate, whether a military unit should pressure an enemy, whether a worker can reach a useful tile, and whether a scout should explore.
This is important because AI should not mutate unit positions directly.
If AI wants to move, it should speak the same movement language as the player.
That keeps the rules centralized. If movement cost changes, human movement, auto-explore, and AI movement all inherit that change.
Why Movement Is Harder Than It Looks
A simple movement system would only check whether the target is adjacent.
A real 4X movement system has to answer more questions:
- Is the target inside the map?
- Is the terrain passable?
- What is the movement cost?
- Is another unit blocking the target?
- Can the unit plan through this tile under fog?
- Does the unit have enough movement points?
- If not, what is the furthest reachable step?
- Should the rest of the path be queued?
- Does the move reveal new fog?
- Should the renderer animate it?
- Does the selected unit need to be refreshed?
- Should auto-explore continue next turn?
That is why movement belongs in the domain.
It touches too many systems to be a renderer trick.
The Rule I Use
The rule is:
Movement intent is a command. Movement legality is a domain rule. Movement display is a renderer effect.
That split lets the same movement system serve player input, queued paths, auto-explore, AI, hotseat, multiplayer, fog of war, and tests.
The unit sliding across the map is only the visible part. Underneath it is a chain of decisions about terrain, cost, visibility, state, and time.
Leave a Reply