[AoNW] Rendering a Hex World with Flutter and Flame

Age of New Worlds is built in Flutter, but the map itself is not a normal Flutter widget tree.

That was one of the first architectural decisions I had to make. A 4X game has a lot of ordinary UI: menus, panels, buttons, overlays, localization, routing, settings, loading states, and HUD elements. Flutter is excellent for that.

But the game map is different.

The map needs a camera. It needs panning and zooming. It needs layered rendering, unit markers, fog of war, selection highlights, movement previews, particles, hover states, and a world coordinate system that is not the same thing as the screen coordinate system.

That is where Flame fits.

So the rendering architecture of Age of New Worlds is a hybrid: Flutter owns the application and HUD, Flame owns the interactive hex world.

The Basic Shape

At the screen level, the game is still a Flutter page.

The game screen creates a Flame renderer, places it behind the HUD, and lets Flutter handle the surrounding interface. The simplified shape looks like this:

Stack(
  children: [
    Positioned.fill(
      child: CappedGameWidget(
        game: _renderer,
        loadingBuilder: (_) => const GameLoadingPanel(),
      ),
    ),
    Positioned.fill(
      child: GameHud(
        session: session,
        gameSave: widget.gameSave,
        displaySettings: widget.displaySettings,
      ),
    ),
  ],
)

This split is important.

The renderer should feel like a game. The HUD should feel like an app. I want the map to be fluid and spatial, while the panels, buttons, objectives, production controls, and options remain predictable Flutter UI.

Trying to build the whole map as widgets would make camera and world rendering awkward. Trying to build the whole interface in Flame would throw away a lot of what Flutter is good at.

So I use both.

The Flame World

The base class for the map renderer is HexWorld.

It extends FlameGame, owns camera behavior, and handles viewport-level input like dragging, pinch zoom, pan zoom, long press, and hover.

abstract class HexWorld extends FlameGame
    with ScrollDetector, CameraController {
  @override
  Color backgroundColor() => MapPalette.worldBackground;

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    camera.viewfinder.zoom = 1.0;
    camera.viewfinder.anchor = Anchor.topLeft;
    await buildWorld();
  }

  Future<void> buildWorld();
}

This gives me a reusable base for a world that can be dragged, zoomed, tested, and measured independently from the specific game state.

The renderer for Age of New Worlds then builds on top of that:

class GameRenderer extends HexWorld
    with KeyboardEvents, LongPressDetector, HexInputBehavior {
  final MapData mapData;
  final String? imagePath;
  final Future<void> Function(GameCommand command) onCommand;

  GameState _renderState = const GameState(activePlayerId: '__loading__');
}

The renderer knows how to display the game, but it still does not own the rules. When the player interacts with the world, the renderer sends commands back into the application layer.

Building the Hex Grid

The visual foundation of the world is HexGrid.

It takes MapData and turns it into Flame components. Each tile has a coordinate, terrain, resources, height, markers, and presentation settings.

One small detail I like is that the grid applies a Y scale to create a slightly isometric perspective:

class HexGrid extends PositionComponent {
  static const double perspectiveY = 0.62;

  HexGrid({
    required this.mapData,
    required this.config,
    required MapViewMode viewMode,
    required HexDisplaySettings displaySettings,
  }) : super(scale: Vector2(1.0, perspectiveY));
}

That means the map is still logically a hex grid, but visually it has more depth than a flat top-down board.

The grid also owns coordinate picking. When the player taps or hovers in world space, the grid can translate that point back into a tile:

TileData? tileDataAtWorldPoint(Vector2 worldPoint) {
  final localPoint = absoluteToLocal(worldPoint);
  final coords = HexGeometry.tileAt(
    point: localPoint,
    hexRadius: config.hexRadius,
    cols: mapData.cols,
    rows: mapData.rows,
  );
  if (coords == null) return null;
  return mapData.tileAt(coords.col, coords.row);
}

This is one of those unglamorous pieces that everything else depends on. If coordinate conversion is wrong, selection, movement, inspection, combat targeting, and long-press behavior all feel broken.

Scene Construction

The map scene is built by GameSceneBuilder.

It creates the base image layer and the hex grid, then adds them to the Flame world in the right order.

class GameSceneBuilder {
  HexGrid? _grid;
  MapImageLayer? _imageLayer;

  Future<void> build({
    required Component parent,
    required MapData mapData,
    String? imagePath,
    required MapViewMode viewMode,
    required HexDisplaySettings displaySettings,
    required GameTileTapCallback onTileTapped,
  }) async {
    _imageLayer = MapImageLayer(
      config: MapConfig.defaultConfig,
      cols: mapData.cols,
      rows: mapData.rows,
    );
    await parent.add(_imageLayer!);

    _grid = HexGrid(
      mapData: mapData,
      config: MapConfig.defaultConfig,
      viewMode: viewMode,
      displaySettings: displaySettings,
      onTileTapped: onTileTapped,
      autoSelectOnTap: false,
    );
    await parent.add(_grid!);
  }
}

The game supports different map presentation modes. Sometimes I want the generated tile view. Sometimes I want to show a graphic map image underneath or instead of parts of the tile presentation. That is why the image layer and grid are separate.

This also matters for tooling. The same map data can support gameplay, editor views, graphic reference layers, and different display preferences without changing the domain model.

Rendering Layers

A 4X map is not one thing. It is many layers that happen to line up.

Age of New Worlds has separate rendering layers for things like:

  • units
  • cities
  • field improvements
  • fog of war
  • movement previews
  • city territory
  • city founding previews
  • threat overlays
  • era tint
  • floating text
  • particles
  • action palettes

