9f62bba80c
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>
157 lines
8.9 KiB
Markdown
157 lines
8.9 KiB
Markdown
# 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. |