12 KiB
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, crumbling, and/or phasing. |
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. |
The block trap
One trap covers stationary blocks, sliders, patrolling platforms, spike blocks, fake blocks, crumbling blocks, and phase blocks — compose the behaviour from options (which combine, e.g. a moving platform that crumbles, or a phase block that's always moving):
| 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 activates (default always). A patrol is just a path + the default always trigger. A block that both moves and phases can give each its own trigger via a per-target map (see Triggers). |
deadly (bool) |
true → a hazard (spikes) instead of a solid. On a phase block the hazard is live only once materialised. |
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) |
phase (bool) + fade |
invisible/intangible until triggered, then fades into a solid (or a hazard, if deadly) over fade s (default 0.3) and back out when the trigger releases. Forming into you is lethal, but a non-deadly one first shoves you clear if you're only clipping an edge; if you die while one is mid-fade it snaps fully visible for the death freeze. |
speed |
px/s |
sprite |
override sprite (default: spike_block if deadly, fake_block if fake, crumble_block if crumble, phase_block if phase, else moving_block) |
delay / release |
hold delay s to activate (may be a per-target map); in once motion, 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
- type: block # deadly block that patrols AND phases in near you
at: [9, 3]
phase: true
deadly: true
move: [4, 0]
trigger:
motion: always # always sliding back and forth
phase: { within: 3 } # but only solid/deadly when you're close
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) |
{ dir: …, inclusive: true } |
…also count the trap's own tile as being on that side (default false: the player must be strictly past the near edge) |
{ 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.
Per-target triggers. A block can both move and phase, and each behaviour
can take its own trigger (and delay). Give a single condition to drive both,
or a map keyed by target (motion / phase) with default covering the rest:
trigger:
default: always # phase (and anything else)
motion: { within: 5 } # motion only
delay: { motion: 0.1, phase: 0.3 }
A map that names some targets but omits default leaves the unnamed ones with
no trigger (off) — the debug view warns if that silently disables a capability.
An unknown target name is an error.
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 driven sideways into a wall by a block hitting you from the side.
Riding a platform horizontally into a wall is not a crush, though: you simply
stop at the wall while the platform slides on under your feet. 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