Input System

SimVX provides a unified input system for keyboard, mouse, gamepad, and touch. All bindings use typed enums: no raw strings.

Input Actions

Actions are named bindings that decouple game logic from specific keys:

from simvx.core import InputMap, Key, MouseButton, JoyButton, Input

# Bind actions to keys (accepts Key, MouseButton, or JoyButton)
InputMap.add_action("jump", [Key.SPACE, JoyButton.A])
InputMap.add_action("shoot", [MouseButton.LEFT])
InputMap.add_action("move_left", [Key.A, Key.LEFT])
InputMap.add_action("move_right", [Key.D, Key.RIGHT])

# Query in process()
class Player(Node):
    def on_update(self, dt):
        if Input.is_action_just_pressed("jump"):
            self.jump()
        if Input.is_action_pressed("shoot"):
            self.fire()

Action Queries

Method

Returns

Description

Input.is_action_pressed(name)

bool

True while any bound key is held

Input.is_action_just_pressed(name)

bool

True on the frame the action activates

Input.is_action_just_released(name)

bool

True on the frame the action deactivates

Input.get_action_strength(name)

float

0.0–1.0 (1.0 when pressed for digital keys, analog for axes)

Input.get_strength(name)

float

Alias for get_action_strength

Vector Input

For directional movement, get_vector() and get_axis() combine multiple actions into a single value:

# Returns a normalised Vec2 from four directional actions
direction = Input.get_vector("move_left", "move_right", "move_up", "move_down")
self.position += direction * speed * dt

# Returns a float from two opposing actions (-1 to 1)
horizontal = Input.get_axis("move_left", "move_right")

InputMap

InputMap manages action-to-binding mappings. It is a class-level singleton.

Method

Description

add_action(name, bindings)

Register an action with a list of key/button bindings

remove_action(name)

Remove an action and all its bindings

add_binding(name, binding)

Add a binding to an existing action

remove_binding(name, binding)

Remove a specific binding from an action

has_action(name)

Check if an action exists

actions (property)

List all registered action names

get_bindings(name)

Get all bindings for an action

clear()

Remove all actions

InputBinding

For advanced bindings (gamepad axes, deadzones), use InputBinding directly:

from simvx.core import InputBinding, JoyAxis

InputMap.add_action("move_left", [
    Key.A,
    Key.LEFT,
    InputBinding(joy_axis=JoyAxis.LEFT_X, joy_axis_positive=False, deadzone=0.2),
])

A binding can require a modifier, written either as a combo string or with the ctrl / shift / alt flags:

InputMap.add_action("dedent", ["shift+tab"])
InputMap.add_action("dedent", [InputBinding(key=Key.TAB, shift=True)])  # the same thing

Two layers then read modifiers, by opposite rules, and both must agree before your code runs:

  • The binding, which decides whether the action matches at all. Modifiers here are required, never exclusive: a binding that names none matches whatever else is held, so Input.is_action_pressed("jump") on a Space binding stays true while the player holds Shift to run.

  • The @on_input filter, which narrows an event the action has already matched. An unlisted modifier must be absent here, so a handler declared bare sees only the unmodified press.

The consequence to remember: a handler for a combo-bound action must open the modifier its binding requires, or it never fires. Bare @on_input(action="dedent") is silently dead against a shift+tab binding, because the binding demands Shift and the default filter forbids it. Pass None for that modifier:

@on_input(action="dedent", shift=None)     # the binding already decided Shift
def dedent(self, event): ...

The same holds for the Shift-to-run case: @on_input(action="jump") does not fire while Shift is held, @on_input(action="jump", shift=None) does, and polling with Input.is_action_pressed("jump") is unaffected because it sees only the binding.

A project file carries either spelling, and simvx.toml is what the Input Map dialog and save_project write:

[input]
dedent = ["shift+tab"]                # combo string
redo = [{key = "y", ctrl = true}]     # or the flags, which is what a save writes

UI navigation actions

The keys that move keyboard focus in the UI are ordinary actions, so they rebind like any other:

Action

Default

What the UI does with it

ui_focus_next

tab

Focus the next control in the tab order

ui_focus_prev

shift+tab

Focus the previous one

ui_accept

enter

Activate the focus owner: a Button presses, a CheckBox toggles, a RadioButton selects

ui_cancel

escape

Dismiss the overlay chain that holds the keyboard

Nothing is written into the InputMap for these: the defaults are a fallback the router consults for any of the four that is not registered. So they survive the InputMap.clear() that project settings perform on launch, and a project that binds none of them still navigates. Register one and it answers for itself:

InputMap.add_action("ui_focus_next", ["ctrl+tab"])   # Tab is now free for the editor
InputMap.add_action("ui_focus_next")                 # no bindings: nothing traverses forward

