Add ARCHITECTURE.md with design rationale and context

Documents the module structure, key design decisions and their reasons,
map file format design, what's not yet implemented, and ZZT background
for future development sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 11:50:42 -05:00
parent 573e662799
commit 9f62bba80c
+157
View File
@@ -0,0 +1,157 @@
# viberogue — Architecture & Design Notes
## What this is
viberogue is a **game-making system**, not just a game. The model is ZZT (1991, Epic MegaGames) — a DOS game that shipped with a built-in editor and a simple scripting language (ZZT-OOP), which let players create and share their own worlds. The goal here is something similar: a runtime + authoring environment where game worlds are defined in plain text files with embedded scripts, and the engine interprets them.
This document records the architectural decisions made so far and the reasoning behind them, so future development sessions don't have to rediscover the "why."
---
## Tech stack
| Concern | Choice | Reason |
|---------|--------|--------|
| GUI/windowing | eframe 0.33 / egui 0.33 | Pure Rust, retained-mode, works on desktop and (eventually) WASM |
| Scripting | Rhai 1.x | Pure Rust, sandboxed, WASM-compatible; designed for embedding |
| Map format | TOML + serde | Human-readable, good Rust tooling, standard in the ecosystem |
**WASM compatibility is a first-class requirement.** Everything in the stack must compile to WASM. This ruled out Lua (C FFI via mlua/rlua) for scripting. Rhai was chosen specifically because it is pure Rust with no C dependencies and explicit no_std/WASM support.
---
## Module structure
```
src/
main.rs — app entry point, rendering, input
game.rs — core data types and game logic
map_file.rs — TOML deserialization, Board loader
maps/
start.toml — the starting map (loaded at launch)
```
### `game.rs` — core types
The types here are the runtime representation of a game world. They are deliberately free of any file-loading or rendering concerns.
**`Glyph`** (`Copy`)
The visual representation of one cell: a character, a foreground color, and a background color. Stored *per cell* (not per element type) so individual cells can animate or vary their appearance independently — e.g. a "fire" element where each flame tile has a slightly different color — without changing their behavior.
**`Element`**
The behavioral definition of a tile type. Currently just `passable: bool`; future fields will include `opaque: bool` (for line-of-sight), `shootable`, etc. Elements are stored once in a palette on `Board`; cells reference them by index. Many cells can share the same Element.
**Why Glyph and Element are separate:**
In ZZT, each board tile had both a visual (character + color pair) and an element type. The visual could vary per-tile even for the same element. We replicate this: `Glyph` is the visual (per-cell), `Element` is the behavior (shared via palette). This lets you have a wall that's gray in one room and blue in another without creating two "wall" element types.
**`Board`**
The complete unit of a game world — one "room" or "screen" in ZZT terminology. Holds:
- `elements: Vec<Element>` — the behavior palette for this board
- `cells: Vec<(Glyph, usize)>` — row-major grid; each cell is a visual + an index into `elements`
- `player: Player` — current player position on this board
- `objects: Vec<ObjectDef>` — scripted objects (parsed, not yet runtime-wired)
- `portals: Vec<PortalDef>` — exits to other boards (parsed, not yet runtime-wired)
**Why `cells` is `Vec<(Glyph, usize)>` not `Vec<Cell>`:**
An anonymous tuple prevents the element index from being detached from its board and misused. The `usize` has no meaning on its own — it only makes sense relative to a specific `Board`'s `elements` palette. Wrapping it in a named `Cell` struct would give it false independence.
**Why `Board` is the complete unit (no wrapper struct):**
An earlier design had `GameMap { board: Board, player: Player, ... }`. This was eliminated because the split was artificial: there's no meaningful use of a `Board` without a player position, and no meaningful use of a player without a `Board`. ZZT itself treats a board as containing everything — the grid, the objects, and the player entry point. Collapsing to a single struct matches the domain model.
**`GameState`**
Currently a thin wrapper around `Board` that provides game-logic methods (`try_move`). It exists to keep mutation logic (collision checking, movement) separate from the data. As the game grows, event processing and scripting dispatch will live here.
---
### `map_file.rs` — file loading
**`MapFile`** and supporting structs are serde deserialization types only — they exist solely to parse TOML and are never used at runtime.
**`impl From<MapFile> for Board`** — the single conversion point. Reads the palette, builds the `elements` vec, then walks the grid string character-by-character to build `cells`. This is the only place that knows about both the file format and the runtime representation.
**`pub fn load(path: &str) -> Result<Board, ...>`** — reads a file, deserializes, converts. Called from `main()` before the window is created.
**Why loading happens before window creation:**
The window minimum size is derived from `board.width` and `board.height`. eframe requires `NativeOptions` (including window size) to be set before calling `run_native`. So the board must be loaded first.
---
### `main.rs` — app + rendering
`App` holds a `GameState`. The `update` method:
1. Reads arrow key input and calls `GameState::try_move`
2. Draws the menu bar
3. Draws the board: for each cell, a filled background rect then a centered monospace character
4. Draws the player on top using `Glyph::player()` (hardcoded `@` in cyan — the player is not a board cell)
**Cell rendering constants:** `CELL_W = 14.0`, `CELL_H = 20.0` pixels. The board is centered in the CentralPanel when the window is larger than the minimum size.
---
## Map file format
XPM-inspired (XPM is an old X11 image format that uses a character palette to define pixel colors). A `[palette]` section maps single characters to both a `Glyph` (visual) and an `Element` (behavior). The `[grid] content` is a TOML multi-line string where each character is a palette key.
```toml
[map]
name = "Room Name"
width = 60
height = 25
player_start = [30, 12]
[palette]
" " = { passable = true, ch = " ", fg = "#000000", bg = "#000000" }
"#" = { passable = false, ch = "#", fg = "#808080", bg = "#606060" }
[grid]
content = """
############################################################
# #
############################################################
"""
[[objects]]
x = 10
y = 5
script = """
on_touch(|| { send_message("open"); });
"""
[[portals]]
x = 59
y = 12
target_map = "cave"
target_entry = "west_door"
```
**Why TOML over a custom format:**
The `toml` crate gives us deserialization with minimal code. Multi-line strings for the grid give a visual representation of the map. Embedded Rhai scripts fit naturally in TOML multi-line strings without escaping issues.
**Why the palette approach:**
A direct mapping from palette character → `(Glyph, Element)` means the map file is both human-readable (you can see the shape of the room from the grid string) and efficient (shared element definitions, per-cell visual variation possible by using different palette chars with the same `passable` value but different colors).
**Colors** are `"#RRGGBB"` hex strings — universally understood, hand-editable.
**`player_start`** is a header field, not a palette character. The player is not a board cell; they are an entity that moves over the board. Using a palette character for player start (like `@` in many roguelikes) would mean the tile under the player is always that character, which makes it awkward to place a player over different terrain.
---
## What's not yet implemented
**Object scripting**`ObjectDef` and `PortalDef` are parsed from map files and stored on `Board`, but they have no runtime effect yet. The next step here is:
1. Wire `ObjectDef` scripts to Rhai: when the player moves to an object's cell, fire its `on_touch` handler
2. Define the Rhai API surface (what functions scripts can call: `send_message`, movement, board queries)
**Portal navigation**`PortalDef` stores a `target_map` and `target_entry` but there's no multi-board loading or board switching yet.
**Multi-board world** — right now the engine loads a single `maps/start.toml`. Future: a world file or directory of boards, lazy-loaded as the player moves through portals.
**Game creation tools** — the long-term goal is an in-app editor. Not started.
---
## ZZT reference
ZZT (1991) was a text-mode game for DOS. Its playfield was 60×25 characters (the right 20 columns were the stats panel). Each board was a self-contained screen with objects (tiles with embedded ZZT-OOP scripts), passageways to adjacent boards, and a fixed element type system (about 50 built-in element types). Players could create worlds with the built-in editor and share `.ZZT` files.
viberogue takes the core ideas — tile-based boards, embedded scripts per object, named portals between boards — and rebuilds them in a modern, WASM-capable stack with a more flexible scripting language and a human-readable file format.