HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust. Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
hexwork docs 04-software-architecture.md
10 kB
Markdown
at main

04 — Software Architecture #

Rust workspace, crates, sim design, data flow. The engineering build-to-print.

Target: Rust 1.75+ (stable), single static binary hexwork, no GPU, no async runtime in sim, deterministic fixed-tick simulation, 60fps CPU ASCII rendering in any modern terminal.


4.1 Workspace layout #

hexwork/
  Cargo.toml            # workspace + release profile (lto, panic=abort? no — unwind for crash reports)
  docs/                 # this bible (00–06)
  crates/
    hexwork-core/       # ECS + sim tick + rules. NO I/O, NO terminal. 100% deterministic.
    hexwork-worldgen/   # floor/town generation (seeded RNG, anchor rooms + systemic fill)
    hexwork-content/    # data-file loading + validation (TOML/JSON monsters, items, hymns, contracts)
    hexwork-render/     # CPU raycaster → cell buffer → ANSI diff → terminal
    hexwork-term/       # terminal abstraction: raw mode, input, resize, truecolor detect, screenreader hook
    hexwork-audio/      # tiny beep/bell synth via terminal bell + optional rodio (feature-gated)
    hexwork-save/       # saves, lineage vault, .hexdemo replays
    hexwork-ui/         # screens, HUD, log, menus, look-mode, chargen (depends on core+content, not render internals)
    hexwork-cli/        # main.rs: arg parsing, game loop, panic handler, crash-report writer
  assets/
    data/               # canonical content: monsters.toml, items.toml, hymns.toml, gods.toml, contracts/, floors/, towns/
    palettes/           # colorblind/high-contrast/no-flicker themes
  tests/                # cross-crate integration: rule-chain tests, determinism replays, content validation

Dependency rule (enforced by CI cargo deny + import lints): core ← worldgen, content ← save ← ui ← cli; render and term are leaves consumed by cli only. core must build with --no-default-features and zero terminal/system deps (so we can fuzz + WASM-test the sim).

Crates and single responsibilities:

Crate Owns Must never own
core entities, components, tick(), fluids/gas/fire/sound/light propagation, combat, hymn resolution, god ledgers RNG seeding policy, file I/O, ANSI codes
worldgen seeded layouts, anchor-room placement, vertical connectors, faction seeding balance numbers (those live in data files)
content schema structs, validation errors with file:line, hot-reload in debug gameplay logic
render camera, raycast, glyph ramp, lighting, diff buffer sim rules
term crossterm backend, input map, resize events, bracketed paste off game state
save bincode + zstd snapshots, lineage vault (sled or JSON), demo event log rule decisions
ui screens/state machine, HUD layout, keybinds, tutorial hints pixels
cli loop, tick scheduling, panic → epitaph writer —

4.2 The sim — deterministic fixed-tick ECS #

ECS choice: hecs (small, no systems-scheduler magic) + handwritten tick order. Explicit tick phases beat parallel-scheduler nondeterminism for a NetHack-descendant: determinism > core-count bragging.

Tick: 10 Hz fixed (DT = 100ms). Render interpolates actor positions between ticks (no sim effect). Holding Shift = advance exactly 1 tick (roguelike step mode). All randomness from a single Xoshiro256** stream seeded per-run; sub-streams per subsystem via seed ^ domain_tag (fluids, combat, worldgen, gossip) so adding a particle never reschedules combat.

Determinism contract: same seed + same input-tick log = same state hash. core::hash_state() (SipHash over canonical entity dump) runs every 600 ticks in debug + on save; desync = crash with report. This powers .hexdemo replays (input log + seed) and Ironcartographer dailies.

Phases per tick (fixed order, each fully documented in code):

  1. intent (player + AI intents queued; AI is utility-scored, no allocations in hot path)
  2. hymns & bargains (spell resolution, god ledger writes)
  3. move & collide (continuous positions → voxel occupancy; shoves, slips, falls, ropes)
  4. fluids (water/oil/blood downhill + fill-up; capped iterations/tick for perf)
  5. gas & scent (rise/sink + draft advection)
  6. fire & heat (ignite, spread, smoke spawn, melt)
  7. signal & electricity (wire/plumbing graph BFS, capped depth)
  8. light (recompute dirty cells only)
  9. sound (emit → propagate → AI stimulus)
  10. structure (load checks on marked-dirty supports only)
  11. rot & schedules (corpses, spoilage, NPC clocks, faction clocks — cheap, staggered)
  12. death & epitaph (rule-chain recorder writes cause links)

Performance budgets (slice gate): tick ≤ 8ms p99 on M1-airs and 5-yr-old ThinkPads for a 96³ active volume; fluids/gas capped at 4k cell-updates/tick with spillover queue; sound/light use dirty-rect propagation. Bench harness cargo bench -p hexwork-core with criterion; CI fails on >10% tick regression.

