Patterns

Idioms that span more than one class: singletons, scene changes, signal checks and decorator hooks.

Singletons: nodes that outlive a scene

A singleton is a node registered on the SceneTree itself. It lives outside the scene root, so change_scene() leaves it untouched, which is what you want for a node that must outlive the scene that started it: music that keeps playing across a scene change, or a save file held open.

It is not a home for the game’s own state. A score belongs to whatever owns the run, and reaching it through a name from anywhere makes every reader of that name a writer of it.

class Jukebox(Node):
    """Keeps one track playing while the scenes change under it."""

    def on_ready(self):
        self.player = self.add_child(
            AudioPlayer(stream=MUSIC_PATH, bus="Music", loop=True, autoplay=True)
        )

class MainMenu(Node):
    def on_ready(self):
        # Register once, when the first scene mounts
        self.tree.add_singleton("Jukebox", Jukebox())

A scene that wants a different track reaches it by name, and the music does not restart when the player leaves the menu:

self.tree.singletons["Jukebox"].player.stream = BATTLE_PATH

SceneTree.add_singleton(name, node) mounts the node, which enters the tree and runs on_ready() immediately. Remove it with remove_singleton(name). A singleton keeps its groups and unique names across change_scene.

Scene transitions

Swap the active root to move between screens. Singletons 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) destroys the old scene and readies the new one exactly as the first root was readied. Call it from any node through self.tree.

Signals

Declaring, connecting and emitting signals is covered in Node system. One check is worth knowing: when a signal declares argument types and dev warnings are on (Node.dev_warnings = True, or python -X dev), connect() warns at once if the callback cannot take the emitted arguments.

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

def bad(hp): ...
health_changed.connect(bad)         # DevWarning: accepts at most 1 positional arg; the signal emits 2

The types are not enforced on emitted values, and a callback taking *args is always accepted.

Lifecycle hooks via decorators

Lifecycle is normally driven by overriding on_ready, on_update(dt), on_fixed_update(dt), on_enter_tree(), on_exit_tree(), on_draw(renderer), on_picked(event), on_gui_input(event) and on_input(event); on_gui_input is the 2D half of the targeted phase on_picked serves in 3D (the focus owner, the pointer’s captor or the controls under it), and @on_gui_input and @on_picked are its decorator forms. Decorators register additional handlers under arbitrary method names so concerns can split without one monolithic override:

from simvx.core import Controller, Key, Node2D, Pointer, Signal, Vec2, on_input, on_update

class Player(Node2D):
    save_requested = Signal()
    input_context = {"jump": [Key.SPACE, Controller.A, Pointer.PRIMARY]}

    def on_ready(self):
        self.velocity = Vec2(0, 0)
        self.frame = 0.0

    @on_update                         # one concern per handler
    def move(self, dt):
        self.position += self.velocity * dt

    @on_update
    def animate(self, dt):
        self.frame += dt * 12

    @on_input("jump")                  # an action's press
    def jump(self, event):
        self.velocity.y = -300
        return True                    # claim the event: no later handler runs

    @on_input(Key.CTRL + Key.S)        # a key, with a modifier held
    def save(self, event):
        self.save_requested.emit()

@on_input filters on an event family, a target (an action, a button or an axis), an edge and held controls; a handler returns True to claim the event. @on_gui_input and @on_picked take the same filters for the targeted phase, less action names. The full rules are in Input: handlers. Hooks are collected once, when the class is defined.

Inside a class body a name defined earlier wins, so a decorator written below a same-named override, @on_update under def on_update(...) or @on_gui_input(...) under def on_gui_input(...), resolves to the override, which then runs at class definition with the decorated function as self. Below a hook that takes an argument (on_update, on_gui_input) that is a missing-argument error naming neither; below on_ready, on_enter_tree or on_exit_tree it raises nothing, and the decorated handler silently becomes None and never runs. Declare decorated handlers above the override, or qualify the decorator (from simvx.core import decorators and @decorators.on_update). uv run python tools/decorator_shadowing.py flags the mistake for every lifecycle and input decorator.

Declaring input actions

Declare each action once, on the root’s input_context, with its bindings giving it a type; see Input. Input.get_axis and Input.get_vector combine separate actions; for movement, declare one Axis1D or Axis2D action instead.