Input Handling
Problem
You want to process player input cleanly, supporting keyboard, mouse, gamepad, and action mapping.
Solution
Input State Singleton
public sealed class InputState
{
// Keyboard
public HashSet<Key> KeysDown { get; } = new();
public HashSet<Key> KeysPressed { get; } = new(); // Just this frame
public HashSet<Key> KeysReleased { get; } = new(); // Just this frame
// Mouse
public Vector2 MousePosition { get; set; }
public Vector2 MouseDelta { get; set; }
public HashSet<MouseButton> MouseDown { get; } = new();
public HashSet<MouseButton> MousePressed { get; } = new();
public HashSet<MouseButton> MouseReleased { get; } = new();
public float ScrollDelta { get; set; }
// Gamepad
public Vector2 LeftStick { get; set; }
public Vector2 RightStick { get; set; }
public float LeftTrigger { get; set; }
public float RightTrigger { get; set; }
public HashSet<GamepadButton> ButtonsDown { get; } = new();
public HashSet<GamepadButton> ButtonsPressed { get; } = new();
public HashSet<GamepadButton> ButtonsReleased { get; } = new();
public void Clear()
{
KeysPressed.Clear();
KeysReleased.Clear();
MousePressed.Clear();
MouseReleased.Clear();
MouseDelta = Vector2.Zero;
ScrollDelta = 0;
ButtonsPressed.Clear();
ButtonsReleased.Clear();
}
}
Input Collection System
public class InputCollectionSystem : SystemBase
{
public override SystemPhase Phase => SystemPhase.EarlyUpdate;
public override int Order => -1000; // Run first
private Vector2 lastMousePosition;
public override void Update(float deltaTime)
{
var input = World.GetSingleton<InputState>();
input.Clear();
// Keyboard events (from window/platform layer)
foreach (var key in PlatformInput.GetPressedKeys())
{
if (!input.KeysDown.Contains(key))
{
input.KeysPressed.Add(key);
}
input.KeysDown.Add(key);
}
foreach (var key in PlatformInput.GetReleasedKeys())
{
input.KeysDown.Remove(key);
input.KeysReleased.Add(key);
}
// Mouse
input.MousePosition = PlatformInput.GetMousePosition();
input.MouseDelta = input.MousePosition - lastMousePosition;
lastMousePosition = input.MousePosition;
input.ScrollDelta = PlatformInput.GetScrollDelta();
// Similar for mouse buttons and gamepad...
}
}
Action Mapping
public enum GameAction
{
MoveUp,
MoveDown,
MoveLeft,
MoveRight,
Jump,
Attack,
Interact,
Pause,
Inventory
}
public sealed class InputMapping
{
private readonly Dictionary<GameAction, List<InputBinding>> bindings = new();
public void Bind(GameAction action, Key key)
{
GetOrCreateBindings(action).Add(new KeyBinding(key));
}
public void Bind(GameAction action, MouseButton button)
{
GetOrCreateBindings(action).Add(new MouseBinding(button));
}
public void Bind(GameAction action, GamepadButton button)
{
GetOrCreateBindings(action).Add(new GamepadBinding(button));
}
public bool IsPressed(GameAction action, InputState input)
{
if (!bindings.TryGetValue(action, out var actionBindings))
return false;
return actionBindings.Any(b => b.IsPressed(input));
}
public bool IsDown(GameAction action, InputState input)
{
if (!bindings.TryGetValue(action, out var actionBindings))
return false;
return actionBindings.Any(b => b.IsDown(input));
}
public bool IsReleased(GameAction action, InputState input)
{
if (!bindings.TryGetValue(action, out var actionBindings))
return false;
return actionBindings.Any(b => b.IsReleased(input));
}
private List<InputBinding> GetOrCreateBindings(GameAction action)
{
if (!bindings.TryGetValue(action, out var list))
{
list = new List<InputBinding>();
bindings[action] = list;
}
return list;
}
}
public abstract record InputBinding
{
public abstract bool IsPressed(InputState input);
public abstract bool IsDown(InputState input);
public abstract bool IsReleased(InputState input);
}
public record KeyBinding(Key Key) : InputBinding
{
public override bool IsPressed(InputState input) => input.KeysPressed.Contains(Key);
public override bool IsDown(InputState input) => input.KeysDown.Contains(Key);
public override bool IsReleased(InputState input) => input.KeysReleased.Contains(Key);
}
public record MouseBinding(MouseButton Button) : InputBinding
{
public override bool IsPressed(InputState input) => input.MousePressed.Contains(Button);
public override bool IsDown(InputState input) => input.MouseDown.Contains(Button);
public override bool IsReleased(InputState input) => input.MouseReleased.Contains(Button);
}
Player Input Component
[Component]
public partial struct PlayerInput : IComponent
{
public Vector2 MoveDirection;
public Vector2 AimDirection;
public bool JumpPressed;
public bool AttackPressed;
public bool InteractPressed;
}
Input Processing System
public class PlayerInputSystem : SystemBase
{
public override SystemPhase Phase => SystemPhase.EarlyUpdate;
public override void Update(float deltaTime)
{
var input = World.GetSingleton<InputState>();
var mapping = World.GetSingleton<InputMapping>();
foreach (var entity in World.Query<PlayerInput>().With<Player>())
{
ref var playerInput = ref World.Get<PlayerInput>(entity);
// Movement (supports both keyboard and gamepad)
playerInput.MoveDirection = Vector2.Zero;
if (mapping.IsDown(GameAction.MoveUp, input))
playerInput.MoveDirection.Y += 1;
if (mapping.IsDown(GameAction.MoveDown, input))
playerInput.MoveDirection.Y -= 1;
if (mapping.IsDown(GameAction.MoveLeft, input))
playerInput.MoveDirection.X -= 1;
if (mapping.IsDown(GameAction.MoveRight, input))
playerInput.MoveDirection.X += 1;
// Normalize diagonal movement
if (playerInput.MoveDirection.LengthSquared() > 1)
playerInput.MoveDirection = Vector2.Normalize(playerInput.MoveDirection);
// Or use gamepad stick directly if available
if (input.LeftStick.LengthSquared() > 0.1f)
playerInput.MoveDirection = input.LeftStick;
// Aim direction (mouse or right stick)
if (input.RightStick.LengthSquared() > 0.1f)
{
playerInput.AimDirection = Vector2.Normalize(input.RightStick);
}
else
{
// Aim toward mouse
ref readonly var pos = ref World.Get<Position>(entity);
var toMouse = input.MousePosition - new Vector2(pos.X, pos.Y);
if (toMouse.LengthSquared() > 1)
playerInput.AimDirection = Vector2.Normalize(toMouse);
}
// Actions (pressed this frame only)
playerInput.JumpPressed = mapping.IsPressed(GameAction.Jump, input);
playerInput.AttackPressed = mapping.IsPressed(GameAction.Attack, input);
playerInput.InteractPressed = mapping.IsPressed(GameAction.Interact, input);
}
}
}
Default Bindings
public static class DefaultInputBindings
{
public static InputMapping Create()
{
var mapping = new InputMapping();
// Keyboard (WASD)
mapping.Bind(GameAction.MoveUp, Key.W);
mapping.Bind(GameAction.MoveDown, Key.S);
mapping.Bind(GameAction.MoveLeft, Key.A);
mapping.Bind(GameAction.MoveRight, Key.D);
// Keyboard (Arrows) - alternative
mapping.Bind(GameAction.MoveUp, Key.Up);
mapping.Bind(GameAction.MoveDown, Key.Down);
mapping.Bind(GameAction.MoveLeft, Key.Left);
mapping.Bind(GameAction.MoveRight, Key.Right);
// Actions
mapping.Bind(GameAction.Jump, Key.Space);
mapping.Bind(GameAction.Attack, MouseButton.Left);
mapping.Bind(GameAction.Interact, Key.E);
mapping.Bind(GameAction.Pause, Key.Escape);
mapping.Bind(GameAction.Inventory, Key.I);
// Gamepad
mapping.Bind(GameAction.Jump, GamepadButton.A);
mapping.Bind(GameAction.Attack, GamepadButton.RightTrigger);
mapping.Bind(GameAction.Interact, GamepadButton.X);
mapping.Bind(GameAction.Pause, GamepadButton.Start);
return mapping;
}
}
Why This Works
Separation of Raw Input and Actions
- InputState: Raw hardware state (keys, buttons)
- InputMapping: Configuration (what keys do what)
- PlayerInput component: Game-meaningful intentions (move, attack)
This allows:
- Rebindable controls
- Multiple input devices
- Input playback/recording
- AI can set PlayerInput directly
Frame-Based Events
KeysPressed vs KeysDown:
Down: Is the key held? (continuous)Pressed: Was the key just pressed this frame? (one-shot)Released: Was the key just released this frame? (one-shot)
Prevents:
- Repeated jumps from holding spacebar
- Missed inputs from polling timing
Component-Based Player Input
Making player intentions a component means:
- Any system can react to input
- Input can be serialized (replays, netcode)
- AI can "fake" player input
- Multiple players supported naturally
Variations
Input Buffering
[Component]
public partial struct InputBuffer : IComponent
{
public GameAction[] BufferedActions;
public float[] BufferTimestamps;
public int BufferIndex;
public const int BufferSize = 10;
public const float BufferWindow = 0.15f; // 150ms
}
public class InputBufferSystem : SystemBase
{
public override void Update(float deltaTime)
{
var input = World.GetSingleton<InputState>();
var mapping = World.GetSingleton<InputMapping>();
float currentTime = Time.TotalTime;
foreach (var entity in World.Query<InputBuffer>().With<Player>())
{
ref var buffer = ref World.Get<InputBuffer>(entity);
// Buffer pressed actions
foreach (GameAction action in Enum.GetValues<GameAction>())
{
if (mapping.IsPressed(action, input))
{
buffer.BufferedActions[buffer.BufferIndex] = action;
buffer.BufferTimestamps[buffer.BufferIndex] = currentTime;
buffer.BufferIndex = (buffer.BufferIndex + 1) % InputBuffer.BufferSize;
}
}
}
}
public static bool ConsumeBufferedAction(ref InputBuffer buffer, GameAction action, float currentTime)
{
for (int i = 0; i < InputBuffer.BufferSize; i++)
{
if (buffer.BufferedActions[i] == action &&
currentTime - buffer.BufferTimestamps[i] < InputBuffer.BufferWindow)
{
buffer.BufferedActions[i] = default; // Consume
return true;
}
}
return false;
}
}
Combo Detection
public record ComboDefinition(GameAction[] Sequence, float MaxInterval, Action OnCombo);
public class ComboSystem : SystemBase
{
private readonly List<ComboDefinition> combos = new()
{
new([GameAction.Attack, GameAction.Attack, GameAction.Attack], 0.3f,
() => Console.WriteLine("Triple Attack!")),
new([GameAction.MoveDown, GameAction.MoveRight, GameAction.Attack], 0.5f,
() => Console.WriteLine("Hadouken!"))
};
private readonly List<GameAction> recentActions = new();
private readonly List<float> actionTimes = new();
public override void Update(float deltaTime)
{
var input = World.GetSingleton<InputState>();
var mapping = World.GetSingleton<InputMapping>();
float currentTime = Time.TotalTime;
// Record actions
foreach (GameAction action in Enum.GetValues<GameAction>())
{
if (mapping.IsPressed(action, input))
{
recentActions.Add(action);
actionTimes.Add(currentTime);
}
}
// Check combos
foreach (var combo in combos)
{
if (MatchesCombo(combo, currentTime))
{
combo.OnCombo();
recentActions.Clear();
actionTimes.Clear();
}
}
// Trim old actions
while (actionTimes.Count > 0 && currentTime - actionTimes[0] > 1f)
{
recentActions.RemoveAt(0);
actionTimes.RemoveAt(0);
}
}
private bool MatchesCombo(ComboDefinition combo, float currentTime)
{
if (recentActions.Count < combo.Sequence.Length)
return false;
int startIndex = recentActions.Count - combo.Sequence.Length;
// Check time window
if (currentTime - actionTimes[startIndex] > combo.MaxInterval * combo.Sequence.Length)
return false;
// Check sequence
for (int i = 0; i < combo.Sequence.Length; i++)
{
if (recentActions[startIndex + i] != combo.Sequence[i])
return false;
}
return true;
}
}
Context-Sensitive Input
[Component]
public partial struct InputContext : IComponent
{
public InputContextType Context;
}
public enum InputContextType
{
Gameplay,
Menu,
Dialogue,
Inventory
}
public class ContextualInputSystem : SystemBase
{
public override void Update(float deltaTime)
{
ref var context = ref World.GetSingleton<InputContext>();
var input = World.GetSingleton<InputState>();
var mapping = World.GetSingleton<InputMapping>();
switch (context.Context)
{
case InputContextType.Gameplay:
ProcessGameplayInput(input, mapping);
break;
case InputContextType.Menu:
ProcessMenuInput(input, mapping);
break;
case InputContextType.Dialogue:
ProcessDialogueInput(input, mapping);
break;
}
// Context switching
if (mapping.IsPressed(GameAction.Pause, input))
{
context.Context = context.Context == InputContextType.Gameplay
? InputContextType.Menu
: InputContextType.Gameplay;
}
}
}
Touch Input
public sealed class TouchState
{
public List<Touch> ActiveTouches { get; } = new();
public List<Touch> TouchesStarted { get; } = new();
public List<Touch> TouchesEnded { get; } = new();
}
public record struct Touch(int Id, Vector2 Position, Vector2 Delta, TouchPhase Phase);
public enum TouchPhase { Began, Moved, Stationary, Ended, Cancelled }
// Virtual joystick from touch
public class VirtualJoystickSystem : SystemBase
{
private Vector2 joystickCenter;
private bool isActive;
private const float JoystickRadius = 100f;
public override void Update(float deltaTime)
{
var touch = World.GetSingleton<TouchState>();
var input = World.GetSingleton<InputState>();
foreach (var t in touch.TouchesStarted)
{
if (t.Position.X < Screen.Width / 2) // Left side = joystick
{
joystickCenter = t.Position;
isActive = true;
}
}
if (isActive)
{
var activeTouch = touch.ActiveTouches.FirstOrDefault(t => t.Position.X < Screen.Width / 2);
if (activeTouch.Id != 0)
{
var delta = activeTouch.Position - joystickCenter;
var distance = delta.Length();
if (distance > 0)
{
var normalized = delta / MathF.Max(distance, JoystickRadius);
// Apply to input as if it were a gamepad stick
// (Or directly to PlayerInput component)
}
}
}
foreach (var t in touch.TouchesEnded)
{
if (t.Position.X < Screen.Width / 2)
{
isActive = false;
}
}
}
}
See Also
- Windowing & Input Research - Platform input handling
- State Machines - Input-driven state changes
- Timers & Cooldowns - Input rate limiting