tecs.input

Keyboard, pointer, touch and gamepad state folded from typed platform events.

Live state survives frame boundaries. Frame edges clear before each sealed host batch, while latched edges accumulate until the first fixed step consumes them. Blocking layers move capture and clear pending edges without inventing releases for controls that remain physically held.

Gamepads reach the same fold through a different door. The keyboard, the pointer and touch arrive in the host's sealed batch, because the window system owns them; a gamepad belongs to no window, so tecs.platform.gamepadbackend is polled once a frame by the tecs.PollGamepads system install adds, and what it saw becomes the same typed platform events the host would have sent. That is why handleEvent is the only fold in this module: a synthetic gamepad event from the debug server and a real one from a controller take one path.

Buttons use positional names. "south" is the button nearest the player on every pad, where "a" is the one nearest the player on some pads and the one to its right on others.

local pad <const> = state:gamepad(1)
if pad ~= nil and pad.connected then
    local moveX <const> = pad:axis("leftX")
    if pad:buttonPressed("south") then
        print(moveX)
    end
end

Device support#

Gamepads expose positional buttons, sticks, analog triggers, hot plug, power state, and rumble. Rumble is conditional: gilrs implements force feedback on evdev and XInput but not on IOKit, so Gamepad.rumble answers false on macOS and for devices without motors.

Touch is available on the platforms where winit reports it: Windows, Linux, Android, and the web. Touch pressure comes through where the surface measures it and reads zero where it does not.

The pen is the pointer. winit declares no pen event, so a stylus arrives as the cursor moving and its buttons as mouse buttons, and Tecs reads it that way rather than inventing a device the layer below does not report. A game that draws with a stylus reads pointerX, pointerY and the mouse buttons and gets what the platform sends.

Pen pressure, tilt, and eraser state are not exposed because winit declares no pen event. Supporting them would require separate NSEvent, WM_POINTER, and XInput2 tablet handling.

Gamepad motion sensors, touchpads, trigger rumble, light bars, player indexes, hardware button labels, device families, device paths, remap events, and standalone motion sensors are not part of the input surface. A game that wants a "cross" glyph rather than a "south" name reads the pad's name and chooses its own art.

Module contents

Constructors

ConstructorDescription
newInputCreates empty input state with the readable base layer.

Types

TypeKindDescription
GamepadrecordOne attached gamepad's identity, event state and outputs.
GamepadAxistypeA gamepad axis name.
GamepadButtontypeA positional gamepad button name.
InputrecordLive, frame-edge, and fixed-latched keyboard and pointer input.
LayerrecordA position in the input capture stack.
MouseButtontypeA conventional pointer button name.
TouchrecordOne finger on a touch surface, from the most recent touch event.

Functions

FunctionKindDescription
installfunctionInstalls and returns the input state owned by one world.
offunctionReturns the input state installed in a world.

Constructors#

newInputconstructor#

function newInput(backend: gamepadbackend.Backend?): Input

Creates empty input state with the readable base layer.

The state reads no device unless it is given a source. Constructing input state by hand is what a test and a headless tool do, and neither should start the platform's device enumeration as a side effect: on macOS that is a run loop on a thread of its own, and a suite whose result changes when somebody plugs a controller in is worse than one that reads no controller at all. install is the call that reaches real hardware.

Arguments

NameTypeDescription
backendgamepadbackend.Backend?

the gamepad source, defaulting to no devices at all

Returns

TypeDescription
Input

a standalone input state

Types#

Gamepadrecord#

