Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/VKSFY/keel/llms.txt

Use this file to discover all available pages before exploring further.

keel.App is the single entry point for every Keel game. It wires together the ECS World, the Scheduler, the GLFW Window, the ModernGL rendering context, and the InputState into one object you configure at startup. After setup, a single call to app.run() blocks until the window closes, driving the fixed-timestep loop for you.

Creating an App

import keel

app = keel.App(
    title="My Game",
    width=800,
    height=600,
    vsync=True,   # default — True enables buffer swap sync
)
App.__init__ performs four things in order:
1

Creates the ECS World

app.world is a fresh World instance containing the archetype registry, the command buffer, the event bus, and the scheduler.
2

Opens the GLFW Window

app.window is a Window object that creates the GLFW window and a ModernGL context. The context is available immediately as app.ctx.
3

Wires input callbacks

app.input is an InputState registered as a world resource so systems can receive it via type injection. GLFW keyboard, mouse, and window-resize callbacks are connected and emit events into the world’s event bus.
4

Shares the Scheduler

app.scheduler is the same Scheduler instance stored inside app.world. Decorating with @app.system(phase) or @world.system(phase) both register into the same runner.

App Properties

PropertyTypeDescription
app.worldWorldThe ECS world — spawn entities, run queries, emit events.
app.inputInputStateKeyboard, mouse, and window state with held and edge-detected helpers.
app.ctxmoderngl.ContextThe ModernGL GL context — pass to renderer and shader setup functions.
app.schedulerSchedulerThe phase-ordered system scheduler.

Registering Systems

Use @app.system(phase) to register a plain function as a system. The phase argument controls when the system runs relative to physics, rendering, and other systems within the same frame.
@app.system(keel.Phase.UPDATE)
def move_player(world, dt):
    for transform, in world.query(keel.Transform2D):
        if app.input.is_key_down(keel.KEY_D):
            transform['x'] += 200.0 * dt
        if app.input.is_key_down(keel.KEY_A):
            transform['x'] -= 200.0 * dt
For ordering within a phase, use the after parameter:
@app.system(keel.Phase.UPDATE)
def read_input(world, dt):
    ...

@app.system(keel.Phase.UPDATE, after=read_input)
def apply_input(world, dt):
    # Guaranteed to run after read_input within Phase.UPDATE.
    ...

Resources

Resources are singleton objects stored in the world and injected into systems by type annotation. app.insert_resource is a convenience forward to world.insert_resource:
class Config:
    gravity: float = 980.0
    max_speed: float = 400.0

app.insert_resource(Config())

@app.system(keel.Phase.UPDATE)
def apply_gravity(world, dt, cfg: Config):
    # cfg is injected automatically because Config was registered as a resource.
    for vel, in world.query(Velocity):
        vel['y'] -= cfg.gravity * dt

Asset Hot Reload

app.setup_assets(watch_dirs) creates the AssetRegistry, registers default loaders for JSON and images, and starts the watchdog file watcher so textures and data files reload automatically when changed on disk.
assets = app.setup_assets(watch_dirs=["assets"])
Pass watch_dirs=None (or omit the argument) to skip the file watcher. The registry is also returned for direct use.

Shutdown Hooks

Register cleanup callbacks with app.add_shutdown_hook. Hooks run after the loop exits and before GLFW is torn down, in registration order. Exceptions inside individual hooks are swallowed so all hooks always run.
audio = keel.setup_audio(app)

app.add_shutdown_hook(audio.stop_all)
app.add_shutdown_hook(lambda: print("Goodbye!"))

Running the Loop

app.run()
app.run() blocks until the window is closed. On exit it calls every registered shutdown hook, destroys the window, and shuts down GLFW.
App.run() is single-shot. Calling it a second time on the same App instance raises RuntimeError because the window and GLFW context are torn down on the first exit. Create a new App instance to open a new window.

The Fixed-Timestep Loop

The loop inside run_loop separates simulation from rendering.
┌─ visual frame (variable rate) ─────────────────────────────────┐
│  1. Clear events from last frame                               │
│  2. Snapshot input edge state                                  │
│  3. Poll GLFW (keyboard / mouse / resize callbacks fire here)  │
│  4. Measure elapsed wall time since last frame                 │
│                                                                │
│  ┌─ fixed-step accumulator ──────────────────────────────────┐ │
│  │  while accumulator >= FIXED_DT (1/60 s):                  │ │
│  │    scheduler.tick_simulation(world, FIXED_DT)             │ │
│  │      → PRE_UPDATE → UPDATE → POST_UPDATE systems          │ │
│  │    world.flush()   ← structural changes applied here      │ │
│  │    accumulator -= FIXED_DT                                │ │
│  └───────────────────────────────────────────────────────────┘ │
│                                                                │
│  5. scheduler.tick_render(world, elapsed)                      │
│       → PRE_RENDER → RENDER → POST_RENDER systems             │
└────────────────────────────────────────────────────────────────┘
FIXED_DT is 1.0 / 60.0 seconds — the constant timestep passed to every simulation system. Render systems receive the real elapsed wall time for the visual frame. The accumulator is capped at 10 × FIXED_DT to prevent the “spiral of death” if the host machine stalls. Any excess elapsed time beyond that cap is discarded rather than scheduling an unbounded burst of catch-up ticks.
Event queues are cleared once per visual frame, not once per simulation tick. This means a KeyEvent emitted during GLFW polling is visible to all simulation ticks within that frame, and to the render phase.

Exported Loop Constants and Types

These are re-exported from keel for convenience:
NameValue / TypeDescription
keel.FIXED_DT1/60 sFixed simulation timestep.
keel.PRESSGLFW constantAction value in KeyEvent when a key is pressed.
keel.RELEASEGLFW constantAction value when a key is released.
keel.REPEATGLFW constantAction value for a held-key repeat event.
keel.FixedStepDriverclassThe accumulator logic; useful for writing headless test drivers.
keel.RenderStateclassPer-frame resource holding alpha (interpolation progress) and frame_dt.

Minimal Full Example

import keel
from keel.renderer import setup_renderer_2d

app = keel.App(title="Hello Keel", width=800, height=600)
setup_renderer_2d(app)

@keel.component
class Player:
    speed: float = 200.0

app.world.spawn(
    keel.Transform2D(x=400.0, y=300.0),
    keel.Sprite(texture_id=0, width=32.0, height=32.0),
    Player(),
)

@app.system(keel.Phase.UPDATE)
def move(world, dt):
    for transform, player in world.query(keel.Transform2D, Player):
        if app.input.is_key_down(keel.KEY_D):
            transform['x'] += player['speed'] * dt
        if app.input.is_key_down(keel.KEY_A):
            transform['x'] -= player['speed'] * dt
        if app.input.is_key_down(keel.KEY_W):
            transform['y'] += player['speed'] * dt
        if app.input.is_key_down(keel.KEY_S):
            transform['y'] -= player['speed'] * dt

app.run()

Build docs developers (and LLMs) love