# Dying Phone — Engine & Level-Format Specification This document describes the game engine and the level configuration format in enough detail to reimplement equivalent functionality from scratch. It does **not** describe the specific shipped levels — only the mechanics and the data format levels are written in. --- ## 1. Concept A single-screen 2D platformer. Your phone is dying; each level's goal is to reach the charger before the battery runs out. Levels are dense with obvious and (mostly) hidden traps. Touching a hazard, falling out of the world, or running the battery to zero kills you and instantly respawns you at the start with every trap reset. There is no scrolling — each level fits one screen. Built with Python + pygame. Art is optional: any missing sprite is drawn as a labeled colored rectangle, so the game is fully playable with zero assets. --- ## 2. Runtime, entry point, CLI - Dependencies: `pygame` (>= 2.5), `PyYAML`. - Entry point `main.py`: - `python main.py` — play every `*.yaml`/`*.yml` in `./levels`, sorted. - `python main.py a.yaml b.yaml …` — play the given level files in order. - `--debug` flag (anywhere in args) — force the debug view on for all levels. - The game plays levels in sequence; finishing the last shows a win screen, and ENTER restarts from the first level. --- ## 3. Coordinates, tiles, window - The world is a grid of square tiles. Tile size defaults to **32 px** and can be overridden per level (`tile_size`). - Grid coordinates are `[col, row]`, 0-based from the top-left of the map. A cell's pixel rect is `(col*tile, row*tile, tile, tile)`. - Y increases downward (screen convention). - The window is sized **once** at startup to fit the tallest and widest level (plus a HUD band of 46 px on top; minimum window width 480 px; plus the debug overscan margin of §14 for any level shown in debug). It is never resized afterward — recreating the display mid-session makes the OS window flicker/close. Each level's play area is a surface of its own pixel size, centered within the fixed window below the HUD; smaller levels are letterboxed with a dark background. --- ## 4. Game loop & states Fixed logical timestep driven by `pygame.time.Clock` at 60 FPS. Each frame `dt` is clamped to a max of `1/30` s to avoid tunneling on lag spikes. Per frame: poll input → run the current state's update → draw. States: - **playing** — normal simulation (battery drains, level & player update, death and goal checks run). - **dying** — death pause: the whole scene is frozen (no level/player update); a timer counts down `DEATH_PAUSE` (0.5 s) showing the corpse, then respawns. - **charging** — level-clear beat: everything frozen while the battery-fill animation plays for `CHARGE_TIME` (1.0 s); then transitions to a win splash. - **won_level** / **won_all** — win splash overlays (per-level vs final). - **fading** — a fade-to-black transition between levels (see §8). Input is edge- and level-sensitive: - `A`/`D` or arrows → move; `S`/down → down (used for dropping through one-ways); `Space`/`W`/`Up` → jump (both an edge `jump_pressed` and a held `jump_held`). - `R` → give up (triggers a death). `Esc` → quit. `Enter` → advance/replay on a win splash. - `F5` → hot-reload the current level file from disk. It is treated as a death: it plays the death beat and increments both counters (so it also escapes soft-locks), and on the following respawn the level is re-read from its file (picking up any edits) instead of merely reset. A malformed edit is caught and logged rather than crashing (the old level is kept). The per-level death count carries across the reload. --- ## 5. Player physics The player is an axis-aligned rectangle sized `0.72*tile` wide × `0.92*tile` tall. Position is tracked as floats (`fx, fy`); the integer collision rect is `round`ed from them each substep. ### 5.1 Tunables (pixels, seconds) | Constant | Value | Meaning | |---|---|---| | `GRAVITY` | 2200 | downward acceleration (px/s²) | | `MAX_FALL` | 1400 | terminal downward velocity | | `MOVE_SPEED` | 320 | horizontal run speed | | `ACCEL` | 3200 | ground acceleration toward target speed | | `AIR_ACCEL` | 2200 | weaker air control | | `FRICTION` | 3600 | ground deceleration when no input | | `JUMP_SPEED` | 760 | initial upward velocity of a jump | | `JUMP_CUT` | 0.45 | fraction of upward velocity kept if jump released early | | `COYOTE_TIME` | 0.10 | grace window to jump after leaving a ledge | | `JUMP_BUFFER` | 0.10 | how long a jump press is remembered before landing | ### 5.2 Update order (per frame) 1. **Ride platforms** — if standing on a moving platform (feet within 3 px of a carrier's top, with horizontal overlap), add that carrier's per-frame motion `(dx, dy)` to the player and record it as `carry`. 2. **Push by movers** — if a block moving horizontally overlaps the player's side (with vertical overlap ≥ half the player height), displace the player out the *nearer* horizontal edge so the block shoves them along that axis (rather than the vertical pass burying them). 3. **Horizontal** — accelerate `vx` toward the input target (or apply friction), integrate `fx`, then resolve X collisions. 4. **Vertical** — update jump timers, apply jump/gravity, integrate `fy`, then resolve Y collisions. 5. **Crush check** — set `crushed` (see §5.6). ### 5.3 Jumping - **Buffered**: a jump press within `JUMP_BUFFER` before landing still fires on landing. - **Coyote**: a jump within `COYOTE_TIME` after walking off a ledge still fires. - **Variable height**: releasing jump mid-rise multiplies remaining upward velocity by `JUMP_CUT` once (tap = short hop, hold = full jump). ### 5.4 Collision resolution (minimum-translation, per axis) Movement and resolution are done one axis at a time (move X → resolve X → move Y → resolve Y). Resolution chooses the side by **least penetration**, not by velocity sign, so clipping a block's corner never warps the player across it. - **X pass**: for each overlapping solid, compute horizontal penetration from each side and the vertical penetration. Only resolve horizontally if the horizontal overlap is the *smaller* one (otherwise it's really a vertical collision — leave it for the Y pass, so a block on top can't be dodged by squirting out sideways). Push out the nearer horizontal edge; zero `vx`. - **Y pass**: for each overlapping solid, resolve toward the nearer of top/ bottom edge. Landing on a top sets `on_ground`; zero `vy`. ### 5.5 One-way platforms Separate from solids. During the Y pass, when falling (`vy >= 0`) and not in a drop-through, the player lands on a one-way **only if its feet just crossed the platform's top edge this frame** (penetration between 0 and roughly the per-frame fall distance). Jumping up through them is unobstructed. Holding down + pressing jump sets a short drop-through timer (~0.12 s) that suppresses one-way landing so the player falls through. ### 5.6 Crush death A crush is a moving block pinning the player against a solid on the opposite side. Detection probes a thin strip on each side of the player for a backing solid, checks whether the player is *pinned* (still overlapping a solid after resolution), and whether a moving carrier is closing in on the appropriate side: - Moving down onto a floored player (backed below + pinned) → crush. - Moving up into a player under a ceiling (backed above + pinned) → crush. - Moving horizontally into a player backed by a wall → crush. - Being carried *up* into a ceiling while riding a platform → crush (the rising carrier under the feet is caught by the "moving up" case above). A block that stops with the player fitting underneath does **not** crush (no residual overlap → not pinned). Riding a platform *horizontally* into a wall is **not** a crush: the carrier is under the feet, perpendicular to the wall, so it can't pin the player against it — the X pass just stops them at the wall edge while the platform slides on underneath. ### 5.7 Death conditions (in `playing`) - Battery ≤ 0. - Player fell out of the world (top below the level height + 160 px). - `crushed` is true. - Player rect overlaps any hazard rect. Reaching the goal rect wins the level. --- ## 6. Battery / timer - Each level has a time limit `battery_seconds` (default 45). It counts down only while `playing`; hitting zero is a death. - The battery **bar** is visual-only and reads near-empty from the start to match the dying-phone theme: it fills to `battery_pct` percent (default 12) at full time and drains proportionally to zero. The exact seconds remaining are always shown as text next to the bar, so the bar can be a thin "almost dead" sliver without losing information. --- ## 7. Death, respawn, counters - On death: increment a **per-level** counter and a **session-total** counter, snapshot the player position as a corpse, call each trap's `finalize_on_death()`, and enter `dying` (scene frozen, traps *not yet* reset — they linger for the pause). A red tint fades over the pause. - After `DEATH_PAUSE` seconds: respawn the player at the level spawn, reset all traps, refill the battery, return to `playing`. - The HUD shows `deaths / total`. The per-level count resets when a level loads; the session total persists across levels and resets only on replay after clearing everything. --- ## 8. Level-complete flow On reaching the goal: 1. **charging** state (scene frozen). The battery widget detaches from the HUD, flies to screen-center while growing (~2.7×, ease-out over the first ~40% of the beat), the background dims, and the battery fills from its near-empty finish level up to 100% (a live percentage is shown). Lasts `CHARGE_TIME`. 2. **won_level** / **won_all** splash: the enlarged, full battery stays centered with a "phone charged!" title above and death counts below. 3. On ENTER: a **fade** transition — snapshot the splash frame, dip it to black over `FADE_TIME` (0.3 s), swap the level at full black, then fade the new level in over `FADE_TIME`. `won_all` → ENTER replays from level 1 with a fresh session total. --- ## 9. Assets - `AssetStore.get(name, w, h, angle=0)` returns a surface of exactly `(w, h)` for a sprite name. - If `assets/.png` exists, it is loaded, optionally **rotated** counter-clockwise by `angle` degrees, then **nearest-neighbour scaled** to `(w, h)` (keeps pixel art crisp). Results are cached by `(name, w, h, angle)`. - Otherwise a placeholder is drawn: a colored rect (per-sprite color + optional label) with a dark border. Placeholders are not rotated. - Sprite names used by the engine: `player`, `player_dead`, `block`, `goal`, `fake_block`, `spike`, `moving_block`, `patrol_block`, `crumble_block`, `arrow_shooter`, `arrow`, `spike_block`, `phase_block`. - Directional sprites (spikes) are rotated by direction: up=0°, left=90°, down=180°, right=−90°. --- ## 10. Level file format (YAML) A level is one YAML document. ### 10.1 Top-level keys | Key | Default | Meaning | |---|---|---| | `name` | filename | display name | | `tile_size` | 32 | pixels per tile | | `battery_seconds` | 45 | time limit | | `battery_pct` | 12 | how full the battery bar *looks* at start (visual only) | | `debug` | false | reveal everything normally hidden (see §14) | | `map` | "" | ASCII map (a YAML block scalar) | | `traps` | [] | list of trap specs (see §12–13) | ### 10.2 Map legend The `map` is rows of characters. A leading blank line is ignored; rows may be ragged (width = longest row). | Char | Meaning | |---|---| | `#` | solid block | | `-` | one-way platform (stand on top; jump/drop through) — rendered **identically** to a solid block, so it's a hidden mechanic | | `P` | player spawn (feet at the cell's bottom, centered) | | `G` | goal / charger | | `.` or space | empty | Static solids and one-ways come only from the map. Everything dynamic or hidden is a trap. --- ## 11. Trap framework Every trap subclasses a base `Trap`. The engine polls a small set of hooks each frame; a trap implements only the ones it needs. ### 11.1 Geometry hooks (default: contribute nothing) - `solid_rects()` → rects that fully block movement. - `oneway_rects()` → rects that block only from above. - `hazard_rects()` → rects that kill the player on contact. - `carriers()` → `(rect, dx, dy)` for moving platforms the player can ride (`dx, dy` = movement this frame). - `current_rect()` → the trap's live footprint (defaults to its cell; movers override so mounts and sensors can follow it). - `sensor_rect()` → where its trigger senses the player from (defaults to `current_rect()`). ### 11.2 Lifecycle - `update(dt, game)` — advance state (has access to `game.player`). - `draw(surface, assets)` — visible appearance. - `reset()` — return to initial state (called on player death / level load). - `finalize_on_death()` — settle to a final look the instant the player dies, before the scene freezes (e.g. a phase block mid-fade snaps fully visible). ### 11.3 Universal properties - Every trap takes `at: [col, row]`. Where a trap draws its own block, leave that map cell empty (otherwise a real solid sits under it). - **`invisible: true`** — any trap can be invisible: it stays fully functional but is not drawn (only shown under the debug view). `warp` hard-codes this. - **`count: [nx, ny]`** (or a single int → a horizontal line) — expand the spec into an nx-by-ny line/grid of copies at level load, each offset by **`spacing: [sx, sy]`** tiles (default 1). Only `at` is shifted per copy (so a block's `move` is relative and works; an absolute `path` is shared, so arrays suit stationary/simple traps). A rectangle of invisible solid blocks is how you make an invisible wall. ### 11.4 Mounting (traps riding traps) A trap may carry a `mounts:` list of child trap specs. A mount's `at` is read as a **relative** offset (in tiles) from its parent. Each frame the parent repositions each mount to track its own live position; mounts may nest arbitrarily. Most mounts **ride rigidly** — they hold their offset and run no motion of their own (spikes, turrets, stationary deadly blocks). A **`block` mount that has its own `path`/`move` is mobile**: its motion runs in a coordinate frame *relative to the parent*, so it both rides along with the parent **and** performs its own stroke/patrol. For example, a block mounted on a platform that patrols left–right can itself lunge upward (a triggered `once` slider) to catch a player leaping over, while continuing to drift sideways with its carrier; spikes mounted on that block ride it in turn. A mobile mount's `carriers()` reports its **total** motion (parent drift + its own move) so a player standing on it is carried correctly, and its `home` trigger sensor tracks the resting cell as it rides along. The engine aggregates a trap and everything mounted on it via `all_solid_rects()`, `all_oneway_rects()`, `all_hazard_rects()`, `all_carriers()`, plus `tick()`/`render()`/`reset_all()` wrappers. --- ## 12. Triggers `spike`, `block`, and `arrow_shooter` are "active" while a **trigger condition** holds. A trigger is the string `always` (default) or a condition object, evaluated against the player each frame relative to the trap's `sensor_rect()`. | Condition | Meaning | |---|---| | `{ within: N }` | player within N tiles of the sensor center (radius) | | `{ dir: left\|right\|above\|below }` | player is on that side | | `{ dir: …, range: N }` | …and within N tiles in that direction | | `{ dir: …, aligned: true }` | …and overlapping on the perpendicular axis (directly left / directly above, etc.) | | `{ dir: …, inclusive: true }` | …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 for A s, then on for B s, repeating | | `{ all: [ … ] }` | AND of sub-conditions | | `{ any: [ … ] }` | OR of sub-conditions | Composites evaluate *all* children each frame (so nested timers keep ticking). **`delay`** (seconds, default 0): the condition must hold *continuously* this long before the trap arms; leaving the condition resets the countdown. This is arm hysteresis (e.g. "linger and it strikes"). --- ## 13. Trap catalog ### 13.1 `spike` A deadly half-tile spike on one edge of its cell, active while its `trigger` holds. - `direction`: `up`/`down`/`left`/`right` — which edge it sits on; the hazard is the half-cell along that edge, and the sprite auto-rotates to match. - `trigger`, `delay` as in §12. ### 13.2 `block` — the all-in-one block One trap covering stationary blocks, sliders, patrolling platforms, spike blocks, fake blocks, and crumbling blocks. Options combine. | Option | Meaning | |---|---| | `path: [[col,row],…]` | waypoints it travels between (default `[at]` = stationary) | | `move: [dcol,drow]` | shorthand for a 2-point path `[at, at+move]` (a slider) | | `mode` | `once` (default): extend to the last waypoint while triggered, retreat to the first when not — slider/dropper. `loop` / `pingpong`: cycle the whole path continuously — a patrol. | | `trigger` | when it moves (default `always`); a patrol is a path + `always` | | `speed` | px/s (default 140) | | `deadly` (bool) | hazard (spikes) instead of a solid | | `fake` (bool) | drawn like a solid block but non-collidable (you fall through) | | `crumble` (bool) + `crumble_delay`, `respawn` | gives way `crumble_delay` s after you stand on it, vanishes, then re-forms after `respawn` s (killing you if you're standing where it re-forms) | | `sprite` | override sprite (default: `spike_block` if deadly, `fake_block` if fake, `crumble_block` if crumble, else `moving_block`) | | `delay` / `release` | (`once`) hysteresis: hold `delay` s to start extending, be clear `release` s (default 0.1) to start retracting | | `sense` | `home` (default): sense the trigger from the resting cell; `current`: sense from the live position (for blocks you ride, so they stay put while ridden) | Behavior notes: - **Committed stroke** (`once`): once a block starts moving it runs to the endpoint without reversing, and only reconsiders its trigger while parked at an endpoint. Combined with `home` sensing, this eliminates the jitter a slider would otherwise get from moving out of its own sensor range. - A non-deadly, non-fake block is a **solid** that reports itself as a **carrier** so the player rides it; a moving block can **shove** or **crush** the player (§5.2, §5.6). - Movement runs first each frame, then the crumble state machine, so a moving platform can also crumble. ### 13.3 `arrow_shooter` A wall turret that fires a deadly projectile every `interval` seconds while its `trigger` holds. - `direction` (up/down/left/right), `speed`, `interval`, `trigger`, `delay`. - Arrows are hazards that travel until off the (inflated) level bounds or until they hit a static solid. (An invisible wall is not a separate type — declare an array of invisible solid blocks: `type: block, invisible: true, count: [w, h]`.) ### 13.4 `warp` An invisible tile that teleports the player to `to: [col, row]` on contact (zeroing velocity). It re-arms only once the player has left the tile, so it fires once per entry. On activation a brief **aura** flashes at both the source and destination tiles and fades out over ~0.35 s (expanding/thinning rings drawn procedurally, no sprite), so the teleport reads on screen even though the tile itself is invisible. ### 13.5 `phase_block` Invisible and intangible until its `trigger` fires, then it fades into a solid over `fade` seconds (alpha 0→1) and fades back out when the trigger releases. If the player overlaps the cell the instant it *starts* forming, it's forgiving about edge clips: when they're only clipping an edge (overlap ≤ half a tile on the shallowest axis) it shoves them out of the cell and solidifies behind them. It stays lethal only when the block forms through the player's middle (a deep overlap) or the shove would press them into another solid (squished against something — a crush, as usual). In the lethal case it stays intangible that frame so they're killed rather than displaced. If the player dies while it is mid-fade, it snaps fully visible before the death freeze (via the `finalize_on_death` hook — see §7/§11). --- ## 14. Debug view `debug: true` on a level (or the `--debug` CLI flag, which forces it on for all levels) reveals everything normally hidden: - a faint tile grid with column/row labels (so `at: [col,row]` is eyeballable), - one-way platforms are faded (distinguishable from solids), - fake blocks tinted, invisible walls tinted, warps tinted with a line to their destination, dormant phase blocks ghosted, not-yet-sprung spikes marked, - crumbled-away blocks shown as ghosts, - moving blocks draw their travel path (waypoints + connecting lines), - an **overscan margin** of `DEBUG_VIEW_MARGIN` tiles (default 1) is revealed on every side, so geometry just off the map — e.g. an invisible catch-wall a step off-screen — is visible instead of clipped. The play surface is enlarged and all drawing is shifted into it (`Level.render_offset`, a draw-only translation; physics stays in true coordinates). The off-map region is shaded, its grid labels run negative / past the map, and the **true runtime viewport is outlined** so it's clear what's actually on screen during play. The window is sized at startup to include this margin for any level shown in debug. --- ## 15. Module layout ``` main.py entry point + CLI arg parsing game/ settings.py all tunables (physics, timing, colors, placeholder table) assets.py sprite loading with rotation + placeholder fallback level.py YAML -> geometry (solids/one-ways/spawn/goal) + trap list player.py player physics, collision resolution, crush/carry/push traps.py trigger conditions, base Trap + mounting, all trap classes game.py window, game loop, states, HUD, death/charge/fade flow tools/gen_sprites.py regenerates the default pixel-art PNGs into assets/ levels/*.yaml level definitions (data, not part of this spec) tests/ pytest suite mirroring the behavior described here ``` A trap type is added by subclassing `Trap`, implementing the hooks it needs, and registering it in the `TRAP_TYPES` factory map in `traps.py` — nothing else in the engine needs to change.