457 lines
22 KiB
Markdown
457 lines
22 KiB
Markdown
# 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 <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 §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.
|