Dying Phone
A single-screen 2D platformer in the spirit of the original Super Mario Bros., built with Python + pygame. Your phone is dying; sprint to the charger at the end of each level before the battery hits zero. The catch: the level is packed with obvious and (mostly) hidden traps. Touch one and you respawn instantly at the start with every trap reset. The battery is just the story's excuse for a timer — running it down kills you the same as any spike.
Install & run
pip install -r requirements.txt
python main.py # play every level in ./levels
python main.py levels/level1.yaml # play a specific level (or several)
python main.py --debug # force the debug view on for all levels
No art required — everything renders as labeled colored rectangles until you
drop PNGs into assets/ (see assets/README.md).
Tests
pip install -r requirements-dev.txt
pytest
The suite runs headless (SDL dummy driver) and covers the physics, collision
resolution, every trap and trigger, mounting, crush/shove interactions, and the
game-flow states. See SPEC.md for a full description of the engine and level
format.
Controls
| Key | Action |
|---|---|
A / D (arrows) |
move left / right |
Space / W |
jump (hold = higher, tap = short hop) |
S + Space |
drop through one-way platforms |
R |
give up and respawn |
F5 |
hot-reload the level from disk (counts as a death; also escapes soft-locks) |
Esc |
quit |
Enter |
advance on the level-complete screen |
Writing levels
Levels are plain YAML. A level has an ASCII map for static geometry and a
traps list for the nasty interactive bits.
name: "My Level"
tile_size: 32 # pixels per tile (optional; default 32)
battery_seconds: 40 # time limit (optional)
map: |
.........
...###...
.P.....G.
#########
traps:
- type: spike
at: [4, 2] # [col, row], 0-based from top-left
direction: up
trigger: { within: 2.0 }
Map legend
| Char | Meaning |
|---|---|
# |
solid block |
- |
one-way platform — stand on top, jump/drop through. Renders identically to a solid block, so it's a hidden mechanic. |
P |
player spawn |
G |
goal / phone charger |
. or space |
empty |
The window is sized to the map automatically — one screen, no scrolling.
Level options (all optional, alongside name / map / traps):
| Key | Default | Meaning |
|---|---|---|
tile_size |
32 | pixels per tile |
battery_seconds |
45 | time limit |
battery_pct |
12 | how full the battery bar looks at the start (visual only) |
debug |
false | reveal everything normally hidden — a tile grid with col/row labels, faded - one-way platforms, tinted fake blocks / invisible walls / warps (with a line to their destination), dormant phase blocks, not-yet-sprung spikes, crumbled-block ghosts, and moving block paths. The --debug CLI flag forces this on for every level. Combine with F5 to iterate on a level without relaunching. |
Trap reference
All traps take at: [col, row]. Where a trap draws its own block (fake, moving,
patrol, crumble, shooter), leave that map cell empty — otherwise a real
solid sits underneath it.
Any trap can also take:
invisible: true— stays fully functional but isn't drawn (revealed under the level'sdebugview). An invisible solidblockis a wall you can't see.count: [nx, ny](or a single int for a horizontal line) — place a line/grid of copies, offset byspacing: [sx, sy]tiles (default 1). Onlyatis shifted per copy, somoveis relative and works; an absolutepathis shared. Handy for a row of spikes or a rectangle of (invisible) blocks.
type |
Key parameters | Behavior |
|---|---|---|
spike |
direction (up/down/left/right), trigger, delay |
Deadly spike; active while its trigger holds. The sprite auto-rotates to point the way direction faces. |
block |
see below | The all-in-one block: stationary, sliding, patrolling, deadly, fake, and/or crumbling. |
arrow_shooter |
direction, speed, interval, trigger, delay |
A wall turret firing deadly arrows every interval seconds while its trigger holds. |
warp |
to [col,row] |
Invisible tile that teleports the player to to on contact. Re-arms once you leave it. |
phase_block |
trigger, fade |
Invisible and intangible until its trigger fires, then fades into a solid over fade seconds (and fades back out when the trigger releases). If you're standing in the cell the instant it starts appearing, you die — and if you die while one is mid-fade it snaps fully visible for the death freeze. |
The block trap
One trap covers stationary blocks, sliders, patrolling platforms, spike blocks, fake blocks, and crumbling blocks — compose the behaviour from options (which combine, e.g. a moving platform that crumbles):
| option | meaning |
|---|---|
path [[col,row],…] |
waypoints it travels between (default just [at] = stationary) |
move [dcol,drow] |
shorthand for a 2-point path [at, at+move] (a slider) |
mode |
once (default): advance to the last point while triggered, retreat to the first when not — the slider/dropper. loop / pingpong: cycle the whole path continuously (a patrol). |
trigger |
when it moves (default always). A patrol is just a path + the default always trigger. |
deadly (bool) |
true → a hazard (spikes) instead of a solid |
fake (bool) |
looks solid but you fall straight through it |
crumble (bool) + crumble_delay, respawn |
gives way crumble_delay s after you stand on it, vanishes, then reappears after respawn s (killing you if you're standing where it re-forms) |
speed |
px/s |
sprite |
override sprite (default: spike_block if deadly, fake_block if fake, crumble_block if crumble, else moving_block) |
delay / release |
(once) hysteresis: the trigger must hold delay s to start extending and be clear release s (default 0.1) to start retracting — stops boundary jitter |
sense |
home (default): a slider senses its trigger from its resting cell, so moving away can't toggle its own trigger. current: senses from the live position — for a block you ride (e.g. a dropper) so it stays put while ridden instead of pulling back |
- type: block # stationary spike block
at: [13, 4]
deadly: true
- type: block # dropper: extends down while you're above it, rides with you
at: [21, 9]
move: [0, 4]
sense: current
trigger: { all: [ {within: 5}, {dir: above, aligned: true} ] }
- type: block # patrolling platform
at: [9, 10]
path: [[9, 10], [14, 10], [14, 6]]
mode: loop
sprite: patrol_block
A once block runs a committed stroke: once it starts moving it runs all the
way to the endpoint (never reversing mid-stroke) and only reconsiders its
trigger while parked — so a slider that moves out of its own sensor range can't
buzz. Blocks track their live position, so a moving block can shove or crush
you, and you can ride a non-deadly one.
Triggers
spike, block, and arrow_shooter are active while a trigger
condition holds. A trigger is always (the default) or a condition object
measured against the player each frame, relative to the trap's current
position (so a moving trap's sensors follow it):
| condition | meaning |
|---|---|
{ within: N } |
player within N tiles (radius) |
{ dir: left|right|above|below } |
player is on that side |
{ dir: …, range: N } |
…and within N tiles that way |
{ dir: …, aligned: true } |
…and overlapping on the perpendicular axis (i.e. directly left / directly above) |
{ timer: { interval: A, up_time: B } } |
cyclic: off A seconds, on B seconds |
{ all: [ … ] } |
every listed condition (AND) |
{ any: [ … ] } |
any listed condition (OR) |
# drops only when you're close AND standing over it; keeps dropping as you ride it
trigger:
all:
- { within: 5 }
- { dir: above, aligned: true }
delay (seconds, default 0): the trigger condition must hold
continuously this long before the trap arms; leaving the condition resets the
countdown. Handy for "linger and it strikes" spikes.
Getting crushed
You also die if a moving (non-deadly) block pinches you: presses you
against a solid surface — squished into the ground from above, carried up into a
ceiling, or ridden sideways into a wall. During the death pause the whole scene
freezes, so the offending block stops on the spot until you respawn.
Mounting traps on other traps
Any trap can carry a mounts: list of child traps that ride along with it. A
mounted trap's at is read as a relative offset (in tiles) from its parent,
and it tracks the parent's live position every frame — so you can bolt a spike
onto a patrolling platform, or a turret onto a moving block:
- type: block
path: [[6, 10], [9, 10]]
mode: pingpong
sprite: patrol_block
mounts:
- type: spike
at: [0, -1] # one tile above the block; moves with it
trigger: always
direction: up
Everything the mounted trap does — hazards, solids, trigger sensing, drawing —
follows the parent automatically; no special-casing per trap type. You can mount
a block too (e.g. deadly blocks at the ends of a moving platform for spikes on
a moving hazard) — a mounted trap rides rigidly at its offset and doesn't
run its own motion, so mount stationary pieces (spikes, blocks, turrets) rather
than something you expect to move on its own.
Adding a new trap type
Subclass Trap in game/traps.py, implement any of the hooks
(solid_rects, oneway_rects, hazard_rects, carriers, update, draw,
reset), and register the class in TRAP_TYPES. The engine polls those hooks
every frame — nothing else needs to change.
Project layout
main.py entry point
game/
game.py loop, states, HUD, death/respawn/win flow
level.py YAML -> geometry + traps
player.py movement & AABB collision
traps.py trap base class + all trap types + factory
assets.py PNG loading with colored-rect placeholders
settings.py physics/gameplay tunables
levels/*.yaml level definitions
assets/*.png optional sprites