Collision, without the fog of war
THREE JOBS, THREE OWNERS
Collision is three jobs, and the library splits them on purpose. Learn the split and nothing else surprises you.
| Job | Who does it | Owns |
|---|---|---|
| Where solids live | PhysicsWorld | the collision tile layer, the TilesetCollision geometry, the list of Body solids, and the grid (tile_size, render_scale) |
| How things move | CollisionRunner | the five move_* methods, gravity, tunables, CollisionResult |
| What can be moved | your sprite | x, y, collision_shape; that is the whole contract |
Tiles and bodies are both just solids in the world. The runner never cares which one it hit; it resolves movement against the union of them. If you can draw it, you can collide with it.
THE TWO-MINUTE WIRING
from tilemap_parser import (
CollisionRunner, PhysicsWorld, Body, RectangleShape,
load_map, load_tileset_collision,
)
game_data = load_map("map.json")
tileset = load_tileset_collision("map.collision.json")
world = PhysicsWorld.from_map(game_data, tileset) # adopts tile_size + render_scale
runner = CollisionRunner.from_world(world, game_type="platformer")
result = runner.move_platformer(player, None, None, dt, input_x=1.0, jump_pressed=False)Note the from_world constructor: it applies the game-type preset and attaches the world in one step. From then on every move_* call resolves against the world's tiles and bodies, so you pass None, None for the tile arguments. The legacy spelling runner = CollisionRunner(); runner.attach(world) still works.
- Attaching overrides the tile source and grid geometry;
runner.detach()falls back to per-call tile arguments. - The tile source is locked at attach. Multiple maps? One world per map, re-attach, or pass
world=other_worldas the last argument of any move call for a one-off override. - Per-call
(tileset_collision, tile_map)still works; the world is optional, not required.
THE OBJECT CONTRACT
class Player:
def __init__(self, x, y):
self.x = float(x)
self.y = float(y)
self.vx = 0.0
self.vy = 0.0
self.on_ground = True
self.collision_shape = RectangleShape(width=24, height=28)
self.collision_layer = 1 # optional, defaults
self.collision_mask = 0xFFFFFFFF| Attribute | Required | Used by |
|---|---|---|
x, y | yes | position; shape origin: top-left for RectangleShape, center for CircleShape, top cap center for CapsuleShape |
collision_shape | yes | primitives only; polygon shapes use MapObject |
vx, vy | physics modes | move_platformer, move_platformer_with_slide, move_grounded |
on_ground | platformer | grounded state, step-up, jump |
collision_layer / collision_mask | optional | body filtering; both sides must agree |
Body is the same contract plus a mode ("static" / "kinematic") and game_id. It is the authoring surface for solids, not sprites.
THE FIVE MOVE METHODS: PICK BY INPUT MODEL
| Method | You feed it | Gravity? | Wall response |
|---|---|---|---|
move_and_slide | displacement delta_x, delta_y | no | slide along the wall; slide_vector reports what's left |
move_rpg | displacement | no | full block; a diagonal into a corner stops you dead |
move_grounded | nothing; reads sprite.vx/vy, applies gravity | yes | full block; vx zeroed on wall hit, vy zeroed on landing |
move_platformer | input_x in [-1, 1] + jump_pressed | yes | full block + step-up + one-way platforms |
move_platformer_with_slide | same as above | yes | everything above, plus walkable slopes (gated by max_walk_angle) |
Rule of thumb: displacement methods for top-down games (you do the velocity math, the runner does the geometry), physics methods for platformers (the runner owns gravity and landing). move_and_slide never reads or writes vx/vy.
Why move_and_slide slides: it tries the full move first (fast path). If that collides, it retries the X-only and Y-only moves. Diagonal into a wall → the X-only move collides, so it retracts X and keeps Y; you've slid. If neither axis alone collides (you clipped a corner dead-on) it picks the dominant axis and slides along the other. For slopes, pass slope_slide=True and it runs up to 4 projection passes, each stripping the component of motion that points into the colliding edge's normal.
THE VELOCITY CONTRACT
In the three physics modes, when you pass velocity=(vx, vy):
- the runner skips its own gravity, input and jump; it only resolves collision for that velocity;
- it adopts the velocity onto the sprite (
sprite.vx,sprite.vyare set); - you own the velocity; the runner zeroes
vxon a wall hit andvyon landing.
crate.vy += 800.0 * dt # you apply gravity
result = runner.move_grounded(crate, None, None, dt, velocity=(crate.vx, crate.vy))
if result.hit_wall_x:
crate.vx = 0.0 # the runner zeroes vy on landing itselfOBJECTS IN THE PHYSICS WORLD: THE SLIDING BOX
This is the centerpiece, straight from examples/physics-crate/main.py (tested, correct): a floor, a wall column, three crates, a player. Walk into a crate and watch it slide; push it into another crate and it stops; jump on top of a crate and stand on it.
Author the world and its bodies
world = PhysicsWorld(tile_map=tile_map, tileset_collision=tileset, tile_size=(32, 32))
crates = [
Body(RectangleShape(width=32, height=32), x=8 * 32, y=floor_y - 32, mode="kinematic"),
Body(RectangleShape(width=32, height=32), x=10 * 32, y=floor_y - 32, mode="kinematic"),
]
for crate in crates:
world.add_body(crate) # <-- nothing collides until this happensmode is a promise, not a physics flag. "static" never moves (scenery);"kinematic" is moved explicitly by your code each frame. Nothing moves a kinematic body except you: the player walking into it does not shove it; that's the push loop below. Bodies take primitive shapes only (anything else raises TypeError), and a body never blocks itself.
Bodies already block and support: no extra code
Once the crate is in the world it is already a wall, a landing surface and a step. The player's single move_platformer call resolves against tiles and the crates. Jump onto a crate and the platformer step-up logic puts you on top; the Y phase checks world.collides_with_body exactly like it checks tiles.
The push: where the velocity contract earns its keep
# 1. player moves against everything
result = runner.move_platformer(player, None, None, dt, input_x=float(axis), jump_pressed=jump)
# 2. pressed against a wall? find what is in the way
if result.hit_wall_x and axis != 0:
block = world.collides_with_body(player)
if block is None:
block = body_ahead(world, player, axis) # probe a few px into the wall
if block is not None and block.mode == "kinematic":
block.vx = axis * PUSH_SPEED # hand the crate a velocity
# 3. drive every body that has a velocity
for crate in world.bodies:
if crate.vx:
crate_result = runner.move_grounded(crate, None, None, dt, velocity=(crate.vx, crate.vy))
if crate_result.hit_wall_x:
crate.vx = 0.0 # crate meets crate/tile wall -> stop
else:
crate.vx *= 0.9 # friction: sliding crates slow down
if abs(crate.vx) < 1.0:
crate.vx = 0.0Walk through what happens:
- The player is stopped by the crate's side (it's a solid), so
hit_wall_xis true. world.collides_with_body(player)identifies which solid, and we only push it ifmode == "kinematic". Static walls never move.- The crate gets
vx = axis * 260.0, then step 3 drives it withmove_grounded(..., velocity=...): explicit velocity, so no gravity, no ledge detection, pure collision resolution. - The crate slides. When it presses into the next crate (or the tile wall),
move_groundedsees a wall, setshit_wall_x, and we zerovx. Crates block each other because step 3 resolves every crate against the world's other crates too.
That's the entire loop: read the result, assign velocity, drive bodies through the runner, read the result again. The runner never simulates pushing on its own, but give it a velocity per frame and it behaves exactly like one.
The sub-pixel gap: why the probe exists
The runner stops a sprite a fraction of a pixel short of a body (a skin gap so resting contact never jitters into a tunnel). Consequence: at the resting position, world.collides_with_body(player) can return None. The demo probes a few pixels into the push direction:
def body_ahead(world, sprite, axis, probe=8.0):
s = copy.copy(sprite)
s.x = sprite.x + axis * probe
return world.collides_with_body(s)The interaction table
| Pair | Mechanism | Notes |
|---|---|---|
| sprite ↔ tiles | runner queries (all move_*) | automatic |
| sprite ↔ body | runner + world.collides_with_body(sprite) | automatic + hit-testing |
| body ↔ body | move_grounded(body, ..., velocity=...) | crates block crates |
| sprite ↔ sprite | not the world; ObjectCollisionManager | separate lane, spatial grid |
The world is not a physics engine. It resolves movement against tiles and bodies; it does not simulate sprite-vs-sprite contact. That's ObjectCollisionManager's lane.
READING COLLISIONRESULT
| Flag | Meaning |
|---|---|
collided | anything hit at all, including a landing |
final_x / final_y | where the runner parked you |
hit_wall_x | movement along X was blocked |
hit_wall_y | movement along Y was blocked (landing or ceiling, displacement modes) |
hit_ceiling | head bonk (physics modes) |
on_ground | feet on something solid |
slide_vector | move_and_slide only: the movement component that survived |
The runner mutates sprite.x/y in place; after the call the sprite is wherever it ended up; final_* is for when you want to know where without inspecting it. And hit_wall_x vs collided: in the crate loop we branch on hit_wall_x precisely so a landing doesn't zero the crate's push velocity.
LAYERS & MASKS: BOTH SIDES MUST AGREE
Filtering is mutual agreement, not either-or. Two objects collide only if both pass:
(a_mask & b_layer) != 0 and (b_mask & a_layer) != 0Defaults are collision_layer=1, collision_mask=0xFFFFFFFF. This gates world.collides_with_body and therefore every body interaction inside every move_*. The AND is deliberate: it makes "should these two interact" symmetric, so one object can't silently filter a pair the other expected.
COORDINATE SPACE
- One coordinate system. Tiles and sprites live in the same pixel space. Tile
(col, row)occupies(col * tile_w, row * tile_h). - Rectangles:
(x, y)is the top-left (plus the shape'soffset). Circles:(x, y)is the center. Capsules:(x, y)is the top cap's center — the draw box is(x - radius, y - radius)sized2r × (2r + height), and the capsule is vertical-only (there is no horizontal capsule). - Bodies are never one-way.
Body.top_y_at(world_x)samples the top surface, but bodies block from every direction. One-way is a tile-polygon feature (poly.one_way), and only the platformer family honors it;move_groundedtreats one-way polygons as plain solid.
TRAPS, RANKED BY HOW OFTEN THEY FIRE
- Rectangle top-left vs circle center. Swap coordinate conventions and your sprite teleports half its size into the floor. The Quick Start warning covers anchoring and width in full.
- Attach before you move. A runner without
attach/from_worldignores the world entirely; bodies become ghosts.from_worlddoes both; prefer it. velocity=skips gravity. Every frame you forget to apply it, the crate hovers.- Pushing the wrong mode. Only
mode == "kinematic"bodies should receive velocity. - The skin gap. A resting sprite can fail a static overlap query. Probe into the direction of motion.
- Tiles with no collision data. A
tile_mapwithout a matchingtileset_collisionraisesValueErrorat world construction, by design, so you can't ship an empty world. - One-way layer filtering. Collision requires both objects' masks to allow the pair, so either one excluding the other's layer prevents the hit.
Next: the CollisionRunner guide: presets, tunables, and per-mode wiring. Or the full end-to-end pipeline.