tilemap-parserv5.2.0GITHUB

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.

JobWho does itOwns
Where solids livePhysicsWorldthe collision tile layer, the TilesetCollision geometry, the list of Body solids, and the grid (tile_size, render_scale)
How things moveCollisionRunnerthe five move_* methods, gravity, tunables, CollisionResult
What can be movedyour spritex, 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

wiring.pyPYTHON
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_world as 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

player.pyPYTHON
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
AttributeRequiredUsed by
x, yyesposition; shape origin: top-left for RectangleShape, center for CircleShape, top cap center for CapsuleShape
collision_shapeyesprimitives only; polygon shapes use MapObject
vx, vyphysics modesmove_platformer, move_platformer_with_slide, move_grounded
on_groundplatformergrounded state, step-up, jump
collision_layer / collision_maskoptionalbody 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

MethodYou feed itGravity?Wall response
move_and_slidedisplacement delta_x, delta_ynoslide along the wall; slide_vector reports what's left
move_rpgdisplacementnofull block; a diagonal into a corner stops you dead
move_groundednothing; reads sprite.vx/vy, applies gravityyesfull block; vx zeroed on wall hit, vy zeroed on landing
move_platformerinput_x in [-1, 1] + jump_pressedyesfull block + step-up + one-way platforms
move_platformer_with_slidesame as aboveyeseverything 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.vy are set);
  • you own the velocity; the runner zeroes vx on a wall hit and vy on landing.
crate falls: you apply gravityPYTHON
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 itself

OBJECTS 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

scene.pyPYTHON
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 happens

mode 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

the push loopPYTHON
# 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.0

Walk through what happens:

  • The player is stopped by the crate's side (it's a solid), so hit_wall_x is true.
  • world.collides_with_body(player) identifies which solid, and we only push it if mode == "kinematic". Static walls never move.
  • The crate gets vx = axis * 260.0, then step 3 drives it with move_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_grounded sees a wall, sets hit_wall_x, and we zero vx. 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:

probe.pyPYTHON
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

PairMechanismNotes
sprite ↔ tilesrunner queries (all move_*)automatic
sprite ↔ bodyrunner + world.collides_with_body(sprite)automatic + hit-testing
body ↔ bodymove_grounded(body, ..., velocity=...)crates block crates
sprite ↔ spritenot the world; ObjectCollisionManagerseparate 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

FlagMeaning
collidedanything hit at all, including a landing
final_x / final_ywhere the runner parked you
hit_wall_xmovement along X was blocked
hit_wall_ymovement along Y was blocked (landing or ceiling, displacement modes)
hit_ceilinghead bonk (physics modes)
on_groundfeet on something solid
slide_vectormove_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:

hit.py: the actual rulePYTHON
(a_mask & b_layer) != 0 and (b_mask & a_layer) != 0

Defaults 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's offset). Circles: (x, y) is the center. Capsules: (x, y) is the top cap's center — the draw box is (x - radius, y - radius) sized 2r × (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_grounded treats one-way polygons as plain solid.

TRAPS, RANKED BY HOW OFTEN THEY FIRE

  1. 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.
  2. Attach before you move. A runner without attach/from_world ignores the world entirely; bodies become ghosts. from_world does both; prefer it.
  3. velocity= skips gravity. Every frame you forget to apply it, the crate hovers.
  4. Pushing the wrong mode. Only mode == "kinematic" bodies should receive velocity.
  5. The skin gap. A resting sprite can fail a static overlap query. Probe into the direction of motion.
  6. Tiles with no collision data. A tile_map without a matching tileset_collision raises ValueError at world construction, by design, so you can't ship an empty world.
  7. 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.