Keel Assets: Registry, Hot Reload, and Scene Files
AssetRegistry caches files by stable handle. FileWatcher reloads textures and JSON on the main thread. Scene saves world state as atomic versioned JSON.
Use this file to discover all available pages before exploring further.
Keel’s asset pipeline has three layers: a central AssetRegistry that caches loaded assets behind stable integer handles, a watchdog-powered FileWatcher that queues file changes on a background thread and drains them safely on the main thread, and a Scene facade that serializes and restores entire world states as atomic JSON files. None of these layers own simulation state — they slot in and out via world resources and PRE_UPDATE systems without touching the ECS core.
The AssetRegistry is the single entry point for loading any file-backed asset. Every path is normalized to an absolute form before being used as a cache key, so "./hero.png" and "assets/../assets/hero.png" resolve to the same slot. Asset IDs are monotonically increasing integers that are never reused after an unload.
import keelfrom keel.renderer import setup_renderer_2dapp = keel.App(title="My Game", width=800, height=600)setup_renderer_2d(app)# Minimal — no hot reload.registry = keel.setup_assets(app)# With hot reload watching the assets/ directory.registry = keel.setup_assets(app, watch_dirs=["assets"])# Convenience form on the App object (identical behavior).registry = app.setup_assets(watch_dirs=["assets"])
setup_assets is idempotent — calling it twice on the same app returns the same AssetRegistry without registering duplicate systems.
Call setup_renderer_2d(app)beforesetup_assets(app) if you want texture loading wired up automatically. When setup_assets detects that the renderer has already been initialized, it binds the texture loader to the renderer’s atlas immediately. If you call them in the reverse order, the renderer’s own init path wires the loader instead.
registry.load(path) returns an AssetHandle. Subsequent calls with the same path return the cached handle without touching disk.
# Load a JSON data file.config_handle = registry.load("assets/config.json")config = registry.get(config_handle) # returns the parsed dictprint(config["player_speed"])# Load a texture (requires setup_renderer_2d to have run first).texture_handle = registry.load("assets/hero.png")texture_id = registry.get(texture_handle) # returns the atlas texture_id (int)# Spawn a sprite using the texture.app.world.spawn( keel.Transform2D(x=400.0, y=300.0), keel.Sprite(texture_id=texture_id, width=64.0, height=64.0),)
Extensions:.jsonReads the file with json.load and returns whatever Python object the JSON represents — typically a dict or list. Useful for level data, game config, or lookup tables.
Texture loader
Extensions:.png, .jpg, .jpeg, .bmp, .tgaDecodes the image with Pillow, uploads it to the renderer’s TextureAtlas, and returns the texture_id integer. Only available after setup_renderer_2d has been called.
Call registry.register_loader(extensions, loader_fn) to add support for any file format. The loader receives the normalized absolute path and must return the loaded asset:
def my_audio_loader(path: str): # Return any Python object — the registry stores it as-is. with open(path, "rb") as f: return f.read()registry.register_loader([".wav", ".ogg"], my_audio_loader)handle = registry.load("assets/sounds/jump.wav")raw_bytes = registry.get(handle)
When watch_dirs is passed to setup_assets, Keel creates a FileWatcher that monitors those directories recursively using watchdog. File changes are deliberately not applied on the watchdog thread — GL texture re-uploads must run on the main thread that owns the OpenGL context. Instead, the handler enqueues paths into a thread-safe queue.Queue, and FileWatcher.poll() drains the queue inside a PRE_UPDATE system registered automatically by setup_assets.
File saved on disk → watchdog observer thread: event.src_path put into queue → next frame, PRE_UPDATE: FileWatcher.poll() drains queue → registry.reload(handle) called on the main thread → TextureAtlas.reload(id) re-uploads pixels to the GPU → Sprite.texture_id is unchanged — pixels update invisibly
The handle and its texture_id are stable across reloads. Any Sprite component already referencing that texture_id picks up the new pixels with no ECS mutation.
This is a condensed version of examples/asset_hot_reload.py:
import keelfrom keel.renderer import setup_renderer_2dfrom pathlib import PathHERO_PATH = Path("assets/hero.png")app = keel.App(title="Hot Reload", width=600, height=600)setup_renderer_2d(app)# Pass the directory to watch, not the file itself.registry = app.setup_assets(watch_dirs=[str(HERO_PATH.parent)])hero_handle = registry.load(str(HERO_PATH))app.world.spawn( keel.Transform2D(x=300.0, y=300.0), keel.Sprite(texture_id=registry.get(hero_handle), width=256.0, height=256.0),)@app.system(keel.Phase.UPDATE)def quit_on_escape(world, dt): if app.input.is_key_down(keel.KEY_ESCAPE): app.window.close()# Now open assets/hero.png in any image editor and save.# The sprite updates on the next frame automatically.app.run()
FileWatcher is also available directly if you need lower-level control:
Method
Description
watcher.watch(directory)
Start watching directory recursively. Idempotent per directory.
watcher.unwatch(directory)
Stop watching directory.
watcher.poll()
Drain the change queue and reload matching assets. Returns the reload count.
watcher.stop()
Stop the watchdog observer and join its thread. Idempotent.
watcher.watched_directories()
List of currently watched directories.
watcher.started
True if the observer is running.
watcher.last_reload_count
Count of assets reloaded on the most recent poll() call.
A failed reload (e.g., the file is corrupted or was deleted mid-write) does not crash the loop. FileWatcher.poll() logs a WARNING via Python’s logging module and continues. To surface these warnings, configure a log handler before calling setup_assets:
keel.Scene serializes the entire world state—every component on every live entity—to a versioned JSON file. Save and load are static methods, so no setup call is needed.
Scene.save walks every archetype, serializes all component fields that are JSON-safe (scalars, booleans, strings), writes to <path>.tmp, and atomically renames it to the target with os.replace. A mid-write crash cannot corrupt a previous save file.
Fields that cannot be serialized (non-scalar types, objects) are skipped with a RuntimeWarning. Numpy scalars are converted to native Python automatically.
Scene.loadadds entities to the world without clearing it first (additive load). It returns a list of the newly spawned entity IDs. To replace the world state entirely, despawn all existing entities before loading:
# Clear the world first.for arch in app.world.archetypes.all_archetypes(): for eid in list(arch.entities[:arch.length]): app.world.despawn(eid)app.world.flush()# Then load the saved state.ids = keel.Scene.load(app.world, "scenes/game.json")print(f"loaded {len(ids)} entities")
Scene.load_additive is an alias for Scene.load that makes the additive intent explicit in code.
Every save file includes "version": 1. If you attempt to load a file whose version field does not match Scene.VERSION, a SceneVersionError is raised immediately before any entities are spawned:
from keel.assets import SceneVersionErrortry: keel.Scene.load(world, "scenes/old_save.json")except SceneVersionError as e: print(f"incompatible save file: {e}")
Scene.load resolves component class names by scanning all modules in sys.modules for classes carrying the __keel_component__ attribute (set by @keel.component). This means every component module must be imported before loading a scene that contains those components. Unknown component names emit a RuntimeWarning and skip the entry rather than raising.
The id field is the entity’s integer ID from the originating world. On load, new IDs are assigned by world.spawn; the original IDs in the file are not preserved.