# 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 ```bash 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 ```bash 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. ```yaml 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's `debug` view). An invisible solid `block` is a wall you can't see. - **`count: [nx, ny]`** (or a single int for a horizontal line) — place a line/grid of copies, offset by **`spacing: [sx, sy]`** tiles (default 1). Only `at` is shifted per copy, so `move` is relative and works; an absolute `path` is 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 | ```yaml - 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) | | `{ 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) | ```yaml # 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 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: ```yaml - 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 ```