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 |
|---|---|---|
|
|
active |
|
|
keyboard key (any-of for tuples) |
|
|
mouse button |
|
flag |
mouse motion events |
|
flag |
scroll events |
|
|
gamepad button |
|
|
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.