The issues below account for the majority of questions from developers new to Keel. Each one has a single, well-defined root cause; once you know it, the fix is mechanical. If your problem isn’t listed here, the world inspector (F1) and the physics debug draw (F3) are the fastest first steps for diagnosis.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.
CollisionEvent2D never fires
CollisionEvent2D never fires
This is the single most common Keel bug. The cause is almost always body type, not a code ordering issue or a missing setup call.The rule
This is pymunk behavior, not a Keel bug. Keel emits a one-time Quick diagnosticOpen the debug draw with F3. Green outlines are
CollisionEvent2D fires only when at least one body in the pair is DYNAMIC. The full matrix:| Pair | Events? |
|---|---|
DYNAMIC vs DYNAMIC | ✅ Yes |
DYNAMIC vs STATIC | ✅ Yes |
DYNAMIC vs KINEMATIC | ✅ Yes |
DYNAMIC vs sensor | ✅ Yes (contact-begin frame only) |
KINEMATIC vs KINEMATIC | ❌ No |
KINEMATIC vs STATIC | ❌ No |
STATIC vs STATIC | ❌ No |
UserWarning the first time a KINEMATIC/KINEMATIC or KINEMATIC/STATIC pair is created to surface the trap early.The fixUse keel.BodyType.DYNAMIC for any entity that needs to participate in collision events: bullets, balls, enemies, projectiles, and players (unless you are handling their movement entirely without physics response, in which case you still need DYNAMIC if you want CollisionEvent2D to fire on contact).DYNAMIC, gray are STATIC, blue are KINEMATIC. If the two bodies you expect to collide are both blue or both gray, that explains the missing events — change at least one to green (DYNAMIC).Score or game state not updating
Score or game state not updating
Symptom A — score reads as a numpy type, not a Python intFor singleton components, use Symptom B — writing to the To write back, call Symptom C — bulk mutation with
world.query() returns numpy array views, not Python scalars. Iterating world.query(GameState) gives you structured numpy columns:world.query_one instead. It converts every field to a plain Python scalar:query_one result has no effectquery_one returns a snapshot dict. Mutating it does not write back to the ECS:world.set with the entity id you saved at spawn time:world.query updates the wrong rowsIf you are iterating the raw query (e.g., to update all entities of a type at once), mutations must index into the column. Assigning to the slice replaces the entire column reference in the local variable without writing through:Ball passes through walls at high speed
Ball passes through walls at high speed
What is happeningThis is tunneling: the ball moves more than its own diameter in a single physics tick, so pymunk’s collision check never sees an overlapping state. The probe in Keel’s test suite shows clean reflective bouncing at 100 px/s but tunneling that bypasses walls past roughly 1500 px/s on a 60 Hz step.At 60 Hz,
FIXED_DT ≈ 0.0167 s. A ball moving at 1500 px/s travels 25 pixels per tick. A wall with a 10-pixel thickness is thinner than one tick of movement — the ball jumps from one side to the other with no overlap frame in between.Workarounds in order of effort- Reduce ball speed. If 1500 px/s is the limit and your game feels fine at 900 px/s, the simplest fix is a speed cap.
-
Reduce
FIXED_DT. A smaller fixed timestep gives pymunk more opportunities to catch fast-moving bodies, at the cost of more physics steps per visual frame. -
Use manual ball physics. Take ownership of the ball’s position in a
PRE_UPDATEsystem, apply your own boundary reflections, and push the result into pymunk each tick withphys.set_positionandphys.set_velocity. pymunk still generatesCollisionEvent2Dfor paddle contact detection; you handle the wall bounces yourself.See the Common Patterns page for the full write-up of this approach.
Text not visible on screen
Text not visible on screen
The coordinate systemKeel’s text renderer draws in screen space, not world space. The fixAdd at least the font’s point size in pixels to the y position so the baseline sits below the top of the screen:The Asteroids example uses
y=0 is the top of the screen, and the y axis grows downward. This is the opposite of the OpenGL world-space convention used by Transform2D on sprites.The baseline trapThe Transform2D position attached to a TextLabel is the text baseline — the bottom of most uppercase letters, below the tops of ascenders like d, h, l. If you place a label at y=0, the glyphs draw upward into negative y and clip outside the viewport. Nothing appears on screen.SCORE_LABEL_Y = 35.0 for a 28-pixel font — roughly font_size + 7 — which places the score just inside the top edge with a comfortable margin.Other invisible-text causes| Cause | Symptom | Fix |
|---|---|---|
TextLabel(visible=False) | Label exists but is hidden | Call set_label_visible(world, label_id, True) |
set_text never called | Entity spawned, no text content | Call set_text(label_id, "your text") |
Color is r=0, g=0, b=0 (black) on a black background | Text draws but is invisible | Set at least one color channel above 0 |
setup_text(app) not called before spawning TextLabel | Text renderer not initialized | Call setup_text(app) before spawning text entities |
`import keel` fails with module not found
`import keel` fails with module not found
The PyPI distribution name and the Python import name are different:
Install from PyPIThen in your code:Install from sourceIf you cloned the repository, install it as an editable package from the repo root:Check for a shadowing fileIf Rename or remove the conflicting file, then retry the import.
| Name | |
|---|---|
| PyPI (install) | keelpy |
| Python (import) | keel |
pip install keelpy succeeds but import keel still raises ModuleNotFoundError, a file named keel.py somewhere on your PYTHONPATH is shadowing the installed package. Python resolves import keel to the first match on sys.path, so a stray keel.py in your project directory or working directory wins over the site-packages installation.