Files
battery/SPEC.md
James Campbell 59e4bcce1c Debug view improvements, warp animation
* Add a visible frame one tile beyond the normal screen in debug mode.

* Fix the display of motion paths for mounted traps in debug mode.

* Add an animation for the warp trap.
2026-07-22 11:56:21 -04:00

452 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 sideways into a wall (via the `carry` direction) → crush.
A block that stops with the player fitting underneath does **not** crush
(no residual overlap → not pinned).
### 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 <level> / <total> 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/<name>.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 §1213) |
### 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 leftright
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.) |
| `{ 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.