Patterns

Cross-cutting idioms that don’t belong to one class. Each entry shows the canonical API for a pattern Godot and other engine users will search for.

Autoloads: persistent singletons

An autoload is a node registered on the SceneTree itself. It lives outside the scene root, so change_scene() leaves it untouched: perfect for global state (score, settings, audio manager).

class GameState(Node):
    lives = Property(3)
    score = Property(0)

# Register once, at app start
app.tree.add_autoload("GameState", GameState())

# Reach it from anywhere in the scene
gs = self.tree.autoloads["GameState"]
gs.score += 100

SceneTree.add_autoload(name, node) calls _enter_tree() and on_ready() immediately. Remove with remove_autoload(name). Autoloads preserve their groups and unique-name registrations across change_scene.

Scene transitions

Swap the active root to move between screens. Autoloads persist; scene-local groups and unique nodes are rebuilt for the new tree.

class TitleScreen(Node):
    def on_ready(self):
        self["PlayButton"].pressed.connect(self._on_play)

    def _on_play(self):
        self.tree.change_scene(GameScene())

class GameScene(Node):
    def on_ready(self):
        # ... gameplay setup ...
        self.player.died.connect(self._on_death)

    def _on_death(self):
        self.tree.change_scene(GameOverScreen(score=self.score))

change_scene(new_root) runs _exit_tree() on the old root and _ready_recursive() on the new one: exactly the same path as the initial root. Works from any node via self.tree.

Signals: decoupled events

Declare as class attributes; connect and emit on instances:

class Enemy(Node):
    died = Signal()                 # no args
    damaged = Signal(int)           # typed: emits one int
    hit_by = Signal(int, str)       # typed: (amount, source)

# Connect
enemy.died.connect(self._on_enemy_died)
enemy.damaged.connect(lambda hp: self.show_damage(hp))
enemy.hit_by.connect(self._log_hit, once=True)   # auto-disconnects after first fire

# Emit
enemy.died()                        # or enemy.died.emit()
enemy.damaged(42)

connect() returns a Connection handle; call .disconnect() on it, or pass the callback back to signal.disconnect(fn). once=True is a one-shot connection.

Typed signals: arity validation

When a signal declares types, connect() inspects the callback’s signature and warns if it can’t accept the emitted arguments:

health_changed = Signal(int, int)   # old_hp, new_hp

def bad(hp):                        # only accepts one arg
    print(hp)

health_changed.connect(bad)         # logs: accepts at most 1 arg (signal emits 2)

The check fires at connect time, not emit time, so bugs surface without needing to run the emitting code path. Callables with *args are always accepted. Types themselves are advisory: nothing enforces runtime type checks on the emitted values.

Signal[int, str] bracket syntax works the same as Signal(int, str).

Lifecycle hooks via decorators

Lifecycle is normally driven by overriding on_ready, on_process(dt), on_physics_process(dt), on_enter_tree(), on_exit_tree(), on_draw(renderer), on_picked(event), on_input(event), and on_unhandled_input(event). Decorators register additional handlers under arbitrary method names so concerns can split without one monolithic override:

from simvx.core import Node, on_process, on_input, Key

class Player(Node):
    def on_process(self, dt):           # primary override: fires first
        self.position += self.velocity * dt

    @on_process                         # extra handler: fires after the override
    def update_animation(self, dt):
        self._frame += dt * self.fps

    @on_input(action="jump")            # filter by InputMap action
    def jump(self, event):
        self.velocity.y = -300
        return True                     # truthy return consumes the event

    @on_input(key=Key.S, ctrl=True)     # filter by raw key + modifiers
    def save(self, event):
        self.tree.save_game()

Filters for @on_input (mutually exclusive: at most one per decoration; stack decorators for multiple bindings):

Filter

Type

Matches

action=

str

active InputMap action

key=

Key or tuple of Key

keyboard key (any-of for tuples)

button=

MouseButton

mouse button

motion=True

flag

mouse motion events

scroll=True

flag

scroll events

joy_button=

JoyButton

gamepad button

joy_axis=

JoyAxis

gamepad axis

State and modifier flags: released=True matches release events (default False matches presses); ctrl/shift/alt/meta are True (must be pressed), False (must not), or None (don’t care). Modifier filters apply to key/button/joy_button only.

Bare @on_input is the catch-all: fires for every event regardless of filter. Hooks are collected once in Node.__init_subclass__, so dispatch is a tuple iteration, not a name lookup.

Typed signal bracket syntax

Signal(int, str) and Signal[int, str] are equivalent: bracket form reads cleanly when types are imported:

from simvx.core import Signal

class Inventory(Node):
    item_added = Signal[str, int]       # (item_id, quantity)
    cleared = Signal()

Arity validation runs at connect() time against the callback’s signature; mismatches log a warning. Types themselves are advisory.

Input actions in on_ready()

Register input bindings inside the root node’s on_ready(), never at module scope:

class Game(Node):
    def on_ready(self):
        InputMap.add_action("jump", [Key.SPACE])
        InputMap.add_action("move", [Key.A, Key.D, Key.LEFT, Key.RIGHT])

Reason: the web exporter skips the module’s if __name__ == "__main__" block and module-level statements, so InputMap.add_action(...) calls written there are silently dropped. Actions registered during on_ready() run regardless of entry point and work on desktop and web builds.

Query actions with Input.is_action_pressed("jump"), Input.is_action_just_pressed("jump"), Input.get_strength("jump") (0.0–1.0), or Input.get_vector("left", "right", "up", "down") for a normalised 2D direction.