Markdown 240 lines
# Level file legend
Levels are plain-text grids. Each cell is one tile (`64 px`). The map
is padded with wall on the right, so rows don't have to be the same
length — but keeping them aligned makes editing sane.
## Where levels live
There are two kinds of level and you never have to touch code to add
one:
* **Built-in** — `.txt` files in `assets/levels/`, listed in
`assets/levels/manifest.json`. To add one: drop the file in, add an
entry to the manifest (`id`, `file`, `title`, `tagline`, and the
optional `music` track name — see Audio). It appears in the level
menu on next launch. No Python edits.
* **Custom** — anything saved by the in-game **Editor** lands in
`~/.the-way-out/custom_levels/<name>.txt` and is auto-discovered (no
manifest entry needed). These show a `CUSTOM` pill in the level menu.
`level_catalog.py` is the single source of truth that merges both;
`tiles.py` (`REGISTRY`) is the single source of truth for the tile
vocabulary below — the editor palette and the level loader both read
it, so this table, the registry and the editor never drift apart.
## In-game Level Editor
Main menu → **Editor**. Build a level visually and play-test it
instantly.
| Action | Bind |
|----------------------|---------------------------------------|
| Place selected tile | Left mouse (hold to drag-paint) |
| Clear to floor | Right mouse (hold to drag-erase) |
| Box fill / box erase | Shift + drag left / right mouse |
| Eyedropper | `Q` or **Pick**, then click a cell |
| Wall the outer ring | **Border** button |
| Select a tile | Click it in the right-hand palette |
| Cycle prop variant | Mouse wheel |
| Pan the canvas | WASD / Arrow keys |
| Grow / shrink grid | `+W/-W` `+H/-H` buttons in the toolbar|
| Rename file | Click the FILE box, type, Enter |
| Save | Ctrl+S or the **Save** button |
| Save & play-test | F5 or the **Test** button |
| Back to menu | Esc |
`P` (player start) and `X` (exit) are singletons — placing a new one
removes the old. Save warns (but never blocks) if there's no `P`/`X`
or if trigger/gate counts look mismatched, so you can iterate freely.
Test launches with the character last picked in **Characters** and
returns you to the editor when the run ends.
## Row formats
There are two ways to write a row; you can mix formats between rows in
the same file.
1. **Dense** (legacy) — one character per cell, no spaces:
```
WWWWWWWW
W..P...W
WWWWWWWW
```
Quick to write, but **cannot use variants** (every cell is the
default look of its object).
2. **Spaced / tokenised** — cells separated by spaces, so a cell can
carry a *variant number*:
```
W W W W W W
W . T3 . A1 W
W W W W W W
```
A token is a letter optionally followed by digits. `T3` = torch
variant 3, `A1` = table variant 1, `M40` = misc decor 40. A bare
letter (`T`) or any dense cell uses variant 1. Out-of-range numbers
clamp back to 1.
A row counts as tokenised the moment it contains a space; otherwise
it's read dense. Recommended: use the spaced format for new levels
(see `level_2.txt` for a full example).
## Core gameplay tiles
These drive the escape-room logic and use the game's own built-in
artwork (not the tileset). No tileset variants — but `L`/`Y`/`G` take
an optional pairing digit (see Trigger ↔ gate pairing below).
| Char | Meaning | Notes |
|------|--------------------|--------------------------------------------------------------|
| `W` | Wall | Solid. Drawn from the tileset floor/wall art (see below). |
| `.` | Floor | Empty walkable cell. Any unrecognised char is also floor. |
| `P` | Player start | Where the chosen character spawns. Use exactly one. |
| `X` | Exit / way out | Opens once the boss is dead (if any) and the key is held. |
| `B` | Boss (Mr. Green) | Spawns lazily when the player first enters the boss room. |
| `N` | Enemy (chaser) | Roaming threat; chases the player, contact damage. Spawns at once. Does **not** gate the exit. |
| `S` | Spikes | Timed hazard (safe → warning → deadly loop). |
| `L` | Lever | Pull with **E**. Pairs by order, or `L2`…`L9` to a gate. |
| `Y` | Pressure plate | Stand on ~0.25 s. Pairs by order, or `Y2`…`Y9` to a gate. |
| `G` | Gate | Solid until its trigger fires. Adjacent `G` = one panel. |
| `K` | Key | Walk over to pick up; required before the exit opens. |
Trigger ↔ gate pairing has two modes you can mix freely in one level:
* **By reading order (default).** Levers and pressure plates form one
combined list in top-to-bottom, left-to-right order; the *i*-th
un-numbered trigger opens the *i*-th un-numbered gate panel. Author
a mixed sequence (plate, lever, plate, …) and rely on order alone —
this is the original behaviour and needs no digits.
* **Explicit pair id.** Give a trigger and its gate the same trailing
digit — `L2` opens the panel containing `G2`, `Y3` ↔ `G3`, etc.
(1–9). Numbered pairs are matched by their number regardless of
order and never interfere with the order-based pairing of the
un-numbered ones, so complex rooms stay unambiguous. Put the digit
on at least one cell of a multi-cell gate panel.
A panel is the set of 4-connected `G` cells; gates that touch are one
panel. In the **editor**, the mouse wheel on `L`/`Y`/`G` sets this
number: leave it at 1 to pair by order, or set 2–9 to pair a trigger
with the gate of the same number (the editor can't emit “1”, which is
fine — 1 just means “by order”).
## Tileset objects
Real tileset artwork. **Solid** objects block movement (you can't walk
through them); the rest are pure decoration drawn under the player.
The variant range is the count of images in that folder under
`assets/tileset/`.
| Char | Object | Solid | Variants | Source folder |
|------|--------------------|-------|----------|-------------------------------------|
| `T` | Torch | no | 1–8 | `static_objects/torches` |
| `C` | Chair | no | 1–14 | `static_objects/chairs` |
| `A` | Table | yes | 1–8 | `static_objects/tables` |
| `E` | Bookshelf | yes | 1–12 | `static_objects/bookshelf` |
| `D` | Bookshelf decor | no | 1–40 | `static_objects/bookshelf_decor` |
| `O` | Box / crate | yes | 1–16 | `static_objects/boxes` |
| `R` | Rubble / blockage | yes | 1–8 | `static_objects/blockage` |
| `M` | Misc clutter | no | 1–44 | `static_objects/other` |
| `Z` | Door (prop) | yes | 1–4 | `static_objects/doors` |
| `J` | Trapdoor | no | 1–6 | `static_objects/trapdoors` |
| `H` | Chest | yes | 1–2 | `interactables/Chest{n}_S.png` |
| `F` | Fire | no | 1 | `interactables/Fire1.png` |
A missing or unknown asset is drawn as a loud magenta block so it's
easy to spot. An unknown *letter* is just treated as floor.
## Controls (for level testing)
| Action | Bind |
|-------------------|-------------------------------|
| Move & aim | WASD / Arrow keys |
| Shoot | Space or **left mouse** |
| Aim | The way you're facing (4-dir) |
| Dash | Shift (~0.18 s, i-frames) |
| Use lever | E within reach |
| Pause | Esc (in level) |
| Retry (after end) | R |
| Back to menu | Esc (after end) / Enter / Space |
## Floor / wall look
The baked floor and wall come from two tiles in
`assets/tileset/tiles/`, set near the top of `tileset.py`:
```python
FLOOR_TILE = "Tile_42"
WALL_TILE = "Tile_03"
```
To restyle the dungeon, browse `assets/tileset/tiles/` (or the full
sheet `assets/tileset/Tileset.png` / `Palette.png`), pick a tile, and
put its file name here. If the name can't be loaded the level quietly
falls back to the original procedural stone, so a wrong value never
breaks anything.
**Per level:** a built-in `manifest.json` entry may override the look
just for that level with `"floor_tile"` / `"wall_tile"` (same tile
names, e.g. `"floor_tile": "Tile_18"`). Either key is optional and
falls back to the global default above; an unknown name still
degrades to procedural stone. Custom (editor-built) levels always use
the global default. Note: the **editor canvas preview** also always
draws the global default floor/wall — a per-level override only shows
when you actually play the level, not while editing it.
## Audio
Sound is **entirely optional and clone-safe** — the game runs silent
if no audio files are present. Just drop files in; no code changes.
Layout (the only convention):
```
assets/audio/sfx/<name>.wav (or .ogg)
assets/audio/music/<name>.ogg (or .wav / .mp3)
```
Sound effects the game already triggers (create the matching file to
hear it):
| File name (`assets/audio/sfx/…`) | Plays when |
|----------------------------------|------------------------------------|
| `shoot` | the player fires a projectile |
| `hit` | any unit takes damage |
| `dash` | the player dashes (Shift) |
| `boss_death` | Mr. Green is defeated |
| `player_death` | the player dies (run failed) |
| `level_complete` | the player reaches the exit |
Adding a new SFX is just a new file plus one `audio.play("name")` call
at the trigger site.
**Background music** is per level. In a `manifest.json` entry add
`"music": "<name>"` to loop `assets/audio/music/<name>.*` for that
level (omit it for silence). Custom (editor-built) levels look for
`assets/audio/music/default.*`. Music stops on win/lose; a level
reload/retry keeps the same track playing without a restart.
Audio files are read **once per session** (like the tileset), so add
them before launching. The **Settings → Sound** toggle mutes/unmutes
everything and is remembered across launches (stored in
`~/.the-way-out/save.json`).
## Minimal example
```
W W W W W W
W P . T3 . W
W . A1 . . W
W . . . . X
W W W W W W
```
Player spawns top-left, a torch (variant 3) and a solid table
(variant 1) decorate the room, exit on the right.