record Gamepad
    id: integer
    connected: boolean
    name: string
    guid: string

    buttonDown: function(borrows self: Gamepad, button: GamepadButton | integer, layer: Layer?): boolean
    buttonPressed: function(borrows self: Gamepad, button: GamepadButton | integer, layer: Layer?): boolean
    buttonReleased: function(borrows self: Gamepad, button: GamepadButton | integer, layer: Layer?): boolean
    axis: function(borrows self: Gamepad, axis: GamepadAxis | integer, deadzone: number?, layer: Layer?): number
    hasButton: function(borrows self: Gamepad, button: GamepadButton | integer): boolean
    hasAxis: function(borrows self: Gamepad, axis: GamepadAxis | integer): boolean
    power: function(borrows self: Gamepad): (gamepadbackend.PowerState, integer)
    rumble: function(borrows self: Gamepad, low: number, high: number, seconds: number): boolean
end

One attached gamepad's identity, event state and outputs.

Input owns the object. A retained reference stays safe after the device goes away: queries answer with neutral values, rumble returns false, and connected stays false. A reconnect creates a new object rather than reviving the old one.

Methods

buttonDown#
buttonDown: function(borrows self: Gamepad, button: GamepadButton | integer, layer: Layer?): boolean

Returns whether a gamepad button is held.

Arguments
NameTypeDescription
borrows selfGamepad
buttonGamepadButton | integer
layerLayer?
Returns
TypeDescription
boolean
Raises
  • when the name is not one this module declares

buttonPressed#
buttonPressed: function(borrows self: Gamepad, button: GamepadButton | integer, layer: Layer?): boolean

Returns whether a gamepad button went down in the active edge tier.

Arguments
NameTypeDescription
borrows selfGamepad
buttonGamepadButton | integer
layerLayer?
Returns
TypeDescription
boolean
Raises
  • when the name is not one this module declares

buttonReleased#
buttonReleased: function(borrows self: Gamepad, button: GamepadButton | integer, layer: Layer?): boolean

Returns whether a gamepad button came up in the active edge tier.

Arguments
NameTypeDescription
borrows selfGamepad
buttonGamepadButton | integer
layerLayer?
Returns
TypeDescription
boolean
Raises
  • when the name is not one this module declares

axis#
axis: function(borrows self: Gamepad, axis: GamepadAxis | integer, deadzone: number?, layer: Layer?): number

Returns an axis reading with the deadzone applied.

A stick runs -1 to 1 and a trigger runs zero to one. An axis no event has mentioned, and one on a device that has gone away, reads as zero.

Arguments
NameTypeDescription
borrows selfGamepad
axisGamepadAxis | integer
deadzonenumber?
layerLayer?
Returns
TypeDescription
number
Raises
  • when the name is not one this module declares

hasButton#
hasButton: function(borrows self: Gamepad, button: GamepadButton | integer): boolean

Reports whether the device has a button.

Arguments
NameTypeDescription
borrows selfGamepad
buttonGamepadButton | integer
Returns
TypeDescription
boolean
Raises
  • when the name is not one this module declares

hasAxis#
hasAxis: function(borrows self: Gamepad, axis: GamepadAxis | integer): boolean

Reports whether the device has an axis.

Arguments
NameTypeDescription
borrows selfGamepad
axisGamepadAxis | integer
Returns
TypeDescription
boolean
Raises
  • when the name is not one this module declares

power#
power: function(borrows self: Gamepad): (gamepadbackend.PowerState, integer)

Reports what the device says about its power source.

Arguments
NameTypeDescription
borrows selfGamepad
Returns
TypeDescription
gamepadbackend.PowerState
integer
rumble#
rumble: function(borrows self: Gamepad, low: number, high: number, seconds: number): boolean

Plays one rumble effect, replacing whatever the device was playing.

A zero or negative duration stops the device instead. Force feedback is not available on every platform, so a false here is an ordinary answer rather than a failure a game has to handle.

Arguments
NameTypeDescription
borrows selfGamepad
lownumber
highnumber
secondsnumber
Returns
TypeDescription
boolean

Fields

id#
id: integer

Read-only. Names the device, and matches the which field of every gamepad platform event about it.

connected#
connected: boolean

Read-only. Reports true from the moment Input opens the device until it goes away, and false permanently afterwards.

name#
name: string

