Full Physics World
The engine assembled end to end in one runnable mini game: a player, pushable crates, a one-way platform, and a body filtered by collision layer. Four small files, each with one job. This is docs/physics-world.md made runnable.
Assets are generated at runtime, so this example has no external asset dependencies: the first run draws a tiny spritesheet and its animation JSON into generated/ inside the example folder.
FEATURES
- Tile collision: solid ground and wall, plus a one-way platform
- PhysicsWorld assembly: tile_map, tileset collision, tile size
- Static bodies (the pillar) and kinematic bodies (the crates)
- A push loop through move_grounded with an explicit velocity
- A move_platformer player controller with runtime-generated animation
- Collision layers and masks: the pillar is on layer 2 and the player's mask excludes it
- Rendering: tiles, dashed one-way edges, hollow layer-2 bodies, animated sprite
PROJECT STRUCTURE
examples/full-physics-world/
├── main.py the game loop: input, movement, rendering
├── world.py the physics space: tiles, one-way platform, bodies
├── player.py the animated player (procedural spritesheet)
└── crate.py kinematic crate pushing through move_groundedRUNNING
pip install -e .
cd examples/full-physics-world
python main.pyARCHITECTURE
Everything flows through the world. The runner is attached to it with CollisionRunner.from_world(world), so tiles and bodies resolve together in every movement call.
SOURCE
main.py: the game loop
Input, one move_platformer call, the push hook, crate physics, then drawing. The only file with a while loop.
"""Full physics world — every collision lane wired into one runnable demo.
This is the "how the engine is intended to be assembled" example:
world.py builds the PhysicsWorld: tiles, one-way platform, bodies
player.py the animated player controller (procedural spritesheet)
crate.py kinematic crate pushing through move_grounded
main.py the game loop: input, movement, rendering
Controls: arrows / WASD to move, Space to jump, R to reset
Assets are generated at runtime into ``./generated``, so this example
has no external asset dependencies.
What to try:
1. Push a crate right — crates block each other, so a pushed crate
stops at the next one, the pillar, or the wall.
2. Walk through the hollow pillar: it is on layer 2 and the player's
collision_mask excludes layer 2, so the player passes while crates
stop.
3. Jump up through the dashed one-way platform from below, then land
on top of it.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
import pygame
from tilemap_parser import CollisionRunner
from crate import PUSH_SPEED, body_ahead, push
from player import PLAYER_H, Player
from world import COLS, GRAVITY_GROUND_Y, ROWS, TILE, build_world
SCREEN_W, SCREEN_H = COLS * TILE, ROWS * TILE
FPS = 60
ASSET_DIR = Path(__file__).resolve().parent / "generated"
def draw_tiles(screen, world):
for (tx, ty), tile_id in world.tile_map.items():
x, y = tx * TILE, ty * TILE
pygame.draw.rect(screen, (70, 70, 90), (x, y, TILE, TILE))
pygame.draw.rect(screen, (55, 55, 75), (x, y, TILE, TILE), 1)
if world.tileset_collision.tiles[tile_id].shapes[0].one_way:
# dashed top edge: you can jump up through this platform
for gx in range(x, x + TILE, 8):
pygame.draw.line(screen, (240, 220, 120), (gx, y), (gx + 4, y))
def draw_bodies(screen, world):
for body in world.bodies:
shape = body.collision_shape
rect = (
body.x + shape.offset[0],
body.y + shape.offset[1],
shape.width,
shape.height,
)
if body.mode == "kinematic":
pygame.draw.rect(screen, (230, 150, 60), rect)
pygame.draw.rect(screen, (40, 40, 40), rect, 2)
else:
# layer-2 pillar, drawn hollow: the player walks through it
x, y, w, h = (int(v) for v in rect)
for gx in range(x, x + w, 8):
pygame.draw.line(screen, (150, 150, 180), (gx, y), (gx + 4, y))
pygame.draw.line(screen, (150, 150, 180), (gx, y + h), (gx + 4, y + h))
for gy in range(y, y + h, 8):
pygame.draw.line(screen, (150, 150, 180), (x, gy), (x, gy + 4))
pygame.draw.line(screen, (150, 150, 180), (x + w, gy), (x + w, gy + 4))
def main():
pygame.init()
screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption("Full physics world")
clock = pygame.time.Clock()
font = pygame.font.SysFont("monospace", 11)
world = build_world()
runner = CollisionRunner.from_world(world)
player = Player(96, GRAVITY_GROUND_Y - PLAYER_H, ASSET_DIR)
def reset():
nonlocal world, runner, player
world = build_world()
runner = CollisionRunner.from_world(world)
player = Player(96, GRAVITY_GROUND_Y - PLAYER_H, ASSET_DIR)
running = True
while running:
dt = clock.tick(FPS) / 1000.0
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_r:
reset()
keys = pygame.key.get_pressed()
axis = (keys[pygame.K_RIGHT] or keys[pygame.K_d]) - (
keys[pygame.K_LEFT] or keys[pygame.K_a]
)
jump = keys[pygame.K_SPACE] or keys[pygame.K_UP] or keys[pygame.K_w]
if axis:
player.facing = 1 if axis > 0 else -1
result = runner.move_platformer(
player, None, None, dt, input_x=float(axis), jump_pressed=jump
)
if result.hit_wall_x and axis != 0:
block = world.collides_with_body(player)
if block is None:
block = body_ahead(world, player, axis)
if block is not None and block.mode == "kinematic":
block.vx = axis * PUSH_SPEED
push(runner, world, dt)
player.update_animation(dt)
screen.fill((35, 35, 45))
draw_tiles(screen, world)
draw_bodies(screen, world)
player.draw(screen)
lines = [
"Arrows/WASD: move Space: jump R: reset",
"Push a crate right: it stops at another crate, the pillar, or the wall.",
"The hollow pillar is layer 2: you pass, crates stop.",
"Jump up through the dashed one-way platform, then land on it.",
]
for i, line in enumerate(lines):
screen.blit(font.render(line, True, (200, 200, 200)), (4, 4 + i * 13))
pygame.display.flip()
pygame.quit()
if __name__ == "__main__":
main()
world.py: the physics space
The scene: ground, a wall, a one-way platform, three crates and the layer-2 pillar. Returns a ready PhysicsWorld.
"""world.py — the physics space: tiles, one-way platform, solid bodies.
This module owns the scene geometry: the tile layer, the tileset
collision data, and the solid bodies (pushable crates + a layer-2
pillar). Nothing moves here — movement happens in main.py through a
CollisionRunner attached to the world.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
from tilemap_parser import Body, PhysicsWorld, RectangleShape
from tilemap_parser.parser.collision import (
CollisionPolygon,
TileCollisionData,
TilesetCollision,
)
TILE = 32
COLS, ROWS = 26, 15
GRAVITY_GROUND_Y = 12 * TILE # top of the ground row (the floor line)
FULL_TILE = [(0.0, 0.0), (float(TILE), 0.0), (float(TILE), float(TILE)), (0.0, float(TILE))]
# Collision layers: the player lives on layer 1 with a mask that excludes
# layer 2, so it walks through the pillar. Crates keep the default mask
# (everything), so they stop at it. Bodies only collide when both sides'
# layer/mask agree (see docs/physics-world.md).
PILLAR_LAYER = 2
def build_world():
"""Create the world: ground, walls, platforms, crates and the pillar."""
tile_map: dict[tuple[int, int], int] = {}
for x in range(COLS):
for y in (12, 13):
tile_map[(x, y)] = 0 # ground, solid
for y in range(8, 12):
tile_map[(20, y)] = 0 # right wall
for x in range(12, 16):
tile_map[(x, 9)] = 1 # one-way platform (tile id 1)
tileset = TilesetCollision(
tileset_name="ground",
tile_size=(TILE, TILE),
tiles={
0: TileCollisionData(
tile_id=0,
shapes=[CollisionPolygon(vertices=FULL_TILE)],
),
1: TileCollisionData(
tile_id=1,
shapes=[CollisionPolygon(vertices=FULL_TILE, one_way=True)],
),
},
)
world = PhysicsWorld(
tile_map=tile_map, tileset_collision=tileset, tile_size=(TILE, TILE)
)
for x in (8, 10, 13):
world.add_body(
Body(
RectangleShape(width=TILE, height=TILE),
x=x * TILE,
y=GRAVITY_GROUND_Y - TILE,
mode="kinematic",
game_id="crate",
)
)
world.add_body(
Body(
RectangleShape(width=TILE, height=6 * TILE),
x=16 * TILE,
y=6 * TILE,
mode="static",
collision_layer=PILLAR_LAYER,
game_id="pillar",
)
)
return world
player.py: the sprite and its art
The player is a plain class with the attributes every movement function reads. The spritesheet and animation JSON are generated here.
"""player.py — the animated player: procedurally generated art + controller.
The spritesheet and its animation JSON are written into ``./generated``
the first time the example runs. Assets are generated at runtime, so
this example has no external asset dependencies.
The player is a plain class exposing the attributes a collision runner
reads: ``x``, ``y``, ``vx``, ``vy``, ``on_ground`` and
``collision_shape`` — nothing more.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
import pygame
from tilemap_parser import AnimationPlayer, RectangleShape, SpriteAnimationSet
PLAYER_W, PLAYER_H = 24, 28
CELL = 32
DURATIONS = {"idle": 450.0, "walk": 120.0, "jump": 500.0}
INK = (20, 25, 35)
BODY = (60, 200, 190)
DARK = (35, 120, 120)
VISOR = (120, 230, 250)
# (legs, blink): each leg is (x, y) of a 4x8 pixel leg within the cell
FRAMES = [
([(12, 24), (16, 24)], False), # 0 idle, eyes open
([(12, 24), (16, 24)], True), # 1 idle, eyes closed
([(10, 24), (18, 24)], False), # 2 walk, stride A
([(18, 24), (10, 24)], False), # 3 walk, stride B
([(12, 20), (16, 20)], False), # 4 jump, legs tucked
]
def build_player_assets(asset_dir: Path) -> Path:
"""Draw the 5-frame spritesheet, write the animation JSON, return its path."""
asset_dir.mkdir(parents=True, exist_ok=True)
sheet = pygame.Surface((len(FRAMES) * CELL, CELL), pygame.SRCALPHA)
for i, (legs, blink) in enumerate(FRAMES):
x = i * CELL
for lx, ly in legs:
pygame.draw.rect(sheet, INK, (x + lx - 1, ly, 6, 10))
pygame.draw.rect(sheet, DARK, (x + lx, ly, 4, 8))
pygame.draw.rect(sheet, INK, (x + 9, 7, 14, 18))
pygame.draw.rect(sheet, BODY, (x + 10, 8, 12, 16))
pygame.draw.rect(sheet, INK, (x + 11, 1, 10, 9))
pygame.draw.rect(sheet, BODY, (x + 12, 2, 8, 7))
pygame.draw.rect(sheet, INK, (x + 13, 3, 6, 5))
pygame.draw.rect(sheet, VISOR if not blink else DARK, (x + 13, 3, 6, 4))
sheet_path = asset_dir / "player.png"
pygame.image.save(sheet, str(sheet_path))
json_path = asset_dir / "player.anim.json"
json_path.write_text(
json.dumps(
{
"spritesheet_path": "player.png",
"tile_size": [CELL, CELL],
"grid_offset": [0, 0],
"animations": {
name: {
"name": name,
"frames": [
{"variant_id": fid, "duration_ms": DURATIONS[name]}
for fid in fids
],
"loop": name != "jump",
}
for name, fids in {"idle": [0, 1], "walk": [2, 3], "jump": [4]}.items()
},
},
indent=2,
)
)
return json_path
class Player:
"""Animated platformer controller (satisfies the sprite protocol)."""
def __init__(self, x: float, y: float, asset_dir: Path):
self.x = float(x)
self.y = float(y)
self.vx = 0.0
self.vy = 0.0
self.on_ground = True
self.facing = 1 # 1 = right, -1 = left
self.collision_layer = 1
self.collision_mask = 0xFFFFFFFD # everything except layer 2 (the pillar)
self.collision_shape = RectangleShape(width=PLAYER_W, height=PLAYER_H)
anim_set = SpriteAnimationSet.load(build_player_assets(asset_dir))
self.anims = {
name: AnimationPlayer(anim_set, name) for name in ("idle", "walk", "jump")
}
self._state = None
self._set_state("idle")
def _set_state(self, name: str) -> None:
if name != self._state:
self._state = name
self.anims[name].reset()
def update_animation(self, dt: float) -> None:
if not self.on_ground:
self._set_state("jump")
elif self.vx != 0.0:
self._set_state("walk")
else:
self._set_state("idle")
self.anims[self._state].update(dt * 1000)
def draw(self, screen, offset_x: float = 0.0, offset_y: float = 0.0) -> None:
frame = self.anims[self._state].get_current_image()
if self.facing < 0:
frame = pygame.transform.flip(frame, True, False)
rect = frame.get_rect(
midbottom=(self.x + PLAYER_W / 2 - offset_x, self.y + PLAYER_H - offset_y)
)
screen.blit(frame, rect)
crate.py: kinematic bodies
Bodies never move themselves. A kinematic body is moved with an explicit velocity through move_grounded, resolved by the same collision lane as the player.
"""crate.py — kinematic bodies: how a push works.
Bodies never move themselves. A ``mode="kinematic"`` body is moved by
the game with an explicit velocity, resolved through the same collision
lane as the player: ``move_grounded(crate, None, None, dt,
velocity=(vx, 0))``. Tiles and other bodies stop it in that one call.
"""
import copy
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
PUSH_SPEED = 260.0
def crates(world):
"""All kinematic bodies in the world (the pushable ones)."""
return [b for b in world.bodies if b.mode == "kinematic"]
def body_ahead(world, sprite, axis, probe=8.0):
"""Find the body the sprite is pressed against.
The runner stops a sprite just *short* of a body (a sub-pixel skin
gap), so a resting-position ``collides_with_body`` check can miss
it. Probe a few pixels into the push direction instead.
"""
s = copy.copy(sprite)
s.x = sprite.x + axis * probe
return world.collides_with_body(s)
def push(runner, world, dt):
"""Give every sliding crate one frame of velocity, collision-resolved."""
for crate in crates(world):
if crate.vx:
result = runner.move_grounded(
crate, None, None, dt, velocity=(crate.vx, 0.0)
)
if result.hit_wall_x:
crate.vx = 0.0
else:
crate.vx *= 0.9
if abs(crate.vx) < 1.0:
crate.vx = 0.0
READING ORDER
- main.py the loop. This is where everything is wired: input, movement, the push hook, drawing.
- world.py the space. It owns the tile layer, the tileset collision data and the bodies; nothing moves here.
- player.py the sprite contract. A plain class with the attributes every movement function reads, plus procedural art.
- crate.py kinematic bodies. How a push works, and why bodies never move themselves.
Then the two guides this example builds on: Physics & Bodies for the object contract and CollisionRunner for the movement presets. The [source] lives in the repo.