Recursive AI

Input actions for keyboard, gamepad and tests

All onegame recipes

Declare a C# game’s input as named actions with default bindings for the keyboard and a gamepad, read them in view code, test them with scenario steps for keys, sticks and actions, inspect them live, and load a player’s saved bindings.

New in 1.2.0. A game declares actions such as move and boost once, each with its default bindings, and reads the actions rather than keys. The same declaration answers the keyboard, any connected gamepad, Steam Input and the test harness. The scaffold that onegame new writes already works this way. This recipe starts from it, as onegame new pilot.

Declare and read

In Game.cs, the scaffold’s two methods on the game’s GameDefinition, with loading of saved bindings added at the end of DefineActions:

C#
    // Input is declared as actions with default bindings. One declaration answers the keyboard, a gamepad and the test harness.
    public override void DefineActions(ActionMap actions)
    {
        actions.Context("play", c => c
            .Vector("move", Bind.Wasd, Bind.Arrows, Bind.LeftStick)
            .Button("boost", Bind.Key(Key.Space), Bind.Pad(GamepadButton.South)));
 
        // The player's saved bindings, on top of the defaults. A missing file leaves the defaults.
        var loaded = actions.Load(ActionMap.DefaultPath(Name));
        foreach (var problem in loaded.Problems)
        {
            Console.Error.WriteLine("bindings: " + problem);
        }
    }
 
    // View code reads the actions and sends the ship a command when they change.
    public override void OnEachFrame(GameInstance game)
    {
        var ship = ShipOf(game);
        var move = game.Actions.Vector("move");
        int dx = (int)MathF.Round(move.X), dy = (int)MathF.Round(move.Y);
        var boost = game.Actions.Held("boost");
        if (dx != ship.Dx || dy != ship.Dy || boost != ship.Boost)
        {
            ship.Send.Steer(dx, dy, boost);
        }
    }
  • Kinds. A Button is held, pressed or released. An Axis runs from -1 to 1. A Vector has X and Y, with y pointing down and length at most 1. Axes and vectors are also Held past a threshold (0.5 by default), so a stick can work as a button.
  • Bindings. Bind.Key, Bind.Pad, Bind.PadAxis, Bind.Mouse, Bind.Wheel, Bind.Touch, and composites: Bind.Wasd, Bind.Arrows, Bind.DPad, Bind.LeftStick, Bind.RightStick, Bind.Pair(negative, positive). An action takes several bindings, and the strongest wins. A binding of the wrong shape throws when it is declared.
  • Commands, not state. Actions are view state. OnEachFrame reads them and sends the ship a Steer command when they change, so recordings, replays and the network see commands, never keys.
  • Contexts. actions.Context("menu", ...) groups actions, and .OnScreens("menu") turns a context on only while that screen is open. c.Tune("move", deadZone: 0.25f) changes a dead zone or threshold.

Test it three ways

One scenario steers the ship with a held key, then the left stick, then the actions themselves:

scenarios/three-ways.yaml

YAML
name: three-ways
description: The keyboard, a gamepad and the move action steer the ship through the same declaration.
steps:
  - action: { name: move, state: up }     # start clean: in 1.2.0 a held action carries over from the scenario before
  - action: { name: boost, state: up }
  - press: { key: d, state: down }        # hold D
  - step: 10
  - press: { key: d, state: up }
  - step: 2
  - expect:
      state: { entity: Ship, property: X, equals: 15 }
  - pad: { axis: leftx, value: 1 }        # push the left stick right
  - step: 10
  - pad: { axis: leftx, value: 0 }
  - step: 2
  - expect:
      state: { entity: Ship, property: X, equals: 30 }
  - pad: { button: south, state: down }   # hold boost on the pad
  - action: { name: move, state: down, x: 0, y: 1 }
  - step: 10
  - expect:
      action: { name: boost, held: true }
  - expect:
      action: { name: move, held: true, y: 1 }
  - pad: { button: south, state: up }     # let go of everything
  - action: { name: move, state: up }
  - step: 2
expect:
  - state: { entity: Ship, property: Boost, equals: false }
  - state: { entity: Ship, property: Y, equals: 30 }
Terminal
onegame test --no-unit
Text
PASS ship-looks-right (375 ms, tick 40, hash a5a533b83e128e35)
PASS steer-ship (4 ms, tick 22, hash 19dbaa512f757607)
PASS three-ways (7 ms, tick 36, hash 1619491e22571156)
3 passed, 0 failed