Read-only. Reports the platform's display name for the device, and an empty string when it reports none. Saved bindings use guid.

guid#
guid: string

Read-only. Reports the stable identity a saved binding matches on, and an empty string when the platform reports none.

GamepadAxistype#

type GamepadAxis = "leftX" | "leftY" | "rightX" | "rightY" | "leftTrigger" | "rightTrigger"

A gamepad axis name.

Compatibility surface: these names are stable. A stick runs -1 to 1 with leftY and rightY growing downward, and a trigger runs zero to one.

GamepadButtontype#

type GamepadButton = "south"
| "east"
| "west"
| "north"
| "back"
| "guide"
| "start"
| "leftStick"
| "rightStick"
| "leftShoulder"
| "rightShoulder"
| "dpadUp"
| "dpadDown"
| "dpadLeft"
| "dpadRight"

A positional gamepad button name.

These are what gilrs reports, spelled as Tecs publishes them. They are positional rather than Xbox-only because the hardware label is not part of the input contract. Names without a gilrs counterpart are not published.

Inputrecord#

record Input
    mouseX: number
    mouseY: number
    mouseDeltaX: number
    mouseDeltaY: number
    wheelX: number
    wheelY: number
    wheelTicksX: integer
    wheelTicksY: integer
    text: string

    beginFrame: function(exclusive self: Input): nil
    enterFixedPhase: function(exclusive self: Input): nil
    exitFixedPhase: function(exclusive self: Input): nil
    handleEvent: function(self: Input, event: platformevents.Event): nil
    keyDown: function(borrows self: Input, key: string, layer: Layer?): boolean
    keyPressed: function(borrows self: Input, key: string, layer: Layer?): boolean
    keyReleased: function(borrows self: Input, key: string, layer: Layer?): boolean
    modifiers: function(borrows self: Input): integer
    mouseDown: function(borrows self: Input, button: MouseButton | integer, layer: Layer?): boolean
    mousePressed: function(borrows self: Input, button: MouseButton | integer, layer: Layer?): boolean
    mouseReleased: function(borrows self: Input, button: MouseButton | integer, layer: Layer?): boolean
    pushLayer: function(exclusive self: Input, name: string, blocking: boolean?): Layer
    popLayer: function(exclusive self: Input): Layer?
    topLayer: function(borrows self: Input): Layer
    canRead: function(borrows self: Input, layer: Layer?): boolean
    touches: function(borrows self: Input, layer: Layer?): {Touch}
    gamepads: function(borrows self: Input): {Gamepad}
    gamepad: function(borrows self: Input, index: integer?): Gamepad?
    gamepadById: function(borrows self: Input, id: integer): Gamepad?
    refreshDevices: function(self: Input): nil
    pollDevices: function(exclusive self: Input): {platformevents.Event}
    destroy: function(exclusive self: Input): nil
end

Live, frame-edge, and fixed-latched keyboard and pointer input.

Methods

beginFrame#
beginFrame: function(exclusive self: Input): nil

Starts a frame by clearing frame-local edges and accumulated deltas.

Arguments
NameTypeDescription
exclusive selfInput
Returns
TypeDescription
nil
enterFixedPhase#
enterFixedPhase: function(exclusive self: Input): nil

Selects latched edges for queries inside one fixed step.

Arguments
NameTypeDescription
exclusive selfInput
Returns
TypeDescription
nil
exitFixedPhase#
exitFixedPhase: function(exclusive self: Input): nil

Leaves fixed-edge mode and consumes the latched edges.

Arguments
NameTypeDescription
exclusive selfInput
Returns
TypeDescription
nil
handleEvent#
handleEvent: function(self: Input, event: platformevents.Event): nil

Folds one translated platform event into live and edge state.

Arguments
NameTypeDescription
selfInput
eventplatformevents.Event
Returns
TypeDescription
nil
keyDown#
keyDown: function(borrows self: Input, key: string, layer: Layer?): boolean

