Keel Systems and Phases: Register and Order Game Logic
Keel systems are plain Python functions registered to a Phase. The Scheduler runs them in phase order each tick, injecting typed resources automatically.
Use this file to discover all available pages before exploring further.
Systems are where game logic lives in Keel. A system is a plain Python function with a fixed two-parameter prefix — (world, dt) — followed by any number of optional typed parameters that the scheduler resolves automatically from the world’s resource registry. There is no base class to inherit, no interface to implement, and no decorator magic beyond the registration call itself.
Systems that need additional context declare extra parameters with type annotations. The scheduler inspects those annotations at registration time and looks up matching resources when it invokes the function:
from keel import InputStatedef handle_quit(world, dt, input: InputState): if input.is_key_pressed(keel.KEY_ESCAPE): world.emit(QuitEvent())
Both decorators call Scheduler.register(phase, fn, after=...) under the hood and return the function unchanged, so the function remains callable outside the scheduler.
Systems within the same phase run in topological order determined by after dependencies. Without after, systems run in registration order.
@app.system(keel.Phase.UPDATE)def read_input(world, dt): ...@app.system(keel.Phase.UPDATE)def apply_velocity(world, dt): ...# apply_movement is guaranteed to run after both of the above.@app.system(keel.Phase.UPDATE, after=[read_input, apply_velocity])def apply_movement(world, dt): ...
Rules enforced at registration time:
Every system listed in after must already be registered in the same phase. Referencing a system from a different phase raises ValueError.
Circular dependencies raise ValueError and roll back the offending registration, leaving the scheduler in a consistent state.
Any parameter beyond world and dt must carry a type annotation. The scheduler resolves the annotation against the world’s resource registry (world._resources) and passes the result as a positional argument.
from keel import InputStateclass AudioSystem: def play(self, path: str) -> None: ...@app.system(keel.Phase.UPDATE)def jump(world, dt, input: InputState, audio: AudioSystem): if input.is_key_pressed(keel.KEY_SPACE): audio.play("assets/jump.wav")
Register resources with app.insert_resource or world.insert_resource before the loop starts:
app.insert_resource(AudioSystem())
Every annotated extra parameter must have its resource registered before the system runs. If world.get_resource(type_) returns None, the system receives None for that argument. Keel does not raise an error — make sure you register resources during setup.
Parameters annotated with a type that cannot be resolved to a type object (generic aliases, union types, etc.) cause TypeError at registration time, not at runtime. Define resource types at module level so typing.get_type_hints can resolve them.
The Scheduler is accessible as app.scheduler or world.scheduler. You rarely need to call it directly, but the full surface is:
# Register a function manually (same as @app.system).app.scheduler.register(keel.Phase.UPDATE, my_fn, after=other_fn)# Inspect registered systems for a phase.fns = app.scheduler.systems(keel.Phase.UPDATE) # list[Callable]# Remove all registered systems from every phase.app.scheduler.clear()
The main loop calls these two methods instead of running all phases at once, which lets simulation and rendering run at different rates:
# Run only PRE_UPDATE, UPDATE, POST_UPDATE.app.scheduler.tick_simulation(world, FIXED_DT)# Run only PRE_RENDER, RENDER, POST_RENDER.app.scheduler.tick_render(world, elapsed)
These are useful when writing headless tests or a custom loop — see world.tick below.
world.tick(dt) runs a complete single-frame cycle without a window:
Clears all event queues.
Runs every system across all six phases in order.
Calls world.flush() to apply deferred structural changes.
import keelworld = keel.World()@keel.componentclass Counter: value: int = 0world.spawn(Counter(value=0))world.flush()@world.system(keel.Phase.UPDATE)def increment(world, dt): for counter, in world.query(Counter): counter['value'] += 1world.tick(1.0 / 60.0)world.tick(1.0 / 60.0)gs = world.query_one(Counter)assert gs['value'] == 2
Use world.tick in unit tests to exercise systems without spinning up a window or GLFW. It is the same codepath the main loop uses, so your systems behave identically in tests and in production.