The GameRenderer owns these layers, but a separate coordinator keeps them synchronized with game state.

class GameRenderingCoordinator {
  final UnitMarkerLayer unitMarkers;
  final UnitMovePreviewLayer movePreview;
  final FieldImprovementMarkerLayer fieldImprovementMarkers;
  final CityMarkerLayer cityMarkers;
  final CityTerritoryOverlayLayer cityTerritory;
  final FogOfWarOverlayLayer fogOfWar;
  final ThreatOverlayLayer threatOverlay;
  final ActionPaletteLayer actionPalette;
  final HexGrid grid;

  void syncAll({
    required GameState state,
    required Component parent,
    required ValueNotifier<GameRenderViewModel> viewModelNotifier,
  }) {
    final viewModel = GameRenderViewModel.fromState(state);

    _syncGridSelection(grid, viewModel);
    _syncPlanningMarkers(state);
    _syncFieldImprovementMarkers(state, parent);
    _syncCityMarkers(state, parent);
    _syncFogOfWar(state);
    _syncUnitMarkers(state, parent);
    _syncMovePreview(state, parent);
  }
}

This is another boundary I care about.

The renderer owns the visual world, but synchronization is explicit. When the game state changes, the coordinator updates each visual layer. Units move to match units in state. Cities update to match cities in state. Fog updates to match player visibility. Movement previews appear or disappear based on the current selection and pending action.

The renderer does not discover game truth by looking at its own components. It receives state and renders it.

Why Layers Matter

Layers make the map easier to reason about.

Fog of war is not mixed into unit rendering. City territory is not mixed into tile drawing. Movement preview is not part of the unit component. Threat overlays are not hardcoded into the terrain.

That separation gives me room to evolve each system.

For example, fog of war has its own visual behavior and rules about what the active player can inspect. Unit markers have to deal with selection, ownership, health, animation, and density. City territory has to show borders and controlled hexes. Movement preview has to express path cost and possible targets.

If all of that lived inside HexTile, the tile component would become the entire game.

Instead, the tile is the foundation. The layers explain what is happening on top of it.

Flutter Still Owns the HUD

The HUD is deliberately not part of the Flame world.

Things like the objective panel, resource display, action deck, turn controls, city panels, options overlay, combat preview, and loading states are Flutter widgets.

That gives me normal layout tools, localization, accessibility hooks, text rendering, responsive constraints, and widget testing. It also keeps the HUD from being tied too tightly to Flame’s component lifecycle.

The renderer exposes view-model state back to Flutter through listenables and providers. Flutter can then decide which panels to show, which buttons are enabled, and what text should be visible.

This gives the game a useful two-way relationship:

  • Flame turns input into spatial intent.
  • The application layer turns intent into game state.
  • Flame renders the world state.
  • Flutter renders the interface around that world.

Input Has to Feel Immediate

One challenge with this architecture is responsiveness.

Even though the renderer does not own the rules, the player still expects the map to feel immediate. Hover feedback, tap feedback, long-press inspection, drag thresholds, pinch zoom, movement previews, and camera focus all need to feel direct.

That means the renderer has a lot of interaction code. It tracks whether the player is dragging, whether a long press is active, where the pointer is, and which world coordinate is under the cursor.

But the important distinction is this: interaction code can live in the renderer without turning into rule ownership.

The renderer can know “the player tapped this tile.” It can even know “this tile is under the pointer.” But it should not be the final authority on whether a city can be founded there or whether an attack is legal.

That remains a command/reducer problem.

Performance and Practicality

Rendering a strategy map is not only about drawing pretty tiles. It is also about avoiding death by a thousand small components.

The project includes development performance hooks around world building, update, render, and component counts. That lets me measure the cost of the scene instead of guessing.

await DevPerformance.timeAsync(
  '$runtimeType.buildWorld',
  () => buildWorld(),
);

This has already shaped how I think about the map. Some things can be individual Flame components. Some things are better as batched or coordinated layers. Some updates should happen only when state changes, not every frame.

A turn-based strategy game does not need the same rendering profile as an action game, but it still needs to feel smooth. Panning a large map with fog, markers, and overlays should not feel heavy just because the game itself is turn-based.

The Trade-Off

Using Flutter and Flame together gives me a lot, but it also creates seams I have to manage carefully.

There are two lifecycles: Flutter widgets and Flame components. There are two kinds of coordinates: screen space and world space. There are two styles of state: provider-driven app state and component-driven visual state.

That can get messy if the boundaries are vague.

So I try to keep the rule simple:

Flutter owns the app interface. Flame owns the spatial world. The domain owns the truth.

When that rule holds, the architecture feels flexible. I can improve the HUD without rewriting the renderer. I can change map layers without changing game rules. I can test reducers without loading Flame. I can render richer effects without making the domain depend on visuals.

What This Enables

This rendering architecture is not only for the current prototype. It is meant to support where the game is going.

I want larger maps, better fog of war, richer unit animation, clearer city visuals, stronger combat feedback, useful strategic overlays, and smoother mobile interaction. I also want the map editor and development tools to keep sharing the same underlying map concepts.

The hybrid approach makes that possible.

Flutter gives me a strong application shell. Flame gives me a real game world. The layered renderer gives me room to make the map more expressive without letting visual code swallow the rules.

Age of New Worlds is still visually evolving, but the shape is already there: a Flutter strategy game with a Flame-powered hex world at its center.

Comments

Leave a Reply

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