Returns whether a physical key is held.

Arguments
NameTypeDescription
borrows selfInput
keystring
layerLayer?
Returns
TypeDescription
boolean
keyPressed#
keyPressed: function(borrows self: Input, key: string, layer: Layer?): boolean

Returns whether a physical key went down in the active edge tier.

Arguments
NameTypeDescription
borrows selfInput
keystring
layerLayer?
Returns
TypeDescription
boolean
keyReleased#
keyReleased: function(borrows self: Input, key: string, layer: Layer?): boolean

Returns whether a physical key came up in the active edge tier.

Arguments
NameTypeDescription
borrows selfInput
keystring
layerLayer?
Returns
TypeDescription
boolean
modifiers#
modifiers: function(borrows self: Input): integer

Returns the modifier mask from the most recent key event.

Arguments
NameTypeDescription
borrows selfInput
Returns
TypeDescription
integer
mouseDown#
mouseDown: function(borrows self: Input, button: MouseButton | integer, layer: Layer?): boolean

Returns whether a pointer button is held.

Arguments
NameTypeDescription
borrows selfInput
buttonMouseButton | integer
layerLayer?
Returns
TypeDescription
boolean
mousePressed#
mousePressed: function(borrows self: Input, button: MouseButton | integer, layer: Layer?): boolean

Returns whether a pointer button went down in the active edge tier.

Arguments
NameTypeDescription
borrows selfInput
buttonMouseButton | integer
layerLayer?
Returns
TypeDescription
boolean
mouseReleased#
mouseReleased: function(borrows self: Input, button: MouseButton | integer, layer: Layer?): boolean

Returns whether a pointer button came up in the active edge tier.

Arguments
NameTypeDescription
borrows selfInput
buttonMouseButton | integer
layerLayer?
Returns
TypeDescription
boolean
pushLayer#
pushLayer: function(exclusive self: Input, name: string, blocking: boolean?): Layer

Pushes a capture layer.

Arguments
NameTypeDescription
exclusive selfInput
namestring
blockingboolean?
Returns
TypeDescription
Layer
Raises
  • when the layer name is empty

popLayer#
popLayer: function(exclusive self: Input): Layer?

Removes and returns the top layer, preserving the base layer.

Arguments
NameTypeDescription
exclusive selfInput
Returns
TypeDescription
Layer?
topLayer#
topLayer: function(borrows self: Input): Layer

Returns the top input layer.

Arguments
NameTypeDescription
borrows selfInput
Returns
TypeDescription
Layer
canRead#
canRead: function(borrows self: Input, layer: Layer?): boolean

Reports whether a layer may read through the current capture stack.

Arguments
NameTypeDescription
borrows selfInput
layerLayer?
Returns
TypeDescription
boolean
touches#
touches: function(borrows self: Input, layer: Layer?): {Touch}

Returns every finger currently on a touch surface.

The list is freshly built, and the records in it are Input's and are reused as the finger moves, so a caller retaining one sees it change and a caller wanting the position it had copies the numbers out.

Arguments
NameTypeDescription
borrows selfInput
layerLayer?
Returns
TypeDescription
{Touch}
gamepads#
gamepads: function(borrows self: Input): {Gamepad}

Returns the connected gamepads, in connection order.

Arguments
NameTypeDescription
borrows selfInput
Returns
TypeDescription
{Gamepad}
gamepad#
gamepad: function(borrows self: Input, index: integer?): Gamepad?

Returns one connected gamepad by connection order.

Arguments
NameTypeDescription
borrows selfInput
indexinteger?
Returns
TypeDescription
Gamepad?
gamepadById#
gamepadById: function(borrows self: Input, id: integer): Gamepad?

Returns one connected gamepad by device identifier.

Arguments
NameTypeDescription
borrows selfInput
idinteger
Returns
TypeDescription
Gamepad?
refreshDevices#
refreshDevices: function(self: Input): nil

