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.KeysDownlists the keys that went down this step.KeysPressedlists every key down this step, not only the new ones.KeysRepeatedlists 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): returntrueto stop lower-priority contexts from seeing the action. CallconsumeAction(state.ActionId)as well if the game'sUpdateshould 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.