Input System

Turian's input system decouples physical devices from game logic through named actions. You define actions in a .inputactions asset — keyboard, mouse, and gamepad bindings all map to the same action name — and your scripts only ever ask "is the jump action pressed?", never "is Space held?".


Quick start

1. Create an Input Actions asset

In the Asset Browser, right-click → Create → Input Actions. This writes a default .inputactions file (ZON format) into the selected folder.

Double-click the file to open the Input Actions Editor.

2. Define your actions

The editor shows a list of actions. Each action has:

  • Name — the string your script will use (e.g. jump, move, look).
  • Kindbutton (on/off), axis (1D −1…+1), or vector (2D, x/y).
  • RolesPositive/Negative for axis; Right/Left/Up/Down for vector; Buttons for button. Each role holds up to 4 bindings.

For each binding choose a device and a code:

Device Code examples
key a, space, left_shift, left_ctrl
mouse right, left, middle
gamepad_button south, east, left_shoulder, left_trigger
gamepad_axis left_x, left_y, right_x, right_y, left_trigger, right_trigger

For gamepad_axis, the + checkbox selects the positive direction of that axis (e.g. stick-right, trigger-pressed). Uncheck it for the negative direction.

Click Save to write the ZON file and re-cook it into the project.

3. Package the asset

The game loader discovers every .inputactions asset in your project automatically — no code needed. The asset is applied to the shared engine.Input at startup.


Reading actions in a script

Scripts receive the engine.Frame context through update. frame.input is an *const engine.Input:

{% raw %}

const engine = @import("engine");

pub const PlayerController = struct {
    pub const is_component = true;

    speed: f32 = 5.0,

    pub fn update(self: *@This(), frame: engine.Frame) void {
        const input = frame.input;
        const dt    = frame.time.delta;
        const t     = frame.transform;

        // Button action — digital on/off
        if (input.wasPressed("jump")) {
            // rising edge only
        }

        // Axis action — 1D continuous value
        const throttle = input.axis("throttle"); // -1..+1

        // Vector action — 2D (e.g. WASD / left stick)
        const move = input.vector("move"); // .x = right, .y = forward
        t.position.x += move.x * self.speed * dt;
        t.position.z += move.y * self.speed * dt;
    }
};

{% endraw %}

Action query API

Method Returns Meaning
isPressed(name) bool action value ≥ threshold (held)
wasPressed(name) bool rose above threshold this frame
axis(name) f32 1D value, −1…+1, with deadzone
vector(name) engine.Vector2 2D value, clamped to unit circle

All four work the same regardless of whether the binding is a key, mouse button, or gamepad stick — the action kind just determines which query makes semantic sense.


Raw device queries

For UI or debug code that needs device-specific state, frame.input also exposes raw queries:

{% raw %}

// Keyboard
if (input.isKeyDown(.space)) { ... }
if (input.wasKeyPressed(.a)) { ... }

// Mouse
const delta = input.mouseDelta(); // .x / .y pixels this frame
const wheel = input.wheelDelta(); // scroll ticks this frame
if (input.isMouseDown(.right)) { ... }

// Gamepad
const lx = input.gamepadAxis(.left_x); // -1..+1
if (input.isGamepadButtonDown(.south)) { ... }

{% endraw %}


Example: freefly camera

The example project ships FreeflyCamera.zig, which drives a no-clip camera from both keyboard+mouse and a gamepad using the same action names:

move        — WASD  / left stick
look_axis   — right stick (analog, framerate-independent)
ascend      — Space / gamepad A / right trigger
descend     — LShift / gamepad B / left trigger
boost       — LCtrl / left shoulder
look        — hold Right Mouse to look with mouse delta

The script reads actions only — it never mentions a key code. Rebind everything in player-controls.inputactions without touching FreeflyCamera.zig.


Runtime rebinding

The engine exposes two functions for in-game "press a key to rebind" menus:

{% raw %}

// In a settings menu update:
if (frame.input.captureBinding()) |binding| {
    // User pressed something — apply it
    _ = frame.input.rebind("jump", .pos, 0, binding);
}

{% endraw %}

captureBinding() returns the first newly-pressed key, mouse button, gamepad button, or gamepad axis that crossed the press threshold. rebind replaces or appends a binding by action name, role (.pos/.neg/.up/.down), and index.

Persistence: saving remapped bindings across sessions requires the settings system (issue #13, not yet implemented). Track progress in issue #42.


engine.Frame reference

Every lifecycle hook that takes a second parameter receives engine.Frame:

{% raw %}

pub fn update(self: *@This(), frame: engine.Frame) void
pub fn start(self: *@This(),  frame: engine.Frame) void
// awake, enable, disable, destroy also accept frame

{% endraw %}

Field Type Meaning
frame.time engine.Time delta, elapsed, frame counter
frame.input *const engine.Input action queries + raw device state
frame.transform *engine.Transform this object's position/rotation/scale
frame.objects []engine.SceneNode all objects in the scene
frame.services *engine.Services user-registered service registry

Use frame.service(MyService) for a typed ?*MyService lookup into frame.services.

The legacy update(self, time: engine.Time) signature is still supported for backward compatibility.


← All docs Edit on GitLab