Table of Contents

Input

The engine reads the keyboard, mouse and gamepads for you. At the start of each update step, before Update runs, it reads the devices and brings the input managers and the bindings up to date. So inside Update, input already describes this step. Do not update the managers yourself: a second update in the same step loses every "just pressed" edge.

There are two ways to read input. Read a device directly when you want to know what it is doing now. Bind a named action when a player might change the control, or when the same action can come from a key or a gamepad.

Keyboard

Keyboard is a singleton, Keyboard.Instance. Inside a Game subclass it is also available as the Keyboard property.

protected override void Update(IGameTime gameTime)
{
  if (Keyboard.IsKeyDown(Key.LeftArrow))
  {
    // Held: true on every step the key is down.
  }

  base.Update(gameTime);
}

IsKeyDown and IsKeyUp read the keyboard as it stands after the events received so far.

To find the keys that changed this step, inject IKeyboardManager:

  • IsKeyPressed(key) is true on the step the key went down.
  • IsKeyReleased(key) is true on the step the key came up.
  • KeysDown lists the keys that went down this step.
  • KeysPressed lists every key down this step, not only the new ones.
  • KeysRepeated lists the operating system's key repeats for this step. A held key repeats, so it can appear more than once in a slow step.
private IKeyboardManager _keys;

protected override void Initialize()
{
  _keys = GetService<IKeyboardManager>();
  base.Initialize();
}

protected override void Update(IGameTime gameTime)
{
  if (_keys.IsKeyPressed(Key.Escape))
    Exit();

  base.Update(gameTime);
}

A key that goes down and comes up between two steps still reads as down in the step where it was released. A quick tap is not lost.

Key events

Keyboard.Instance also raises events as they arrive, before Update: KeyPressed, KeyReleased and TextInput. Each carries a KeyboardEventArgs. Use KeyCode for the key and IsRepeat to tell an operating system repeat from a new press. The TextInput event carries typed characters in Character, which is '\0' on a key event; see Text input.

The modifier properties are IsShiftPressed, IsCtrlPressed, IsAltPressed and IsCommandPressed. IsShortcutModifierPressed is true for either Ctrl or Command, so a shortcut written with it works on every platform.

Keys are named after the Windows virtual-key codes in Key: Key.A to Key.Z, the top row as Key.D0 to Key.D9, the keypad as Key.NumPad0 to Key.NumPad9, and Key.SpaceBar, Key.Enter, Key.Escape, Key.LeftShift, Key.UpArrow and so on. There are no lower-case letters. Typed characters come from text input, not from key codes.

Mouse

Inject IMouseManager for the mouse state, updated once a step.

Member Meaning
CurrentPosition The pointer in the game's logical render size, which is what the game draws in.
RawPosition The pointer in window pixels.
Difference How far the pointer moved since the last step, in logical coordinates.
IsPressed, IsClicked, IsReleased The left button: held, went down this step, came up this step.
IsRightPressed, IsRightClicked, IsRightReleased The same for the right button.
IsDoubleClicked True on the click that completes a double-click, on the same step as IsClicked. Two clicks count when they are within DoubleClickInterval (default 500 ms) and DoubleClickRadius (default 32 window pixels) of each other.
IsHovering(rectangle) Whether the pointer is over a rectangle, in logical coordinates.
CurrentState.ScrollWheelDelta Wheel movement since the last step.
private IMouseManager _mouse;

protected override void Update(IGameTime gameTime)
{
  if (_mouse.IsClicked && _mouse.IsHovering(_startButton))
    StartGame();

  base.Update(gameTime);
}

Do not call Mouse.Instance.GetSnapshot() yourself. Reading the snapshot takes the wheel movement with it, so the manager would see no scrolling.

Mouse.Instance also controls the cursor. IsVisible shows or hides it. IsRelativeMode switches to relative mode, for mouse-look: the snapshot's X and Y become the movement since the last read, not a position. Relative mode needs the game window, so set it from Initialize or later.

Gamepads

Gamepad opens each gamepad as it is connected and closes it when it is removed. Read the connected ones with GetSnapshots():

foreach (var pad in Gamepad.Instance.GetSnapshots())
{
  if (pad.IsPressed(GamepadButton.A))
  {
    // The bottom face button is A, whatever the pad's brand.
  }

  var stickX = pad.GetAxis(GamepadAxis.LeftX);
}

