24 KiB
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/*.ymlin./levels, sorted.python main.py a.yaml b.yaml …— play the given level files in order.--debugflag (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/Dor arrows → move;S/down → down (used for dropping through one-ways);Space/W/Up→ jump (both an edgejump_pressedand a heldjump_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)
- 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 ascarry. - 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).
- Horizontal — accelerate
vxtoward the input target (or apply friction), integratefx, then resolve X collisions. - Vertical — update jump timers, apply jump/gravity, integrate
fy, then resolve Y collisions. - Crush check — set
crushed(see §5.6).
5.3 Jumping
- Buffered: a jump press within
JUMP_BUFFERbefore landing still fires on landing. - Coyote: a jump within
COYOTE_TIMEafter walking off a ledge still fires. - Variable height: releasing jump mid-rise multiplies remaining upward
velocity by
JUMP_CUTonce (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; zerovy.
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).
crushedis 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 whileplaying; 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_pctpercent (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 enterdying(scene frozen, traps not yet reset — they linger for the pause). A red tint fades over the pause. - After
DEATH_PAUSEseconds: respawn the player at the level spawn, reset all traps, refill the battery, return toplaying. - 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:
- 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. - won_level / won_all splash: the enlarged, full battery stays centered with a "phone charged!" title above and death counts below.
- 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 overFADE_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>.pngexists, it is loaded, optionally rotated counter-clockwise byangledegrees, 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.
- If
- 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 tocurrent_rect()).
11.2 Lifecycle
update(dt, game)— advance state (has access togame.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).warphard-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 byspacing: [sx, sy]tiles (default 1). Onlyatis shifted per copy (so a block'smoveis relative and works; an absolutepathis 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").
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
defaultcovering 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 — aphaseblock withtrigger: { motion: … }and nodefaultnever 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
delayapplies to all targets; an unnamed target without adefaultdelay falls back to0.
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,delayas 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 withhomesensing, 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
phasetrigger holds the block fades in and becomes collidable (a solid, or — withdeadly— 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). Adeadlyphase 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 thefinalize_on_deathhook — 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_MARGINtiles (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.