diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fe32e6e --- /dev/null +++ b/ARCHITECTURE.md @@ -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` — 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` — scripted objects (parsed, not yet runtime-wired) +- `portals: Vec` — exits to other boards (parsed, not yet runtime-wired) + +**Why `cells` is `Vec<(Glyph, usize)>` not `Vec`:** +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 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`** — 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. \ No newline at end of file