[AoNW] Adding Gamepad Support to a Flutter 4X Game

Written by

in

,

Age of New Worlds started as a mobile-first game.

That shaped a lot of early decisions. The map had to work with touch. The HUD had to survive small screens. Panels had to slide in and out without burying the world. Most interactions were built around taps, long presses, drags, and responsive layouts.

For a while, that was enough.

Then the game started growing toward more platforms: web, desktop, Steam, and eventually devices like the Steam Deck. At that point controller support stopped being a nice extra and became part of the shape of the game.

A turn-based 4X game can be played slowly, but the input still has to feel immediate. Moving the map cursor, opening HUD panels, confirming actions, cancelling selections, inspecting tiles, scrolling popups, and ending a turn all need to work without the player reaching for a mouse.

So I added gamepad support.

Not as a keyboard shortcut layer.

As an input architecture.

The Plugin

The low-level part is handled by the gamepads package.

It gives the app a stream of normalized gamepad events: buttons, axes, values, and gamepad ids. That matters because controllers are messy. A Steam Deck, Xbox controller, DualSense, and generic Bluetooth controller should not force the game code to understand four different worlds.

In AoNW the plugin enters through a small adapter:

Gamepads.normalizedEvents
        ↓
GamepadInputAdapter
        ↓
GamepadEventMapper
        ↓
GamepadInputSnapshot

The adapter listens to the normalized event stream, tracks the active controller, and updates a single ValueNotifier<GamepadInputSnapshot>.

The mapper is where hardware becomes game intent.

A button is not “button 0”. It is confirm, cancel, inspect, moveMode, primaryAction, or hudFocusNext.

An axis is not “right stick Y”. It is cameraY.

That distinction is small, but it keeps the rest of the game from depending on controller hardware.

flowchart TD
    A["gamepads plugin<br/>NormalizedGamepadEvent"] --> B["GamepadInputAdapter"]
    B --> C["GamepadEventMapper"]
    C --> D["GamepadInputSnapshot<br/>current held state"]
    D --> E["GamepadFrameController"]
    E --> F["GamepadControlFrame<br/>edge presses + repeat + analog"]
    F --> G["GamepadInputRouterScope"]

Snapshot vs Frame

One of the most useful separations is between a snapshot and a frame.

GamepadInputSnapshot describes what is currently held.

GamepadControlFrame describes what should happen this frame.

Those are not the same thing.

Holding A should not confirm every frame. Holding the D-pad should move once, wait briefly, then repeat. Analog camera movement should be continuous. Buttons should fire on edges. Deadzones should apply before the renderer ever sees camera values.

That is the job of GamepadFrameController.

It turns raw held state into frame-level meaning:

held confirm: true
previous confirm: false
→ confirmPressed: true

held confirm: true
previous confirm: true
→ confirmPressed: false

The same controller also handles cursor repeat timing, deadzone normalization, and camera sensitivity.

Routing, Not Broadcasting

The first version of gamepad input was too widget-local.

Each listener could have its own ticker. Each panel could interpret the same snapshot. The renderer could consume input while a popup was open. It worked until the UI became dense enough that “works” was not the same as “has ownership”.

The final version has one central GamepadInputRouterScope.

Routes register with a priority:

renderer < primary < hud < panel < modal

Analog frame callbacks can be broadcast, because the renderer may still need camera input.

Discrete actions are different. confirm, cancel, details, mode, navigation, and primary action go through _dispatchFirst. The highest-priority route that can handle the action wins.

flowchart LR
    A["GamepadControlFrame"] --> B["Router"]
    B --> C["onFrame<br/>broadcast"]
    B --> D{"discrete action?"}
    D --> E["modal route"]
    D --> F["panel route"]
    D --> G["hud route"]
    D --> H["renderer route"]
    E --> I["first matching route wins"]

That solved a very real class of bugs.

For example: a resource popup can be opened with the mouse. If the player then uses the D-pad, the popup should scroll. The hidden map underneath should not also move its cursor or confirm a tile.

So scrollable popups register as panel routes. They consume navigation and unhandled gamepad actions. The renderer is still there, but it is lower priority.

