Input
The KeenEyes input system provides backend-agnostic input handling with support for keyboard, mouse, and gamepad devices.
Architecture Overview
The input system follows the same abstraction pattern as graphics:
| Package | Purpose |
|---|---|
KeenEyes.Input.Abstractions |
Backend-agnostic interfaces and types |
KeenEyes.Input |
Backend-agnostic systems (InputCaptureSystem) |
KeenEyes.Input.Silk |
Silk.NET implementation |
This separation allows:
- Swapping input backends without changing game code
- Writing backend-agnostic input systems
- Testing input logic without hardware dependencies
Quick Start
using KeenEyes;
using KeenEyes.Input;
using KeenEyes.Input.Abstractions;
using KeenEyes.Input.Silk;
using KeenEyes.Graphics.Silk;
using KeenEyes.Platform.Silk;
using KeenEyes.Runtime;
var windowConfig = new WindowConfig { Title = "My Game" };
var graphicsConfig = new SilkGraphicsConfig();
var inputConfig = new SilkInputConfig { GamepadDeadzone = 0.15f };
using var world = new World();
// Install plugins (window first, then graphics/input in any order)
world.InstallPlugin(new SilkWindowPlugin(windowConfig));
world.InstallPlugin(new SilkGraphicsPlugin(graphicsConfig));
world.InstallPlugin(new SilkInputPlugin(inputConfig));
// Add systems
world.AddSystem<CameraSystem>(SystemPhase.EarlyUpdate);
world.AddSystem<RenderSystem>(SystemPhase.Render);
world.CreateRunner()
.OnReady(() =>
{
var input = world.GetExtension<IInputContext>();
// Event-based input
input.OnKeyDown += (kb, args) =>
{
if (args.Key == Key.Escape)
Console.WriteLine("Escape pressed!");
};
})
.Run();
IInputContext
The main entry point for all input handling. Access it via world.GetExtension<IInputContext>().
Device Access
var input = world.GetExtension<IInputContext>();
// Primary devices (most common use case)
IKeyboard keyboard = input.Keyboard;
IMouse mouse = input.Mouse;
IGamepad gamepad = input.Gamepad;
// All connected devices (multi-device support)
ImmutableArray<IKeyboard> keyboards = input.Keyboards;
ImmutableArray<IMouse> mice = input.Mice;
ImmutableArray<IGamepad> gamepads = input.Gamepads;
// Gamepad connection count
int connectedGamepads = input.ConnectedGamepadCount;
Global Events
Events that fire for any device:
// Keyboard
input.OnKeyDown += (keyboard, args) => { };
input.OnKeyUp += (keyboard, args) => { };
input.OnTextInput += (keyboard, character) => { };
// Mouse
input.OnMouseButtonDown += (mouse, args) => { };
input.OnMouseButtonUp += (mouse, args) => { };
input.OnMouseMove += (mouse, args) => { };
input.OnMouseScroll += (mouse, args) => { };
// Gamepad
input.OnGamepadButtonDown += (gamepad, args) => { };
input.OnGamepadButtonUp += (gamepad, args) => { };
input.OnGamepadConnected += (gamepad) => { };
input.OnGamepadDisconnected += (gamepad) => { };
Keyboard
Polling
Check key state each frame (good for continuous input like movement):
var keyboard = input.Keyboard;
// Check specific key
if (keyboard.IsKeyDown(Key.W))
MoveForward(deltaTime);
if (keyboard.IsKeyUp(Key.Space))
StopJumping();
// Check modifiers
KeyModifiers mods = keyboard.Modifiers;
if ((mods & KeyModifiers.Shift) != 0)
Sprint();
// Get full state snapshot
KeyboardState state = keyboard.GetState();
foreach (var key in state.PressedKeys)
Console.WriteLine($"{key} is pressed");
Events
Respond to discrete key presses (good for actions like jumping, menu selection):
keyboard.OnKeyDown += (args) =>
{
Console.WriteLine($"Key pressed: {args.Key}");
Console.WriteLine($"Is repeat: {args.IsRepeat}");
Console.WriteLine($"Shift held: {args.IsShiftDown}");
};
keyboard.OnKeyUp += (args) =>
{
Console.WriteLine($"Key released: {args.Key}");
};
// Text input (for text fields, chat)
keyboard.OnTextInput += (character) =>
{
textBuffer.Append(character);
};
Key Modifiers
// Via keyboard
if (keyboard.Modifiers.HasFlag(KeyModifiers.Control))
HandleCtrl();
// Via event args
keyboard.OnKeyDown += (args) =>
{
if (args.IsControlDown && args.Key == Key.S)
SaveGame();
};
Mouse
Polling
var mouse = input.Mouse;
// Position
Vector2 position = mouse.Position;
float x = position.X;
float y = position.Y;
// Button state
if (mouse.IsButtonDown(MouseButton.Left))
Fire();
if (mouse.IsButtonUp(MouseButton.Right))
StopAiming();
// Full state snapshot
MouseState state = mouse.GetState();
Vector2 scrollDelta = state.ScrollDelta;
Events
mouse.OnButtonDown += (args) =>
{
Console.WriteLine($"Button: {args.Button} at {args.Position}");
};
mouse.OnMove += (args) =>
{
Console.WriteLine($"Position: {args.Position}, Delta: {args.Delta}");
RotateCamera(args.DeltaX, args.DeltaY);
};
mouse.OnScroll += (args) =>
{
ZoomCamera(args.DeltaY);
};
Cursor Control
// Visibility
mouse.IsCursorVisible = false; // Hide cursor
// Capture (lock to window, raw input)
mouse.IsCursorCaptured = true; // For FPS-style camera
// Position
mouse.SetPosition(new Vector2(400, 300)); // Center cursor
Gamepad
Connection Status
var gamepad = input.Gamepad;
if (!gamepad.IsConnected)
{
Console.WriteLine("No gamepad connected");
return;
}
Console.WriteLine($"Gamepad: {gamepad.Name} (index {gamepad.Index})");
Polling
// Buttons
if (gamepad.IsButtonDown(GamepadButton.South)) // A on Xbox
Jump();
if (gamepad.IsButtonDown(GamepadButton.RightShoulder))
Aim();
// Analog sticks (with deadzone applied)
Vector2 leftStick = gamepad.LeftStick;
Vector2 rightStick = gamepad.RightStick;
MovePlayer(leftStick.X, leftStick.Y);
RotateCamera(rightStick.X, rightStick.Y);
// Triggers (0.0 to 1.0)
float leftTrigger = gamepad.LeftTrigger;
float rightTrigger = gamepad.RightTrigger;
Brake(leftTrigger);
Accelerate(rightTrigger);
// Generic axis access
float axis = gamepad.GetAxis(GamepadAxis.LeftStickX);
Events
gamepad.OnButtonDown += (args) =>
{
if (args.Button == GamepadButton.Start)
PauseGame();
};
gamepad.OnAxisChanged += (args) =>
{
Console.WriteLine($"Axis {args.Axis}: {args.Value} (delta: {args.Delta})");
};
gamepad.OnConnected += () => Console.WriteLine("Gamepad connected!");
gamepad.OnDisconnected += () => Console.WriteLine("Gamepad disconnected!");
Vibration
// Set vibration (0.0 to 1.0 for each motor)
gamepad.SetVibration(leftMotor: 0.5f, rightMotor: 0.8f);
// Stop vibration
gamepad.StopVibration();
Configuration
var config = new SilkInputConfig
{
EnableGamepads = true, // Enable gamepad support
MaxGamepads = 4, // Maximum gamepad slots
GamepadDeadzone = 0.15f, // Analog stick deadzone
CaptureMouseOnClick = false // Auto-capture mouse on click
};
world.InstallPlugin(new SilkInputPlugin(config));
Input in Systems
For game logic, use polling-based input in systems:
public class PlayerMovementSystem : ISystem
{
private IWorld? world;
private IInputContext? input;
public bool Enabled { get; set; } = true;
public void Initialize(IWorld world)
{
this.world = world;
}
public void Update(float deltaTime)
{
// Lazy initialization
input ??= world?.TryGetExtension<IInputContext>(out var ctx) == true ? ctx : null;
if (input is null) return;
var keyboard = input.Keyboard;
var gamepad = input.Gamepad;
// Calculate movement from keyboard
var moveDir = Vector2.Zero;
if (keyboard.IsKeyDown(Key.W)) moveDir.Y -= 1;
if (keyboard.IsKeyDown(Key.S)) moveDir.Y += 1;
if (keyboard.IsKeyDown(Key.A)) moveDir.X -= 1;
if (keyboard.IsKeyDown(Key.D)) moveDir.X += 1;
// Or from gamepad
if (gamepad.IsConnected)
moveDir = gamepad.LeftStick;
// Apply to player entities
foreach (var entity in world!.Query<Transform3D, PlayerTag>())
{
ref var transform = ref world.Get<Transform3D>(entity);
transform.Position += new Vector3(moveDir.X, 0, moveDir.Y) * deltaTime * 5f;
}
}
public void Dispose() { }
}
Polling vs Events
| Use Case | Pattern | Example |
|---|---|---|
| Continuous input | Polling | WASD movement, camera rotation |
| Discrete actions | Events | Jump, attack, menu selection |
| Text input | Events | Chat messages, text fields |
| Connection status | Events | Gamepad connect/disconnect |
Rule of thumb: If you check it every frame, use polling. If it's a one-time action, use events.
Event Handler Patterns
Registering Global Handlers
// In OnReady callback
world.CreateRunner()
.OnReady(() =>
{
var input = world.GetExtension<IInputContext>();
// Global key handler
input.OnKeyDown += (kb, args) =>
{
if (args.Key == Key.F1) ShowHelp();
if (args.Key == Key.F11) ToggleFullscreen();
};
// Global mouse handler
input.OnMouseButtonDown += (mouse, args) =>
{
if (args.Button == MouseButton.Middle)
StartPanning();
};
})
.Run();
Device-Specific Handlers
// Per-keyboard handler (multi-keyboard setups)
foreach (var keyboard in input.Keyboards)
{
keyboard.OnKeyDown += (args) =>
{
Console.WriteLine($"Keyboard {keyboard.Index}: {args.Key}");
};
}
// Per-gamepad handler (local multiplayer)
foreach (var gamepad in input.Gamepads)
{
gamepad.OnButtonDown += (args) =>
{
HandlePlayerInput(gamepad.Index, args.Button);
};
}
Cleanup Pattern
When using event handlers, store references for proper cleanup:
private Action<IKeyboard, KeyEventArgs>? keyHandler;
public void Initialize()
{
var input = world.GetExtension<IInputContext>();
keyHandler = (kb, args) => HandleKey(args);
input.OnKeyDown += keyHandler;
}
public void Cleanup()
{
var input = world.GetExtension<IInputContext>();
if (keyHandler != null)
input.OnKeyDown -= keyHandler;
}
Action Mapping
Action mapping provides an abstraction layer between game logic and physical inputs. Instead of checking specific keys or buttons, you define named actions that can be bound to multiple input sources.
Why Use Action Mapping?
- Multiple bindings per action - Same action works with keyboard AND gamepad
- Rebindable controls - Let players customize their bindings at runtime
- Context switching - Easily enable/disable groups of actions (gameplay vs menu)
- Cleaner code - Check
jump.IsPressed(input)instead of checking each key/button
Basic Usage
// Define an action with multiple bindings
var jump = new InputAction("Jump",
InputBinding.FromKey(Key.Space),
InputBinding.FromGamepadButton(GamepadButton.South));
// In your update loop
if (jump.IsPressed(input))
player.Jump();
InputBinding
An InputBinding represents a single input source:
// Keyboard key
var spaceBinding = InputBinding.FromKey(Key.Space);
// Mouse button
var clickBinding = InputBinding.FromMouseButton(MouseButton.Left);
// Gamepad button
var buttonBinding = InputBinding.FromGamepadButton(GamepadButton.South);
// Gamepad axis (for axis-as-button, e.g., trigger as "accelerate")
var triggerBinding = InputBinding.FromGamepadAxis(GamepadAxis.RightTrigger, threshold: 0.5f);
// Gamepad axis (negative direction)
var leftStickLeft = InputBinding.FromGamepadAxis(GamepadAxis.LeftStickX, threshold: 0.3f, isPositive: false);
InputAction
An InputAction groups bindings together and provides state queries:
// Create with multiple bindings
var fire = new InputAction("Fire",
InputBinding.FromMouseButton(MouseButton.Left),
InputBinding.FromGamepadButton(GamepadButton.RightShoulder));
// State queries
bool pressed = fire.IsPressed(input); // Any binding active
bool released = fire.IsReleased(input); // No binding active
float value = fire.GetValue(input); // Analog value (1.0 if digital)
// Disable/enable
fire.Enabled = false;
// Rebind at runtime
fire.ClearBindings();
fire.AddBinding(InputBinding.FromKey(Key.F));
InputActionMap
Group related actions into maps for context switching:
// Create action maps for different contexts
var gameplayMap = new InputActionMap("Gameplay");
gameplayMap.AddAction("Jump", InputBinding.FromKey(Key.Space));
gameplayMap.AddAction("Fire", InputBinding.FromMouseButton(MouseButton.Left));
gameplayMap.AddAction("Interact", InputBinding.FromKey(Key.E));
var menuMap = new InputActionMap("Menu");
menuMap.AddAction("Select", InputBinding.FromKey(Key.Enter));
menuMap.AddAction("Back", InputBinding.FromKey(Key.Escape));
// Access actions by name
var jump = gameplayMap.GetAction("Jump");
// Switch contexts
gameplayMap.Enabled = false; // Disables all gameplay actions
menuMap.Enabled = true; // Enables all menu actions
ActionMapProvider
For managing multiple action maps:
var provider = new ActionMapProvider();
// Register maps
provider.AddActionMap(gameplayMap);
provider.AddActionMap(menuMap);
// Switch active context
provider.SetActiveMap("Menu"); // Enables Menu, disables others
// Access
var activeMap = provider.ActiveMap;
var menuActions = provider.GetActionMap("Menu");
Multiplayer (Per-Player Input)
Bind actions to specific gamepads for local multiplayer:
// Player 1's actions bound to gamepad index 0
var player1Jump = new InputAction("Jump",
InputBinding.FromGamepadButton(GamepadButton.South))
{
GamepadIndex = 0 // Only check gamepad 0
};
// Player 2's actions bound to gamepad index 1
var player2Jump = new InputAction("Jump",
InputBinding.FromGamepadButton(GamepadButton.South))
{
GamepadIndex = 1 // Only check gamepad 1
};
In ECS Systems
public class PlayerMovementSystem : SystemBase
{
// Define actions (can also be passed in via constructor)
private static readonly InputAction jumpAction = new("Jump",
InputBinding.FromKey(Key.Space),
InputBinding.FromGamepadButton(GamepadButton.South));
private IInputContext? input;
public override void Update(float deltaTime)
{
input ??= World.TryGetExtension<IInputContext>(out var ctx) ? ctx : null;
if (input is null) return;
// Clean, readable input checks
if (jumpAction.IsPressed(input))
{
// Apply jump to player entities
}
}
}
Button Naming
Gamepad buttons use standardized names based on position:
| KeenEyes | Xbox | PlayStation | Nintendo |
|---|---|---|---|
| South | A | Cross | B |
| East | B | Circle | A |
| West | X | Square | Y |
| North | Y | Triangle | X |
| LeftShoulder | LB | L1 | L |
| RightShoulder | RB | R1 | R |
| LeftTrigger | LT | L2 | ZL |
| RightTrigger | RT | R2 | ZR |
Plugin Architecture
All platform plugins share the window through ISilkWindowProvider from KeenEyes.Platform.Silk:
SilkWindowPlugin (must be installed first)
├── Creates native window
├── Provides ISilkWindowProvider (shared window access)
├── Provides ILoopProvider (main loop)
└── Creates input context
SilkGraphicsPlugin SilkInputPlugin
├── Uses ISilkWindowProvider ├── Uses ISilkWindowProvider
├── Creates OpenGL context └── Wraps input context
└── Provides IGraphicsContext └── Provides IInputContext
Required installation order:
// Window plugin MUST be installed first
var windowConfig = new WindowConfig
{
Title = "My Game",
Width = 1920,
Height = 1080,
VSync = true
};
world.InstallPlugin(new SilkWindowPlugin(windowConfig));
// Then install graphics and/or input (any order after window)
world.InstallPlugin(new SilkGraphicsPlugin(graphicsConfig));
world.InstallPlugin(new SilkInputPlugin(inputConfig));
This architecture means:
SilkWindowPluginowns the window and main loop- Both
SilkGraphicsPluginandSilkInputPluginrequireSilkWindowPlugin - Graphics and input can be installed in any order after window
- All plugins share the same native window and input context
Input + UI Integration
When using the KeenEyes UI system, input handling requires coordination between raw input and UI focus.
UI Focus and Input Context
var input = world.GetExtension<IInputContext>();
var ui = world.TryGetExtension<UIContext>(out var ctx) ? ctx : null;
// Check if UI has focus before processing gameplay input
if (ui?.HasFocus != true)
{
// Process gameplay input normally
ProcessGameplayInput(input);
}
else
{
// UI has focus - skip gameplay input
// UI systems handle input automatically
}
Input Blocking Patterns
// Option 1: Let UI consume input first
public class GameInputSystem : ISystem
{
private IWorld? world;
private IInputContext? input;
private UIContext? ui;
public bool Enabled { get; set; } = true;
public void Initialize(IWorld world) => this.world = world;
public void Update(float dt)
{
input ??= world?.TryGetExtension<IInputContext>(out var i) == true ? i : null;
ui ??= world?.TryGetExtension<UIContext>(out var u) == true ? u : null;
// Skip if UI is capturing input
if (ui?.HasFocus == true) return;
// Process gameplay input
var keyboard = input?.Keyboard;
// ...
}
public void Dispose() { }
}
Context-Sensitive Input
public class ContextualInputSystem : ISystem
{
public void Update(float dt)
{
var input = world.GetExtension<IInputContext>();
var ui = world.TryGetExtension<UIContext>(out var ctx) ? ctx : null;
// Escape always available for pause menu
if (input.Keyboard.IsKeyDown(Key.Escape))
{
TogglePause();
return;
}
// UI has priority for focused elements
if (ui?.HasFocus == true)
{
// Let UI handle input
return;
}
// Process gameplay input
HandleMovement(input);
HandleActions(input);
}
}
Mouse Position to UI Coordinates
Mouse position from the input system is in screen coordinates, which matches the UI coordinate system:
var mousePos = input.Mouse.Position; // Screen coordinates (0,0 = top-left)
// UI elements use the same coordinate system
// ComputedBounds are in screen space
foreach (var entity in world.Query<UIRect, UIInteractable>())
{
ref readonly var rect = ref world.Get<UIRect>(entity);
if (rect.ComputedBounds.Contains(mousePos))
{
// Mouse is over this element
}
}
Advanced Multimodal Input
Unified Input Abstraction
When supporting keyboard, mouse, AND gamepad simultaneously:
public class UnifiedInputSystem : ISystem
{
public void Update(float dt)
{
var input = World.GetExtension<IInputContext>();
var kb = input.Keyboard;
var mouse = input.Mouse;
var gamepad = input.Gamepad;
// Movement: keyboard OR gamepad
var moveDir = Vector2.Zero;
// Keyboard WASD
if (kb.IsKeyDown(Key.W)) moveDir.Y -= 1;
if (kb.IsKeyDown(Key.S)) moveDir.Y += 1;
if (kb.IsKeyDown(Key.A)) moveDir.X -= 1;
if (kb.IsKeyDown(Key.D)) moveDir.X += 1;
// Gamepad overrides if connected and active
if (gamepad.IsConnected)
{
var stick = gamepad.LeftStick;
if (stick.LengthSquared() > 0.01f)
moveDir = stick;
}
// Normalize if needed
if (moveDir.LengthSquared() > 1)
moveDir = Vector2.Normalize(moveDir);
// Aim: mouse OR right stick
var aimDir = Vector2.Zero;
if (gamepad.IsConnected &&
gamepad.RightStick.LengthSquared() > 0.01f)
{
aimDir = Vector2.Normalize(gamepad.RightStick);
}
else
{
// Aim toward mouse from player position
var playerPos = GetPlayerScreenPosition();
var toMouse = mouse.Position - playerPos;
if (toMouse.LengthSquared() > 1)
aimDir = Vector2.Normalize(toMouse);
}
// Apply to player
ApplyMovement(moveDir, aimDir);
}
}
Input Device Detection
Track which input device was used most recently:
public enum ActiveInputDevice { Keyboard, Mouse, Gamepad }
public class InputDeviceTracker : ISystem
{
private ActiveInputDevice lastDevice = ActiveInputDevice.Keyboard;
private Vector2 lastMousePos;
public ActiveInputDevice ActiveDevice => lastDevice;
public void Update(float dt)
{
var input = World.GetExtension<IInputContext>();
// Check for keyboard activity
if (input.Keyboard.GetState().PressedKeys.Length > 0)
lastDevice = ActiveInputDevice.Keyboard;
// Check for mouse movement or clicks
if (input.Mouse.Position != lastMousePos ||
input.Mouse.IsButtonDown(MouseButton.Left))
{
lastDevice = ActiveInputDevice.Mouse;
lastMousePos = input.Mouse.Position;
}
// Check for gamepad activity
if (input.Gamepad.IsConnected)
{
var gp = input.Gamepad;
if (gp.LeftStick.LengthSquared() > 0.1f ||
gp.RightStick.LengthSquared() > 0.1f ||
gp.IsButtonDown(GamepadButton.South))
{
lastDevice = ActiveInputDevice.Gamepad;
}
}
}
}
Cursor Visibility Based on Device
Show/hide cursor based on active input device:
// In your input system
if (deviceTracker.ActiveDevice == ActiveInputDevice.Gamepad)
{
input.Mouse.IsCursorVisible = false;
}
else
{
input.Mouse.IsCursorVisible = true;
}
UI Prompts Based on Device
Display controller-appropriate button prompts:
public string GetPrompt(string action)
{
return deviceTracker.ActiveDevice switch
{
ActiveInputDevice.Gamepad => action switch
{
"Jump" => "[A]",
"Attack" => "[X]",
"Interact" => "[Y]",
_ => "[?]"
},
_ => action switch
{
"Jump" => "[Space]",
"Attack" => "[Left Click]",
"Interact" => "[E]",
_ => "[?]"
}
};
}
Dependencies
- KeenEyes.Input.Abstractions - Backend-agnostic interfaces
- KeenEyes.Input - Backend-agnostic systems
- KeenEyes.Input.Silk - Silk.NET implementation
- KeenEyes.Platform.Silk - Shared window provider