Commit
the-way-out
docs: add DOCUMENTATION.md, align doc chrome
modified CHANGELOG.md
@@ -1,9 +1,14 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo_dark.png">
<img src="assets/logo_light.png" alt="The Way Out" width="280">
</picture>
<h1>Changelog</h1>
<p>
<a href="README.md"><b>README</b></a> ·
<a href="DOCUMENTATION.md"><b>Documentation</b></a> ·
<a href="VERSIONING.md"><b>Versioning</b></a> ·
<b>Changelog</b>
</p>
@@ -12,6 +17,15 @@
---
All notable changes to The Way Out are recorded in this file. The
format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
adapted for a game with a save file (see [VERSIONING.md](VERSIONING.md)).
Per-tag notes also appear on the
[releases page](https://github.com/ajhahnde/the-way-out/releases).
## [Unreleased]
## v1.0.5
Visual identity update. Adds a new responsive project logo in the
@@ -701,6 +715,8 @@ Initial release.
- Shared theme across all screens (single palette and font set).
- Readable status and stat text; animated character-select preview.
[Unreleased]: https://github.com/ajhahnde/the-way-out/compare/v1.0.5...HEAD
---
[← Prev: Versioning](VERSIONING.md) · [Back to start (README) ↺](README.md)
added DOCUMENTATION.md
@@ -0,0 +1,244 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo_dark.png">
<img src="assets/logo_light.png" alt="The Way Out" width="280">
</picture>
<h1>Documentation</h1>
<p>
<a href="README.md"><b>README</b></a> ·
<b>Documentation</b> ·
<a href="VERSIONING.md"><b>Versioning</b></a> ·
<a href="CHANGELOG.md"><b>Changelog</b></a>
</p>
</div>
---
This page is the architectural overview of The Way Out: how the game
loop, levels, characters, interactables, editor, save file, and the
self-updater fit together. Module names below refer to actual files in
the repository. For the level-file tile vocabulary and the editor key
bindings, see [`assets/levels/LEGEND.md`](assets/levels/LEGEND.md) — it
is the authoritative reference and this page links to it rather than
restating it.
## Contents
1. [Source layout](#1-source-layout)
2. [Game loop and states](#2-game-loop-and-states)
3. [Levels and maps](#3-levels-and-maps)
4. [Characters and combat](#4-characters-and-combat)
5. [Interactables and puzzles](#5-interactables-and-puzzles)
6. [Level editor](#6-level-editor)
7. [Save files](#7-save-files)
8. [Updater and packaging](#8-updater-and-packaging)
9. [Testing](#9-testing)
## 1. Source layout
```text
main.py Entry point: pygame init, the game-state machine, the main loop
launcher.py Frozen entry point baked into the .app — the ONLY frozen Python;
relocate-to-/Applications, seed the code tree, then self-update
menu.py Title, settings, level, character, and pause menus
levels.py Level parsing, the Camera, and LevelManager (runtime + win/lose)
level_catalog.py Merges built-in (manifest.json) and custom levels into one list
units.py Character base + 5 playables, Boss, Enemy, Projectile
interactables.py Spikes, Lever, Gate, KeyItem, PressurePlate (procedural art)
static_objects.py Tile / Prop sprites + procedural TileTextures (floor/wall fallback)
tiles.py TileSpec REGISTRY — the one tile vocabulary the loader + editor read
tileset.py Tileset asset loader (floor/wall tiles + furniture/decor art)
theme.py Shared palette, font cache, draw primitives, animated menu scene
audio.py Lazy mixer: SFX + per-level music (clone-safe, fully optional)
effects.py Particle bursts + full-screen fades (game-feel polish)
loading_screen.py Inter-level beat: title, tagline, character avatar, control hints
editor.py In-game level editor (palette → canvas → .txt)
save.py JSON progress + preferences at ~/.the-way-out/save.json
updater.py Self-update engine — pulls the latest code into ~/.the-way-out/app/
settings.py Screen, FPS, save/level paths, TILE_SIZE constants
version.py / VERSION Runtime version string (VERSION is the single source of truth)
assets/ Sprites, audio, fonts, and level maps
levels/ *.txt maps + manifest.json + LEGEND.md (tile vocabulary)
tileset/ Floor/wall tiles + furniture/decoration art
units/ Character + enemy sprite sheets
audio/sfx,music/ Sound effects and per-level music beds
scripts/ gen_sfx.py (SFX), make_icon.py (.icns), render_logo.swift (logo)
tests/ pytest suite (headless — conftest wires dummy SDL drivers)
build_mac.sh PyInstaller builder — arm64 .app
build_mac_intel.sh PyInstaller builder — x86_64 .app
The Way Out.spec PyInstaller spec
pyproject.toml Pinned runtime/build/dev deps + the Ruff config
```
## 2. Game loop and states
`main.py` boots pygame fullscreen at the monitor's native resolution
(`settings.WIDTH/HEIGHT` is only the fallback), constructs one instance
of every menu plus the `LevelManager` and `LevelEditor`, then runs a
single `clock`-paced loop over a `game_state` string machine:
| State | Screen |
| :------------ | :------------------------------------------------------------- |
| `menu` | Animated title scene — the chosen character is playable on it |
| `settings` | Sound + music-volume preferences |
| `char_select` | Character picker with stat bars and the per-character ability |
| `lvls` | Level menu (built-in + custom), ✓ marks and best-time line |
| `loading` | Inter-level loading screen — title, tagline, avatar, hints |
| _playing_ | A live level, driven entirely by `LevelManager` |
| `paused` | Frozen-world overlay — preserves every bit of level state |
| `editor` | The in-game level editor |
| `updating` | Threaded self-update status toast over the title |
`return_state` records where a finished run goes back to — normally
`lvls`, but `editor` when the level was launched from the editor's
**Test** button. The loading screen is shown only on first entry into a
level; R-retry and pause-restart bypass it with a direct
`level_manager.load_level()`. The update flow runs on a worker thread
that writes into `update_state`; the loop polls it each frame and
renders an animated status, so the window never blocks on the network.
Background music is selected per state (`_BGM_FOR_STATE`); gameplay
music is owned by the level (its `manifest.json` `music` track).
## 3. Levels and maps
A level is a plain-text grid where each cell is one `TILE_SIZE` (64 px)
tile. There are two kinds, and adding either needs **no code changes**:
- **Built-in** — `.txt` files in `assets/levels/`, listed in
`assets/levels/manifest.json` (`id`, `file`, `title`, `tagline`,
optional `music` / per-level floor+wall override).
- **Custom** — anything the editor saves, landing in
`~/.the-way-out/custom_levels/<name>.txt` and auto-discovered (no
manifest entry; shown with a `CUSTOM` pill).
`level_catalog.py` is the single source of truth that merges both
lists. Rows may be **dense** (one char per cell) or **tokenised**
(space-separated, so a cell can carry a variant number like `T3`); a
row is tokenised the moment it contains a space. The tile vocabulary
lives once in `tiles.py` (`REGISTRY`) — both the loader and the editor
palette read it, so the map format, the registry, and the editor never
drift apart. `levels.py` dispatches each glyph at load time, builds the
sprite groups, and `LevelManager` owns the runtime: the `Camera`,
collision, combat, the win/lose transition, and the best-time write.
> Full tile table, row formats, trigger/gate pairing, and the
> floor/wall and audio conventions are in
> [`assets/levels/LEGEND.md`](assets/levels/LEGEND.md).
## 4. Characters and combat
Combat is 4-directional: you aim the way you face and fire a ranged
`Projectile`. `units.py` defines a `Character` base and five playable
subclasses, each with its own HP / speed / damage / fire-rate profile
and one **signature ability** on a cooldown, fired with **Shift**:
| Character | Ability | Effect |
| :-------- | :-------- | :-------------------------------------------------- |
| Wizard | Slow | Enemies, boss, and their shots run at 0.35× for 3 s |
| Shiggy | Dash | Short, i-framed burst dash |
| Penguin | Shield | Total damage immunity for 2.5 s |
| Elf | Volley | Doubled fire rate for 2 s |
| Wolf | Sprint | 1.5 s of peak movement speed (no i-frames) |
Threats are the `Boss` (two-phase; its general identity — Mr. Green,
Mr. Orange, Gen. Frost, The Archer, Mr. Shadow — is chosen
deterministically from the level id, so a level always fights the same
boss, with mechanics unchanged) and roaming `Enemy` chasers that deal
contact damage but do **not** gate the exit. The exit opens once the
boss (if any) is dead and the key is held.
## 5. Interactables and puzzles
`interactables.py` holds the escape-room props, all drawn with cached
procedural art (clone-safe, no asset files required):
- **Spikes** — timed hazard cycling safe → warning → deadly.
- **Lever** — pulled with **E** within reach.
- **PressurePlate** — trips after ~0.25 s of standing on it.
- **Gate** — solid until its trigger fires; a *panel* is the set of
4-connected gate cells.
- **KeyItem** — walked over to pick up; required before the exit opens.
Triggers (levers + plates) bind to gate panels either **by reading
order** (top-to-bottom, left-to-right) or by an **explicit pair id** —
a matching trailing digit `1`–`9` on a trigger and its gate. The two
modes mix freely in one level. See the pairing rules in
[`LEGEND.md`](assets/levels/LEGEND.md).
## 6. Level editor
`editor.py` (`LevelEditor`) is a pygame-native palette-to-canvas editor
that writes `.txt` files in the same tokenised format the runtime
loads, into `~/.the-way-out/custom_levels/`. It reads the same
`tiles.REGISTRY`, so the palette can never offer a tile the loader does
not understand. The palette is a hover-driven drawer that overlays the
canvas without reflowing it; **Load** reopens any saved custom map,
**Theme** picks one of five floor/wall presets saved alongside the map,
and **Test** (or F5) launches a play-test that returns to the canvas
when the run ends. Player start (`P`) and exit (`X`) are singletons.
The full key-binding table is in
[`LEGEND.md`](assets/levels/LEGEND.md#in-game-level-editor).
## 7. Save files
`save.py` keeps one JSON document at `~/.the-way-out/save.json`:
```json
{
"completed": ["level_1"], // beaten level ids
"times": {"level_1": 42.7}, // best clear time, seconds
"settings": {"sound": true} // persisted preferences
}
```
Every public helper goes through one `_load` / `_write` pair, so a
write of one section never clobbers another. `_write` is atomic
(`tmp` + `fsync` + `os.replace`), so a process killed mid-write leaves
the previous save intact instead of truncating it to `{}`. Any I/O or
shape error degrades to "no save data" / "save skipped" — a weird
filesystem never crashes the game. Legacy saves that stored integer
level indices are migrated to string ids on read. This on-disk shape is
the save-file surface frozen by
[`VERSIONING.md` §3.1](VERSIONING.md#31-save-file-format).
## 8. Updater and packaging
The packaged macOS `.app` is only a thin launcher. `launcher.py` is the
**only** Python PyInstaller bakes into the bundle, and it never changes
after a build. On launch it relocates the app into `/Applications`
(dodging macOS App Translocation), seeds the real game code from the
bundle on first run, and then hands off to it. The actual game lives
*outside* the frozen bundle, in `~/.the-way-out/app/`, and is refreshed
by `updater.py` straight from GitHub's `main` branch (a `codeload` zip,
versioned by the commit sha in `app/.version`). TLS is pinned to
`certifi`'s CA bundle — the frozen build ships no system CA store — and
an `online()` probe to `1.1.1.1:53` distinguishes "no internet" from
"GitHub unreachable". `updater.py` is pure standard library on purpose
(it is itself part of the auto-updated payload) and only ever writes
inside `~/.the-way-out/app/`; the save file is a *sibling* of `app/`, so
an update can never wipe progress. `build_mac.sh` / `build_mac_intel.sh`
produce the arm64 / x86_64 `.app` via PyInstaller.
> This section describes the implementation. The release, support, and
> security contract those mechanics must honour is in
> [`VERSIONING.md`](VERSIONING.md).
## 9. Testing
`tests/` is a headless pytest suite — `conftest` wires the dummy SDL
video/audio drivers before `pygame.init()`, so it runs without a
display or audio device. It covers the level-parser helpers
(`_split_cells` / `_cell_variant` / `_pair_id`), the `PressurePlate`
charge/trip lifecycle, `Lever.use` single-shot behaviour, and the
`Character` attack + ability-cooldown bookkeeping. CI
(`.github/workflows/ci.yml`) runs on every push and PR: a `check` job
on Ubuntu (`ruff check`, `pytest`, and an `import main` smoke under the
dummy drivers) and a `build` job on macOS that runs `build_mac.sh` and
uploads `dist/TheWayOut-mac.zip` as a workflow artifact.
---
[← Prev: README](README.md) · [Next: Versioning →](VERSIONING.md)
modified README.md
@@ -17,6 +17,7 @@
<p>
<b>README</b> ·
<a href="DOCUMENTATION.md"><b>Documentation</b></a> ·
<a href="VERSIONING.md"><b>Versioning</b></a> ·
<a href="CHANGELOG.md"><b>Changelog</b></a>
</p>
@@ -29,6 +30,9 @@
<img src="assets/screenshot.png" alt="The Way Out — character select" width="780">
</p>
> The character-select screen: five playables, each with its own HP,
> speed, damage, fire-rate, and signature ability.
Pick a character, fight your way through locked rooms, work the levers
and pressure plates, and find the way out.
@@ -79,6 +83,10 @@ in-game editor (`editor.py`) edits them live.
| `audio.py` | Music & SFX |
| `assets/` | Sprites, audio, fonts, level maps |
A deeper walk-through of each subsystem — the game loop, levels,
combat, the editor, the save file, and the self-updater — is in
[Documentation](DOCUMENTATION.md).
## Build (macOS)
```bash
@@ -105,4 +113,4 @@ Apache License 2.0 — see [`LICENSE`](LICENSE).
---
[Next: Versioning →](VERSIONING.md)
[Next: Documentation →](DOCUMENTATION.md)
modified VERSIONING.md
@@ -4,6 +4,7 @@
<p>
<a href="README.md"><b>README</b></a> ·
<a href="DOCUMENTATION.md"><b>Documentation</b></a> ·
<b>Versioning</b> ·
<a href="CHANGELOG.md"><b>Changelog</b></a>
</p>
@@ -349,4 +350,4 @@ any time.
---
[← Prev: README](README.md) · [Next: Changelog →](CHANGELOG.md)
[← Prev: Documentation](DOCUMENTATION.md) · [Next: Changelog →](CHANGELOG.md)