Input
Softfire’s input system centres on InputActions — named logical inputs that map to one or more physical bindings. Game code never polls keyboard keys or gamepad buttons directly. It queries actions by name. This means remapping, swapping devices, and supporting mobile virtual controls all work without changing your game logic.
Defining actions
Actions are declared in an InputActionMap asset, which is a JSON file in your content directory:
{ "actions": [ { "name": "Jump", "bindings": [ { "device": "Keyboard", "key": "Space" }, { "device": "Gamepad", "button": "A" }, { "device": "Touch", "virtualButton": "Jump" } ] }, { "name": "MoveHorizontal", "bindings": [ { "device": "Keyboard", "negativeKey": "A", "positiveKey": "D" }, { "device": "Keyboard", "negativeKey": "Left", "positiveKey": "Right" }, { "device": "Gamepad", "axis": "LeftStickX" } ] } ]}Load the action map in Initialize:
var actionMap = Content.Load<InputActionMap>("Input/GameActions");Services.Get<InputService>().LoadActionMap(actionMap);Querying actions in a system
using Softfire.Engine.Input;
public class PlayerMovementSystem : SystemBase{ private readonly IInputService _input;
public PlayerMovementSystem(World world, IInputService input) : base(world) { _input = input; }
public override void Update(GameTime gameTime) { float moveX = _input.GetAxis("MoveHorizontal"); bool jumpPressed = _input.WasPressed("Jump"); bool jumpHeld = _input.IsHeld("Jump");
foreach (var (entity, transform, controller) in _query) { controller.IntendedMoveX = moveX; if (jumpPressed) controller.JumpBufferTimer = controller.JumpBufferWindow; } }}WasPressed returns true for exactly one frame — the frame the action crossed the pressed threshold. IsHeld returns true for every frame the action remains active. GetAxis returns a float in [-1, 1] for analogue inputs, or -1/0/1 for digital inputs mapped to an axis.
Input remapping
The InputRemapService allows players to reassign bindings at runtime. Bindings are stored in the save slot so remapping persists across sessions.
var remap = Services.Get<InputRemapService>();
// Start listening for the next physical input to remap Jump's keyboard bindingremap.BeginRebind("Jump", deviceType: DeviceType.Keyboard, onComplete: binding =>{ Logger.Info($"Jump rebound to {binding.Key}");});
// Cancel if the player presses Escapeif (_input.WasPressed("Cancel")) remap.CancelRebind();The editor’s Input Remapper panel exposes this flow visually — useful for testing action bindings without writing UI code.
Virtual joysticks and buttons for mobile
For touch-screen targets, declare virtual controls in the InputActionMap using the Touch device. The VirtualInputRenderer system draws the on-screen controls automatically based on the action map.
{ "name": "Jump", "bindings": [ { "device": "Touch", "virtualButton": "Jump" } ]},{ "name": "MoveHorizontal", "bindings": [ { "device": "Touch", "virtualJoystick": "LeftStick", "axis": "X" } ]}Virtual control positions and sizes are configured in the VirtualInputLayout asset. The VirtualInputRenderer respects safe area insets on mobile.
Gyroscope input
Gyroscope input is available on supported platforms (mobile and DualSense / Joy-Con when connected). Bind it like any other device:
{ "name": "Tilt", "bindings": [ { "device": "Gyroscope", "axis": "Pitch", "sensitivity": 1.5, "deadzone": 0.05 } ]}Query the tilt value via _input.GetAxis("Tilt") as normal. Calibration is handled automatically on the first frame after the device reports data.
DualSense adaptive triggers
DualSense adaptive trigger effects are set directly on the DualSenseService, not through the action map:
var ds = Services.Get<DualSenseService>();
// Weapon resistance: gradual tension starting at 20% trigger travelds.SetTriggerEffect(TriggerSide.Right, new TriggerResistanceEffect{ StartPosition = 0.2f, Force = 0.7f,});
// Resetds.ClearTriggerEffect(TriggerSide.Right);Haptic feedback and LED colour are set similarly via ds.SetHaptic() and ds.SetLedColor().
Input leniency and coyote-time buffering
The input service has built-in support for two common leniency patterns:
Jump buffering — a pressed action can be “remembered” for a configurable window so that presses slightly before landing still register as jumps. Configure jumpBufferWindow (in seconds) on the PlatformerControllerComponent.
Coyote time — a jump input is accepted for a brief window after the player walks off a ledge. Configure coyoteTimeWindow on PlatformerControllerComponent. The PlatformerMovementSystem handles this automatically when the component is present.
Both are implemented as timers in the movement system — not as input-layer hacks — so they are fully visible in the inspector and easy to tune.