4.3 World representation #

  • Voxels: ChunkedGrid<48³> chunks, Voxel { material: u8, occ: u8, fluid: FluidCell, gas: GasCell, light: u8, scent: u8, flags } — 16 bytes/voxel. Active set = loaded 3×3×3 chunks around player + lit/settled far-field abstract sim for towns (schedules only).
  • Actors: continuous Vec3<f32> + radius + stance (stand/crouch/climb/swim/prone/rope). Voxel occupancy derived, never authoritative for actors (so falling/leaning/riding work).
  • Vertical connectors as first-class entities: Stairwell, Shaft, Well, Vent, ElevatorCar, Counterweight, RopeAnchor — each with load/length/anchor-quality; cutting/flooding/jamming handled by generic rules, not scripts.
  • Rooms breathe: each enclosed volume gets a pressure node; doors/vents/grates are edges. Draft field derived per tick for smoke/scent/gas advection. Secret vents are just edges flagged hidden until flour/smoke reveals them.

4.4 Content as data (designers edit TOML, not Rust) #

Every monster/item/hymn/god/contract is a validated data file. Example (shape is contract, exact fields in assets/data/*.schema.md to be written in slice):

[monster.cable_rat]
hp = 6; habits = ["gnaw_wire", "nest_in_walls"]; tell = "ozone smell + skritch above"
uses = ["blood_pits_iron", "pelt_insulates", "wire_in_belly"]
conducts = true; blood = "weak_acid"; fear_of = ["cats", "bells"]

Validation: hexwork-content build-time test loads ALL data, checks cross-refs (every blood= names a fluid, every contract names a reachable floor), and rejects single-use items (the 2-uses law is a TEST, not a guideline). Balance numbers carry // intent: comments.

4.5 Rendering pipeline (detail in doc 05) #

sim snapshot (read-only) → camera → raycast/DDA → glyph+color cell buffer → ANSI diff → term. Render thread reads via triple-buffered snapshot (copy of visible chunks + actor list, ~200KB); sim never blocks on render. Target: 80×24 minimum, 120×36 recommended, up to 240×70; degrades by render-scale, not sim fidelity.

4.6 Input, loop, and screens #

term normalizes kitty/wezterm/xterm/gnome-terminal + SSH: keys, mouse-drag orbit (optional), resize, focus. ui is a screen stack: Title → Chargen → Play (HUD+log+hint ribbon) → Look/Inventory/Contracts/Map/Chapel → Death/Epitaph/Replay. Full keyboard play mandatory; every mouse action has a key twin. Keymap in one TOML file, remappable in-game.

Main loop pseudo-shape: poll input → map to intents → tick accumulator (10Hz) → snapshot → render → log flush → autosave every 600 ticks. Panic handler writes crash-epitaph.md (seed, tick, state hash, last 50 log lines) — a crash must still tell a story.

4.7 Saves, lineage, replays #

  • Save: bincode+zstd snapshot + seed + input log tail; versioned (save_version: u32, migrations in hexwork-save, old saves load or explain why not).
  • Lineage vault: per-lineage JSON: mapped regions, recipes, translations, ghosts, auctioned gear. Never stores live run state (permadeath survives rm of the save, not of the vault — document this).
  • .hexdemo: seed + input-tick log + content pack hash; replay verifies hash then steps sim headless; hexwork --replay file.hexdemo --export-ansi dumps a shareable ANSI cast. Death replays auto-record rule-chain subtitles from the cause-link recorder.

4.8 Testing strategy (NetHack-descendant needs Net principal: trust no interaction) #

  • Rule-chain tests: every §3.3 system has a test that asserts the causal story, e.g. oil_under_door_grate_chokes_mimic: pour → ignite → smoke advects through grate → mimic coughs → lootable. 100+ such tests at 1.0; slice ships 20.
  • TDTTOE property tests: proptest over material×hazard matrix (acid×{iron grate, iron helm, ...}) asserts consistent outcomes; deducibility lint: every hazard lists its rule refs.
  • Determinism tests: fixed seed + scripted inputs → state hash equality across 10k ticks, run in CI on 2 platforms.
  • Content tests: schema validation + 2-uses law + cross-ref integrity + epitaph count.
  • Soak: headless bot delver (random+greedy policy) runs 50k ticks nightly; asserts tick budget + no panics + SPS instrumentation hooks.
  • Accessibility tests: palette contrast ratios computed; screenreader event stream snapshot-tested.

4.9 Build, release, telemetry (opt-in only) #

Single cargo build --release binary; rust-toolchain.toml pins stable; cargo dist for GH releases (linux/mac/win); terminal feature matrix in README. No network calls in-game. Opt-in anonymous stats (SPS survey prompt after death — "was this death worth retelling? [y/n]") stored locally;毫不 exfiltrated.

4.10 The hard rules (PRs get rejected for these) #

  1. No gameplay logic outside core + data files. 2. No nondeterminism in core (no Instant, no threads, no HashMap iteration affecting outcomes — use BTreeMap/indexed pools). 3. No render-blocking-sim. 4. No single-use items. 5. No silent rules — every hazard emits a log line with its law the first time it matters ("The smoke is SUCKED through the grate — rooms breathe.").