Reconciles the device list against what the platform currently reports.

Engine-owned. Hot plug arrives as events, so this is the safety net for a stream that was not being drained rather than the ordinary path. It changes the device list without emitting the events an ordinary attach produces, because the world it would emit into is not this call's to reach.

Arguments
NameTypeDescription
selfInput
Returns
TypeDescription
nil
pollDevices#
pollDevices: function(exclusive self: Input): {platformevents.Event}

Reads the gamepad source and folds what it saw into input state.

Engine-owned. The tecs.PollGamepads system install adds calls this once a frame and dispatches what comes back, so ordinary game code reads gamepads instead. A caller driving input without a world calls it directly and may ignore the result.

Arguments
NameTypeDescription
exclusive selfInput
Returns
TypeDescription
{platformevents.Event}
destroy#
destroy: function(exclusive self: Input): nil

Retires every device and forgets every finger.

Engine-owned. A host tearing a world down early calls this; ordinary game code must not. tecs.application does not call it, and nothing leaks when nobody does: the state dies with its world, and the process-wide gamepad source is deliberately left open here because another world may still be reading it.

Arguments
NameTypeDescription
exclusive selfInput
Returns
TypeDescription
nil

Fields

mouseX#
mouseX: number
mouseY#
mouseY: number
mouseDeltaX#
mouseDeltaX: number
mouseDeltaY#
mouseDeltaY: number
wheelX#
wheelX: number
wheelY#
wheelY: number
wheelTicksX#
wheelTicksX: integer
wheelTicksY#
wheelTicksY: integer
text#
text: string

Layerrecord#

record Layer
    name: string
    blocking: boolean
    index: integer
end

A position in the input capture stack.

Fields

name#
name: string
blocking#
blocking: boolean
index#
index: integer

MouseButtontype#

type MouseButton = "left" | "middle" | "right" | "back" | "forward"

A conventional pointer button name.

These are winit's names. back and forward identify the two auxiliary pointer buttons.

Touchrecord#

record Touch
    device: string
    finger: string
    x: number
    y: number
    normalX: number
    normalY: number
    pressure: number
end

One finger on a touch surface, from the most recent touch event.

Input owns and reuses every field. Callers treat the record as read-only and copy values they need to retain.

Fields

device#
device: string

Read-only. Names the touch surface's opaque identity, set when the finger first appears.

finger#
finger: string

Read-only. Names the finger's opaque identity, set when it first appears.

x#
x: number

Read-only. Reports the position in logical window coordinates.

y#
y: number

Read-only. Reports the position in logical window coordinates.

normalX#
normalX: number

Read-only. Reports the position across the surface, from zero to one.

normalY#
normalY: number

Read-only. Reports the position down the surface, from zero to one.

pressure#
pressure: number

Read-only. Reports the pressure from zero to one, and zero where the surface does not measure it.

Functions#

installfunction#

function install(exclusive world: ecs.World, backend: gamepadbackend.Backend?): Input

Installs and returns the input state owned by one world.

Three systems come with it. Two consume the fixed-step latched edges, and tecs.PollGamepads runs in Ingress, ahead of everything a game schedules, so a controller read anywhere in the frame answers from this frame.

This is the call that reaches real hardware. The platform's device source is opened once per process and shared, because opening it per world would give one controller two identities on two hot-plug streams; a build with no libtecsgamepad gets the device-free source and says so once in the log.

Arguments

NameTypeDescription
exclusive worldecs.World

the world whose fixed steps consume latched edges

backendgamepadbackend.Backend?

the gamepad source, defaulting to the process-wide platform one

Returns

TypeDescription
Input

the new input state, also available through of

Raises

  • when the world already owns input state

offunction#

function of(borrows world: ecs.World): Input?

Returns the input state installed in a world.

Arguments

NameTypeDescription
borrows worldecs.World

the world to inspect

Returns

TypeDescription
Input?

its input state, or nil when none is installed