Each snapshot has an InstanceId, the device Name and a Type (GamepadType.Xbox, Playstation, SwitchPro, and so on). Buttons are named by their position on the pad. A is the bottom face button, B the right, X the left and Y the top.

Snapshots carry levels, not edges. For "just pressed" on a gamepad, use an action binding, or keep the previous step's snapshot and compare. Sticks read from -1 to 1 and triggers from 0 to 1.

A snapshot's axes already have the engine's dead zone applied: a stick inside a radial dead zone of 0.15 reads 0, and outside it the value is rescaled so it still runs from 0 to full deflection. A trigger below 0.05 reads 0. Bindings apply a dead zone of their own (0.2 by default) on top of that when they read an axis.

Input bindings

A binding maps a named action to keys, gamepad buttons or both. The game's code asks about the action, and the player's choice of control decides what answers. The engine registers the service as IInputBindingService.

Register each action once, usually in Initialize. A button is either held or not. An axis is a value from -1 to 1.

using BSGameLib.Game.Input;

protected override void Initialize()
{
  var bindings = GetService<IInputBindingService>();

  bindings.RegisterAction(InputActionDefinition.Button(
    "jump", "Jump",
    InputBinding.CreateButton(
      keyboardKeys: [new KeyboardKeyBinding(Key.SpaceBar)],
      gamepadButtons: [new GamepadButtonBinding(GamepadButton.A)]),
    category: "Gameplay"));

  bindings.RegisterAction(InputActionDefinition.Axis1D(
    "move", "Move",
    InputBinding.CreateAxis(
      keyboardAxes: [new KeyboardAxisBinding(Key.A, Key.D)],
      gamepadAxes: [new GamepadAxisBinding(GamepadAxis.LeftX)]),
    category: "Gameplay"));

  base.Initialize();
}

Read the action in Update:

protected override void Update(IGameTime gameTime)
{
  var bindings = GetService<IInputBindingService>();

  if (bindings.WasJustPressed("jump"))
    Jump();

  var move = bindings.GetAxisValue("move");

  base.Update(gameTime);
}

These are the queries on a registered action:

Method Button Axis
IsPressed Held now. Non-zero now.
WasJustPressed Went down this step. Not used.
WasJustReleased Came up this step. Not used.
WasJustPressedOrRepeated As WasJustPressed, and also each key repeat while held. Keyboard only. Not used.
GetAxisValue 1 when held, otherwise 0. The value, -1 to 1.

Use WasJustPressedOrRepeated for an action that should keep acting while held, such as deleting text. Movement should read IsPressed or GetAxisValue and be paced by the game, not by key repeat.

Rebinding

Changing a binding replaces what the action is bound to:

var bindings = GetService<IInputBindingService>();

bindings.SetKeyboardBinding("jump", Key.W, Key.SpaceBar);
bindings.SetGamepadBinding("jump", GamepadButton.A);
bindings.SetKeyboardAxisBinding("move", Key.LeftArrow, Key.RightArrow);
bindings.ResetBinding("jump");

SetBinding takes a whole InputBinding, and GetBinding and TryGetBinding return the current one. A button action cannot take axis bindings, and an axis action cannot take button bindings. Setting the wrong kind throws.

Registering an action again with the same id replaces its definition. A binding the player has changed is kept.

A key modifier is part of a keyboard binding. new KeyboardKeyBinding(Key.S, KeyModifiers.Ctrl) fires only while Ctrl is also held. KeyModifiers.Command is the Command key on macOS and the Windows key elsewhere. To accept both conventions, bind the key twice: once with Ctrl and once with Command.

Querying an id that has not been registered is harmless: the queries return false or 0. Changing or resetting an unregistered id throws KeyNotFoundException.

Consuming an action

Consume(actionId) marks an action as used for the rest of the step. Later readers see it as not pressed: IsPressed and WasJustPressed are false, and GetAxisValue is 0. ConsumeAll() does this for every action. Consumed actions are reset at the next step.

Input contexts

An input context gets the actions that changed this step before Update does, so it can take them for a menu, a dialog or a pause screen. Implement IInputContext:

  • Priority: higher priorities are offered each action first.
  • TryHandle(in InputActionState state, Action<string> consumeAction): return true to stop lower-priority contexts from seeing the action. Call consumeAction(state.ActionId) as well if the game's Update should not see it either.