Matching is exact, which is how Tab is told from Shift+Tab, and only key bindings apply – the router routes keys, so a gamepad binding on one of these four does nothing. The other half of the story is the widget: a focused control that sets event.handled keeps the key, which is how CodeTextEdit keeps Tab for indent. See UI for the focus model itself.

Direct Key/Mouse Queries

For non-action-based input (debug tools, editor code):

# Keyboard
Input.is_key_pressed(Key.ESCAPE)
Input.is_key_just_pressed(Key.F3)

# Mouse
Input.is_mouse_button_pressed(MouseButton.LEFT)        # held this frame
Input.is_mouse_button_just_pressed(MouseButton.LEFT)   # rising edge (single click)
Input.is_mouse_button_just_released(MouseButton.LEFT)  # falling edge
pos = Input.mouse_position   # Vec2
delta = Input.mouse_delta     # Vec2 (frame-to-frame movement)
scroll_x, scroll_y = Input.scroll_delta

Mouse Capture

Control cursor visibility and confinement:

from simvx.core import MouseCaptureMode

Input.set_mouse_capture_mode(MouseCaptureMode.CAPTURED)  # FPS-style
Input.set_mouse_capture_mode(MouseCaptureMode.VISIBLE)   # Normal

Mode

Description

VISIBLE

Normal cursor

HIDDEN

Cursor hidden but moves freely

CAPTURED

Cursor hidden and locked to window (FPS cameras)

CONFINED

Cursor visible but confined to window

Gamepad

from simvx.core import JoyButton, JoyAxis

# Digital buttons
if Input.is_gamepad_pressed(button=JoyButton.A):
    self.jump()

# Analog sticks (returns Vec2)
stick = Input.get_gamepad_vector(stick="left")
self.velocity = Vec3(stick.x, 0, stick.y) * speed

# Raw axis value: a trigger is 0.0 released, 1.0 fully pulled
trigger = Input.get_gamepad_axis(axis=JoyAxis.RIGHT_TRIGGER)

# Which pads are readable right now; empty once the last one is unplugged
pads = Input.get_connected_gamepads()

Axis ranges

Every window backend maps its own library’s ranges onto one convention, so the same physical control reads the same number everywhere:

Axis

Range

At rest

left_x, left_y, right_x, right_y

-1.0 to 1.0, +y down the screen

0.0

lt, rt

0.0 to 1.0

0.0

A trigger is therefore bound with joy_axis_positive=True; its released position is the bottom of its travel, not the middle.

Connection

get_connected_gamepads() returns the ids readable this frame, in ascending order. A pad enters the list on the first poll that reports it and leaves on the first poll that does not, and its held buttons release through the ordinary just-released edge when it goes, so an action bound to a pad that was unplugged mid-press stops reading pressed. A backend with no gamepad support (Qt) reports nothing and prunes nothing.

Enums

Enum

Examples

Key

SPACE, ESCAPE, ENTER, TAB, AZ, F1F12, UP/DOWN/LEFT/RIGHT

MouseButton

LEFT, RIGHT, MIDDLE, BUTTON_4, BUTTON_5

JoyButton

A, B, X, Y, LEFT_BUMPER, RIGHT_BUMPER, START, BACK, DPAD_UP/DOWN/LEFT/RIGHT

JoyAxis

LEFT_X, LEFT_Y, RIGHT_X, RIGHT_Y, LEFT_TRIGGER, RIGHT_TRIGGER

Touch gestures

GestureRecognizer is a node that turns raw touch points into gestures and emits one signal per kind (tap, long_press, swipe, pinch, rotate, pan). Thresholds are properties: tap_timeout, long_press_timeout, tap_max_distance, swipe_min_velocity.

gestures = root.add_child(GestureRecognizer())
gestures.tap.connect(lambda x, y: print("tap", x, y))
gestures.swipe.connect(lambda direction: print("swipe", direction))

Timings are measured against the scene clock (tree.now), so slow motion stretches a long press, a paused scene never completes one, and the same fixed-step input script is recognised identically every run. A recogniser driving UI that must keep working while the game is paused (a pause menu’s tap-and-hold) opts out:

gestures = GestureRecognizer(clock="wall")     # wall time, ignores pause
gestures.update_mode = UpdateMode.ALWAYS       # and it has to keep ticking

Input.set_touch_emulation(True) maps the left mouse button to finger 0, so every single-finger gesture is reachable on desktop. See examples/features/ui/gestures.py.

Input Events

For UI widgets and event-driven handling, SimVX provides event objects:

  • InputEventKey: key, pressed, echo, shift, ctrl, alt, handled

  • InputEventMouse: button, pressed, position, shift, ctrl, alt, handled

Set event.handled = True to consume an event and prevent further propagation. UI widgets receive input first: if a focused widget consumes the event, game nodes don’t see it.

API Reference

See simvx.core.input for the complete input API.