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:
// 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
Buttonis held, pressed or released. AnAxisruns from -1 to 1. AVectorhasXandY, with y pointing down and length at most 1. Axes and vectors are alsoHeldpast 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.
OnEachFramereads them and sends the ship aSteercommand 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
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 }
onegame test --no-unitPASS 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 untilstate: up. A barepress: dtaps it.pad:drives the harness’s virtual gamepad:buttontakes the namesnav:uses (south,east,start...), andaxistakesleftx,lefty,rightx,righty,lefttriggerorrighttrigger.action:drives the action itself, whatever it is bound to:action: boosttaps a button, andstate: downholds it untilstate: up. Theaction:expectation checksheld,pressed,released,xandy.- 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:
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:
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:
bindings: line 4: unknown action 'jump'.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:
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()andLoadText(text)do the same with a string. A bad line is skipped and listed inProblems.actions.SetBinding,ResetBindingsandBeginRebind(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.