Holding D for ten frames and pushing the stick for ten move the ship the same 15 units at the normal speed of 1.5 a tick. With boost held, ten frames down make 30. Run alone, three-ways gives the same hash.

  • press: { key: d, state: down } holds a key until state: up. A bare press: d taps it.
  • pad: drives the harness’s virtual gamepad: button takes the names nav: uses (south, east, start...), and axis takes leftx, lefty, rightx, righty, lefttrigger or righttrigger.
  • action: drives the action itself, whatever it is bound to: action: boost taps a button, and state: down holds it until state: up. The action: expectation checks held, pressed, released, x and y.
  • An expect: step takes one expectation.

At 1.2.0 a held action carries over into the next scenario of the same onegame test run. The scaffold’s steer-ship ends with move and boost still held, so a scenario after it starts boosting. That is why three-ways releases both first and lets go of everything at the end.

Look at them live

The actions tool lists every context and action with its state and bindings. Given a name, it drives that action as the harness does. With the MCP client from Drive a running game over MCP, with the 200-character cut taken out:

Text
step {"frames": 1} -> {"frame":1,"tick":1,"hash":"a7f6119f33887e04"}
actions {} -> {"contexts":[{"name":"play","active":true,"screens":[]}],"actions":[{"name":"move","kind":"vector","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":true,"bindings":["quad(key:A,key:D,key:W,key:S)","quad(key:ArrowLeft,key:ArrowRight,key:ArrowUp,key:ArrowDown)","stick:left"]},{"name":"boost","kind":"button","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":true,"bindings":["key:Space","pad:South"]}]}
actions {"name": "boost", "state": "down"} -> {"contexts":[{"name":"play","active":true,"screens":[]}],"actions":[{"name":"move","kind":"vector","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":true,"bindings":["quad(key:A,key:D,key:W,key:S)","quad(key:ArrowLeft,key:ArrowRight,key:ArrowUp,key:ArrowDown)","stick:left"]},{"name":"boost","kind":"button","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":true,"bindings":["key:Space","pad:South"]}]}
step {"frames": 1} -> {"frame":2,"tick":2,"hash":"b7cdad13ee4ffa71"}
state_query {"entity": "Ship"} -> {"id":1,"type":"Ship","X":0,"Y":0,"Dx":0,"Dy":0,"Boost":true}
quit {} -> {"frame":2,"tick":2,"hash":"b7cdad13ee4ffa71"}

A change shows after the next step: the second actions call still reports boost up, and one frame later the ship is boosting. Query after a step, too. Before the first frame every action reads active: false. input_pad holds pad buttons and sticks the same way.

Saved bindings

A bindings file lists only the actions that differ from their defaults, one line each, in the spelling the actions tool shows. ActionMap.DefaultPath("pilot") is ~/.local/share/pilot/bindings.txt on Linux:

Text
onegame-bindings 1
boost = key:B | pad:South
move = quad(key:J,key:L,key:I,key:K) | stick:left
jump = key:Space

With that file in place, the game reports the line it skipped on stderr, and actions shows the player’s bindings:

Text
bindings: line 4: unknown action 'jump'.
Text
step {"frames": 1} -> {"frame":1,"tick":1,"hash":"a7f6119f33887e04"}
actions {} -> {"contexts":[{"name":"play","active":true,"screens":[]}],"actions":[{"name":"move","kind":"vector","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":false,"bindings":["quad(key:J,key:L,key:I,key:K)","stick:left"]},{"name":"boost","kind":"button","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":false,"bindings":["key:B","pad:South"]}]}
quit {} -> {"frame":1,"tick":1,"hash":"a7f6119f33887e04"}

The test run reads the same file, because DefineActions runs there too. D no longer moves the ship, so the key part of three-ways fails:

Text
PASS ship-looks-right (389 ms, tick 40, hash a5a533b83e128e35)
PASS steer-ship (4 ms, tick 22, hash 19dbaa512f757607)
FAIL three-ways (3 ms, tick 12, hash b04b131b59df8baa)
  step 6 (expect, line 10): expectation_failed: Ship.X is 0, expected 15
  expect state (line 11): Ship.X is 0, expected 15
    expected: 15
    actual:   0
2 passed, 1 failed

action: and pad: steps don’t depend on key bindings, so prefer them in tests. Test key bindings on a machine without a saved file.

  • actions.Save(path) writes the file through a temporary file. ToText() and LoadText(text) do the same with a string. A bad line is skipped and listed in Problems.
  • actions.SetBinding, ResetBindings and BeginRebind (which waits for the next key, button or stick push) change bindings in a running game. ActionUi.BindingList(game) builds a list of the actions with their bindings and Rebind and Reset buttons.
  • These weren’t run for this page: Steam Input feeding the same actions (SteamActionSource), touch regions and the rebinding screen.

Every call is in the engine’s input notes.

See both in a complete game: C# game development without an editor.