Every non-trivial Keel game re-invents the same handful of solutions: a single component that holds top-level state, a plain Python dict next to the ECS for data that doesn’t fit in numpy, a safe path for despawning during a collision, a set that lets collision events be classified in constant time, and a manual position loop for entities that move too fast for pymunk to track reliably. The patterns below are drawn directly from the Pong, Asteroids, and Platformer examples shipped with Keel. Reach for them before building your own infrastructure.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.
Singleton components — spawn once, read anywhere
Singleton components — spawn once, read anywhere
A singleton component is an ordinary Read from any systemWrite back with The Asteroids example carries
@keel.component class of which exactly one entity will ever exist. It holds top-level game state: score, lives, wave number, game-over flag, and so on. Spawn it once at startup (before app.run()), then read it from any system with world.query_one.Define and spawnworld.query_one returns a plain Python dict with scalar values — no [0] indexing required, no numpy type surprises.world.setquery_one is a read-only snapshot. To update fields, call world.set with the entity id you saved at spawn time.If you need to update many entities of the same component type in bulk (e.g., ticking down all bullet lifetimes at once), use
world.query(ComponentType) and mutate the numpy column view in place: bullets['lifetime'] -= dt. query_one is only for singletons where you want plain Python scalars.score, lives, wave, game_over, respawn_timer, ship_alive, and restart_pending all in one GameState singleton, which every system reads through world.query_one(GameState) and writes through world.set(GS_ENTITY, GameState, ...).Side tables for non-numpy data
Side tables for non-numpy data
Keel components are backed by numpy structured arrays. That means every field must be a numeric scalar or bool — no strings, no Python tuples, no dicts, no lists. When you need to store per-entity data that numpy cannot hold, use a module-level Python Re-apply the velocity every framepymunk can perturb a body’s velocity during its step. Re-push the stored value each Clean up on despawnThe same pattern extends to any other non-numpy data: enemy patrol velocities (
dict keyed by entity ID.This is the same pattern Keel uses internally for TextLabel text content (set_text, get_text, clear_text all delegate to a module-level dict keyed by entity id).Example: bullet velocityA bullet needs a constant (vx, vy) tuple for its entire lifetime. Tuples can’t live inside a numpy structured array, so they go in BULLET_VEL beside the ECS:PRE_UPDATE tick so the bullet maintains its constant heading:ENEMY_VEL: dict[int, float]), dialogue strings, animation frame sequences — anything that doesn’t fit in a float or int column goes in a module-level dict and is cleaned up in the despawn path.Deferred despawn queue — never despawn inside a query loop
Deferred despawn queue — never despawn inside a query loop
world.query() returns numpy array views directly into the archetype’s storage. Calling world.despawn() inside a query loop can invalidate those views mid-iteration, causing reads from freed memory or skipping rows silently. Always queue entity ids and process them in a dedicated system that runs after every query has finished.The queuePhase.POST_UPDATE so it runs after all UPDATE and POST_UPDATE gameplay systems have finished iterating:world.despawn queues a command in Keel’s internal CommandBuffer. The actual structural change only applies on the next world.flush(). The DESPAWN_QUEUE pattern adds a second layer of safety: it keeps you from even calling world.despawn while you are inside a numpy view, which could produce confusing double-free behaviour in edge cases.Entity ID tracking for collision detection
Entity ID tracking for collision detection
CollisionEvent2D carries two entity IDs — event.entity_a and event.entity_b — but no component type information. There is no built-in “which archetype is this?” look up on a collision event. The naive fix is to call world.has_component(a, Bullet) for every event, which costs an O(log n) dict lookup per entity per event per frame.The idiomatic Keel solution is to maintain module-level sets that classify every entity at spawn time. Membership tests on a Python set[int] are O(1):Declare the tracking setsCollisionEvent2D does not guarantee ordering — either body can be entity_a. Handle both orientations:despawn_system so they don’t accumulate stale IDs:SHIP_ENTITIES, BULLET_ENTITIES, and ASTEROID_ENTITIES — to route all four collision pairs (bullet-vs-asteroid, ship-vs-asteroid, and their mirrors) in a single O(1) dispatch per event.Manual ball / projectile physics
Manual ball / projectile physics
pymunk’s continuous collision detection works well at modest speeds, but tunneling becomes visible past roughly 1500 px/s on a 60 Hz step: the ball moves more than its own diameter in one tick, and pymunk’s broadphase misses the wall. For arcade games where correctness of every bounce matters more than realistic physics response, the recommended approach is to own the position math yourself and use Keel’s physics bridge only for collision event detection.The patternKeep velocity and position in a module-level dict (a side table, as described above). Each Why
The Pong example uses
PRE_UPDATE tick, advance position, apply boundary reflections, then push the result into pymunk via phys.set_position and phys.set_velocity. pymunk still generates CollisionEvent2D on the next step, so your collision system works unchanged.phys.set_velocity too?pymunk integrates velocity into position during its own step. If you only push set_position without correcting set_velocity, pymunk will overwrite your position with its own integrated result on the very next step. Setting both makes pymunk’s internal state agree with yours before the step runs.When to use this pattern vs. pure pymunk| Situation | Recommendation |
|---|---|
| Ball speed under ~1000 px/s, 60 Hz | Pure pymunk with elasticity=1.0 |
| Ball speed 1000–1500 px/s | Reduce FIXED_DT or clamp speed |
| Ball speed above ~1500 px/s, or correctness-critical arcade game | Manual ball physics (this pattern) |
| Bullet that simply needs to disappear on hit | Pure pymunk is fine; bullets are short-lived |
phys.set_position and phys.set_velocity in its reset_timer_system to relaunch the ball after a score, and the Asteroids example uses the same calls in apply_asteroid_vel and apply_ship_vel to re-assert velocity every frame over pymunk’s own integration.