Contexts are offered only the actions that went down or came up this step, through WasJustPressed and WasJustReleased. Held actions are not offered.

Push a context on InputContextManager (injected). PushScoped returns a scope, and disposing the scope pops the context.

public sealed class PauseInput : IInputContext
{
  public int Priority => 100;

  public bool TryHandle(in InputActionState state, Action<string> consumeAction)
  {
    if (!state.WasJustPressed || state.ActionId != "pause")
      return false;

    TogglePause();
    consumeAction(state.ActionId);
    return true;
  }
}

public sealed class PauseMenu
{
  private readonly InputContextManager _contexts;
  private InputContextScope _scope;

  public PauseMenu(InputContextManager contexts) => _contexts = contexts;

  // The context is pushed while the menu is open, and popped when it closes.
  public void Open() => _scope = _contexts.PushScoped(new PauseInput());
  public void Close() => _scope.Dispose();
}

Controls for BSGameLib.UI

UiInputActions names the actions the UI uses for navigation: NavigateNext, NavigatePrevious, Submit and Cancel. Moving focus between controls and activating the focused control both depend on them. The engine registers none of them, so the game binds them with its other actions:

var bindings = GetService<IInputBindingService>();

bindings.RegisterAction(InputActionDefinition.Button(
  UiInputActions.Submit, "Submit",
  InputBinding.CreateButton(
    keyboardKeys: [new KeyboardKeyBinding(Key.Enter), new KeyboardKeyBinding(Key.SpaceBar)],
    gamepadButtons: [new GamepadButtonBinding(GamepadButton.A)]),
  category: "UI"));

The UI acts on those four names only. It does not use NavigateLeft, NavigateRight, NavigateHorizontalAxis or NavigateVerticalAxis. See Navigation and panels.

Several players

PlayerInputManager holds one binding service per player slot. Slot 0 is the engine's own service, so single-player games need nothing here. GetBindings(index) returns a slot's service, and RegisterSlot(index, profile, bindings) adds one. A PlayerInputProfile is an id and a display name, and the engine attaches no behaviour to it.

The frame loop updates every slot's service once a step, before Update, so a slot registered in Initialize needs nothing more. A service shared by two slots is updated once. A service that is not an InputBindingService gets its Update(deltaSeconds) call from the frame loop, so the game does not call it.

protected override void Initialize()
{
  var second = new InputBindingService(new DeviceInputManager(), GetService<InputContextManager>());
  GetService<PlayerInputManager>().RegisterSlot(1, new PlayerInputProfile("Player2", "Player 2"), second);

  base.Initialize();
}

Text input

Typed text does not come from key codes. It comes from a TextInput handler, which you create from the keyboard:

var typed = Keyboard.Instance.CreateTextInputHandler();
typed.CharEntered += (sender, e) => Console.WriteLine($"Typed '{e.Character}'");
typed.IsEnabled = true;

// When the text field is no longer in use:
typed.Dispose();

Text is a StringBuilder that the handler keeps up to date with the characters typed while it is enabled. A handler only receives text while IsEnabled is true.

Text input stays on while any enabled handler exists, so turn a handler off as soon as you have finished with it. Dispose turns it off and lets it go, and is safe to call more than once. An enabled handler stays subscribed to the keyboard until it is disposed or disabled, even when nothing else refers to it, so a handler dropped while still enabled keeps text input on. A disabled handler holds nothing.

Editing keys such as Backspace, Enter and the arrows arrive as key events, not as text.

Clipboard

SdlClipboard reads and writes the system clipboard as text:

SdlClipboard.Instance.SetText("Copied from the game");
var pasted = SdlClipboard.Instance.GetText();

GetText returns an empty string when the clipboard holds no text, such as an image or a file list. Controls that need the clipboard take an IClipboard, so a test can supply its own.

Input and Dear ImGui

While Dear ImGui wants the keyboard, the game receives no keys: the bindings and IKeyboardManager see nothing, and the KeyPressed, KeyReleased and TextInput events are not raised. The keyboard still follows the physical keys underneath, so a key released during that time is not left held: it reads as up from the next step. IsKeyDown is not gated, so a key pressed while ImGui has the keyboard reads as down until it is released. While ImGui wants the mouse, the scroll wheel is withheld, but clicks and movement still reach the game. See Dear ImGui.