The map waits its turn.

The Renderer Became Simpler

The renderer used to have a per-frame gamepad path of its own.

That looked convenient at first. The renderer already has an update loop, so why not let it read the latest gamepad snapshot?

Because then it becomes a second input router.

Now the renderer only exposes explicit actions:

applyGamepadAnalogFrame
moveGamepadCursor
confirmGamepadCursor
cancelGamepadAction
inspectGamepadCursor
toggleGamepadMoveMode

The renderer does not decide whether it owns the controller. The router decides that.

This made the map logic easier to reason about. The renderer can still pan the camera, move the map cursor, inspect the current tile, or dispatch commands through the same command mapper as other input paths. But it no longer quietly consumes buttons behind a modal.

HUD Focus

The HUD needed its own model too.

A controller is not a mouse. You cannot assume the player can point at a tiny resource chip or global action. The HUD needs focusable sections and predictable movement.

AoNW now uses directional HUD focus actions and a shared list cursor helper.

GamepadListCursor handles the boring but important part: selected-or-first, wraparound, positive and negative movement, empty lists, and preferred active items.

That logic appears in several places: city production, technology navigation, and HUD focus. The 2D technology tree still keeps its own geometry, because “move right” in a graph is not the same as “next item in a list”.

This is the kind of refactor I like: not abstracting the game away, only extracting the part that was already repeating.

Settings Matter

Controller support is not finished when the first button works.

People expect control settings.

AoNW now has a GamepadControlSettings value object with:

  • enabled/disabled gamepad input
  • deadzone
  • camera sensitivity
  • inverted camera Y
  • button bindings
  • axis bindings
  • reset to defaults
  • safe fallback when stored preferences are invalid

Default behavior stays close to the current manual:

A      confirm
B/Back cancel
X      move mode
Y      inspect
L3/R3  HUD focus previous/next
LB/RB  focus previous/next
Start  primary action
D-pad  cursor movement
Triggers zoom
Sticks  cursor/camera

The important part is that these defaults are not hardcoded into the renderer. They live at the mapping boundary.

The Shape Of The System

The resulting architecture is more layered than the first attempt, but each layer has a smaller job.

flowchart TD
    P["gamepads plugin"] --> A["Adapter<br/>stream + active device"]
    A --> M["Mapper<br/>hardware to intent"]
    M --> S["Snapshot<br/>held input state"]
    S --> F["Frame controller<br/>edges, repeat, deadzone"]
    F --> R["Router<br/>priority + ownership"]
    R --> HUD["HUD"]
    R --> PANEL["Panels / modals"]
    R --> MAP["Renderer"]
    MAP --> CMD["Game commands"]

This is the same lesson I keep running into with AoNW.

The hard part is rarely the first working version.

The hard part is deciding where the authority lives.

For gamepad input, the authority could not live in the renderer. It could not live in every panel. It had to live in a small routing scope that knows the current frame, knows the registered routes, and can make one decision per action.

Once that existed, the rest became much calmer.

Tests

The test suite covers the pieces separately:

  • mapper defaults and remapping
  • disabled input
  • invalid settings fallback
  • frame edge detection
  • cursor repeat
  • router priority
  • renderer arbitration behind panels
  • scrollable popup consumption
  • list cursor wraparound
  • provider persistence
  • architecture tests

The full suite currently passes with the gamepad changes included.

That matters because input bugs are usually not loud. They are small leaks. A button fires twice. A popup scrolls and the map moves. A held input becomes “new” when a route mounts. A deadzone slider says one thing and the clamp does another.

Those are exactly the kinds of mistakes I want tests to catch before I notice them while holding a controller.

Closing Thought

Adding gamepad support was not just about the Steam Deck.

The Steam Deck made the need obvious, but the deeper change was architectural. AoNW now has a clearer input pipeline: hardware events become game intent, game intent becomes frame actions, and frame actions are routed to whoever actually owns the controller at that moment.

That is the part I care about most.

Not only that the game can be played with a controller.

But that the controller now has a place in the architecture.

Comments

Leave a Reply

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