4b12005800
Documents render.rs, editor.rs, and glyph_picker.rs; updates the module structure diagram and main.rs section to reflect the refactored layout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
252 lines
17 KiB
Markdown
252 lines
17 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, AppMode, frame loop (input → menu → panels → board → dialogs)
|
||
game.rs — core data types and game logic
|
||
map_file.rs — TOML deserialization, Board loader
|
||
render.rs — cell-size constants, draw_glyph/draw_board, board_origin, pos_to_cell
|
||
editor.rs — EditorTab, EditorState, show_editor_panel
|
||
glyph_picker.rs — floating Glyph picker dialog; decoupled (open, glyph) interface
|
||
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.
|
||
|
||
**`Behavior`**
|
||
A plain data struct of runtime behavioral properties — currently `passable: bool` and `opaque: bool`. Returned by `Archetype::behavior()`. Adding a new property (e.g. `shootable`) requires only a new field here; no match arms needed at call sites. This is a deliberate improvement over storing behavior as a bag of booleans on a per-cell or per-archetype basis.
|
||
|
||
**`Archetype`**
|
||
An enum of named element types: `Empty`, `Wall`, `Object`, `ErrorBlock`. Each variant knows:
|
||
- Its canonical map-file name (e.g. `"wall"`) via `name()`
|
||
- Its default `Behavior` via `behavior()`
|
||
- Its default `Glyph` via `default_glyph()` — used by the editor when stamping a cell
|
||
|
||
`ErrorBlock` is a sentinel for map files that reference an unknown archetype name. It renders as a yellow `?` on red so malformed maps are immediately visible in-game. It is excluded from `ALL_ARCHETYPES` (the editor's list) because it is not a valid authoring choice.
|
||
|
||
**Why `Behavior` and `Archetype` are separate types:**
|
||
`Archetype` is the *named class* of a thing — it lets you say "this is a wall" in a map file and in the editor. `Behavior` is the *runtime properties* that drive simulation. Keeping them separate means adding a new property (e.g. `pushable`) only requires a new field on `Behavior`; no match arms need to be updated across the codebase.
|
||
|
||
**Why Glyph and Archetype 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 type. We replicate this: `Glyph` is the visual (per-cell), `Archetype` is the class (shared across cells of the same type). This lets you have a wall that's gray in one room and blue in another without creating two archetype variants.
|
||
|
||
**`Board`**
|
||
The complete unit of a game world — one "room" or "screen" in ZZT terminology. Holds:
|
||
- `cells: Vec<(Glyph, Archetype)>` — row-major grid; each cell directly owns its visual and behavioral class
|
||
- `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, Archetype)>` with no palette:**
|
||
An earlier design had `cells: Vec<(Glyph, usize)>` where the `usize` indexed a per-board `elements: Vec<Element>` palette. This was eliminated because the palette indirection added complexity without benefit: `Archetype` is a `Copy` enum (4 bytes), so storing it per-cell is as efficient as storing an index, and it removes the invariant that the index must stay in sync with a specific board's palette.
|
||
|
||
**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 (each entry maps a character to a `(Glyph, Archetype)` pair), then walks the grid string character-by-character to build `cells`. Unknown archetype names produce an `ErrorBlock` cell and log a warning. 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:**
|
||
eframe requires `NativeOptions` (including window size) to be set before calling `run_native`. Loading the board first means its dimensions are available if they're ever needed for sizing logic (currently the window uses fixed defaults, but the board must exist before `App::new` is called).
|
||
|
||
---
|
||
|
||
### `main.rs` — app + frame loop
|
||
|
||
`App` holds a `GameState`, an `AppMode` (`Play` | `Edit`), and an `EditorState`. The `update` method phases:
|
||
|
||
1. Reads arrow key input and calls `GameState::try_move` — **Play mode only**
|
||
2. Draws the menu bar with a File menu and a Play/Edit mode toggle
|
||
3. In Edit mode: calls `editor::show_editor_panel` — declared before `CentralPanel` so egui allocates its space first
|
||
4. Draws the board and player via `render::draw_board` (see viewport sections below)
|
||
5. In Edit mode: calls `glyph_picker::show` if `editor.glyph_picker_open`
|
||
|
||
**Viewport — Play mode:**
|
||
`render::board_origin(available, board_w, board_h, player)` computes the pixel position of cell (0,0). If the board fits along an axis it is centered; if it overflows the viewport is centered on the player and clamped so no empty space appears at the board edges. No scroll bars.
|
||
|
||
**Viewport — Edit mode:**
|
||
The board is wrapped in `egui::ScrollArea::new([true, true])`, which shows scroll bars when the board overflows the viewport. Content is allocated at exact board size; `rect.min` from `allocate_exact_size` serves as the origin passed to `render::draw_board`.
|
||
|
||
**Stamp action:** `*board.get_mut(cx, cy) = (self.editor.glyph, self.editor.selected)` — the custom glyph and selected archetype are written together. The glyph can differ from the archetype's `default_glyph()` if the user has customized it.
|
||
|
||
---
|
||
|
||
### `render.rs` — constants and drawing primitives
|
||
|
||
All pixel-level rendering knowledge lives here. No dependency on `EditorState` or `App`.
|
||
|
||
**Constants:** `CELL_W = 14.0`, `CELL_H = 20.0` pixels per cell. Window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`) are defined here so they stay co-located with the cell measurements they derive from.
|
||
|
||
**`draw_glyph(painter, origin, x, y, glyph)`** — renders one cell as a filled background rect plus a centered monospace character.
|
||
|
||
**`draw_board(painter, origin, board)`** — iterates all cells calling `draw_glyph`, then draws the player as an overlay on top.
|
||
|
||
**`pos_to_cell(origin, pos) -> (i32, i32)`** — converts a pixel position to cell coordinates using floor division. Returns negative values for clicks above/left of the origin; callers must guard `>= 0`. Mirrors the rendering math so clicks land on the correct cell.
|
||
|
||
**`board_origin(available, board_w, board_h, player) -> Pos2`** — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping).
|
||
|
||
---
|
||
|
||
### `editor.rs` — editor state and side panel
|
||
|
||
**`EditorTab`** (`Palette` | `Board` | `World`) — which tab is active in the side panel.
|
||
|
||
**`EditorState`** — transient editor state:
|
||
- `selected: Archetype` — the archetype class to stamp
|
||
- `glyph: Glyph` — the visual to stamp (independent of the archetype's default; resets to `archetype.default_glyph()` when the archetype selection changes)
|
||
- `glyph_picker_open: bool` — whether the glyph picker dialog is visible
|
||
- `tab: EditorTab` — which side-panel tab is active
|
||
|
||
**`show_editor_panel(ctx, editor, board)`** — renders the resizable right-side panel (default 200 px). On the Palette tab: a scrollable archetype list (at most 5 rows visible) and a current-glyph preview button that sets `glyph_picker_open = true`. Board and World tabs are placeholders.
|
||
|
||
---
|
||
|
||
### `glyph_picker.rs` — glyph picker dialog
|
||
|
||
**`show(ctx, open, glyph, board_cells)`** — renders a floating `egui::Window`. Takes `open: &mut bool` and `glyph: &mut Glyph` directly, with no dependency on `EditorState`, so it can be invoked from any context that needs glyph selection.
|
||
|
||
Three sections:
|
||
1. **Board palette** — unique glyphs found on the current board, shown as clickable cells. The current glyph is highlighted with a white stroke.
|
||
2. **Colors** — `color_edit_button_srgba` pickers for `glyph.fg` and `glyph.bg`.
|
||
3. **Character grid** — 16 × 6 grid of printable ASCII (0x20–0x7E). Clicking a cell updates `glyph.ch`. The current character is highlighted.
|
||
|
||
---
|
||
|
||
## 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]
|
||
" " = { archetype = "empty", ch = " ", fg = "#000000", bg = "#000000" }
|
||
"#" = { archetype = "wall", 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 archetypes by name, not by property:**
|
||
Palette entries used to store `passable = true/false` directly. Switching to `archetype = "wall"` means adding a new behavioral property (e.g. `opaque`) only requires a code change — existing map files don't need to be updated. It also makes files more readable: `archetype = "wall"` is self-documenting. Unknown names produce a visible `ErrorBlock` cell rather than silently defaulting.
|
||
|
||
**Why the palette approach:**
|
||
A direct mapping from palette character → `(Glyph, Archetype)` means the map file is both human-readable (you can see the shape of the room from the grid string) and flexible (per-cell visual variation is possible by using different palette chars with the same archetype 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)
|
||
|
||
**Object behavior from scripts** — `Archetype::Object.behavior()` currently returns a static default (`passable: false, opaque: false`). Eventually, `Board::is_passable` will need a special branch for Object cells that reads the behavior from the cell's Rhai script instead.
|
||
|
||
**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.
|
||
|
||
**File open dialog** — the editor has no way to load a different map file from the UI. This requires a native file dialog (e.g. `rfd`). WASM support for file dialogs is an open question.
|
||
|
||
**Map save** — the editor can paint cells but has no way to write changes back to a `.toml` file. Saving will require serializing the `Board` back to `MapFile` format and writing it to disk.
|
||
|
||
---
|
||
|
||
## Future considerations
|
||
|
||
These are not current requirements but intended future directions. Where a planned change conflicts with current design, the tension is called out explicitly so it can be addressed before it becomes a problem.
|
||
|
||
### The player may become an object; boards may have no player
|
||
|
||
The long-term goal is for the "player" to be an object on the board that happens to respond to arrow key events — not a hardcoded special entity. Some boards may have no player-like object at all and do something else with input events (a cutscene, a menu, a puzzle that reacts to keys differently).
|
||
|
||
ZZT hard-coded the player as a special element and many game authors had to work around this limitation (e.g. hiding the real player behind a wall and scripting a fake one). This is a deliberate improvement over that model.
|
||
|
||
**Current design tension:**
|
||
|
||
The following assumptions are baked in today and will need to change when this is implemented:
|
||
|
||
- `Board.player: Player` is a required non-optional field. A board with no player can't be represented. This should eventually become `Option<Player>`, or the player should be removed from `Board` entirely and tracked by the engine layer only when present.
|
||
|
||
- `player_start` in the map file is a required header field. It will need to become optional, or player spawning will move into the object/script system (an object with a special role, spawned at its `x`/`y` position).
|
||
|
||
- `GameState::try_move` directly mutates `board.player`. Once the player is an object driven by Rhai, movement will go through the scripting dispatch layer instead. `try_move` will likely be replaced by something like `engine.dispatch_event(ArrowKey(dx, dy))`.
|
||
|
||
- In `main.rs`, the player is rendered as a hardcoded overlay using `Glyph::player()`. Once the player is an object, it should be rendered as part of the normal object layer, not as a special case.
|
||
|
||
None of these are blockers for current work, but avoid making `Board.player` more central than it already is (e.g. don't add methods that assume player presence, don't derive window sizing from player position).
|
||
|
||
---
|
||
|
||
## 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. |