Files
battery/SPEC.md
2026-08-02 00:03:58 -04:00

24 KiB
Raw Permalink Blame History

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 rounded 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 <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.)
{ 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").

12.1 Per-target triggers and delays

A trap with more than one triggerable behaviour — a block can both move and phase — lets each behaviour take its own trigger and delay. The value is either:

  • a single condition/scalar (the common case), applied to every target; or
  • a per-target map keyed by target name, with default covering the rest.

Target names are disjoint from the condition keywords above, so a bare condition like { within: 2 } is never mistaken for a map. The block's targets are motion and phase.

trigger: { within: 2 }                     # both motion and phase
trigger:
  default: always                          # phase (and any other target)
  motion: { within: 5 }                    # motion only
delay: { motion: 0.1, phase: 0.3 }         # per-target arm delays
  • If a map names some targets but omits default, the unnamed targets get no trigger and never fire — a phase block with trigger: { motion: … } and no default never materialises. (In the debug view this prints a warning, since it's usually a mistake.)
  • An unknown target name raises an error at load time.
  • Each target keeps its own condition instance and arm countdown, so their timers never interfere.
  • A scalar delay applies to all targets; an unnamed target without a default delay falls back to 0.

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, crumbling blocks, and phase 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 activates (default always); a patrol is a path + always. May be a per-target map (motion / phase) — see §12.1
speed px/s (default 140)
deadly (bool) hazard (spikes) instead of a solid. On a phase block the hazard is live only while it's materialised
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)
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 — see the phasing note below
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, §12.1); in once motion, 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, non-phasing (or materialised) 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, crumble, and phasing run in that order each frame, so a block can combine them — e.g. a block that's always moving (motion: always) but only phases in when the player is near (phase: { within: N }). Motion is trigger-driven; crumble is contact-driven; phasing is trigger-driven.
  • Phasing: while the phase trigger holds the block fades in and becomes collidable (a solid, or — with deadly — a hazard that's live only once materialised); when it releases, it fades back out and goes intangible. If the player overlaps the cell the instant it starts forming, a non-deadly block is forgiving about edge clips: when they're only clipping an edge (overlap ≤ half a tile on the shallowest axis) it shoves them out and solidifies behind them, staying lethal only when it forms through their middle (a deep overlap) or the shove would press them into another solid (a crush). A deadly phase block skips the shove and simply kills. If the player dies while a phase block is mid-fade, it snaps fully visible before the death freeze (via the finalize_on_death hook — see §7/§11).

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.

(A phase block — invisible/intangible until triggered, then fades into a solid or hazard — is not a separate type: it's the block option phase: true, §13.2.)


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.