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):
intent(player + AI intents queued; AI is utility-scored, no allocations in hot path)hymns & bargains(spell resolution, god ledger writes)move & collide(continuous positions → voxel occupancy; shoves, slips, falls, ropes)fluids(water/oil/blood downhill + fill-up; capped iterations/tick for perf)gas & scent(rise/sink + draft advection)fire & heat(ignite, spread, smoke spawn, melt)signal & electricity(wire/plumbing graph BFS, capped depth)light(recompute dirty cells only)sound(emit → propagate → AI stimulus)structure(load checks on marked-dirty supports only)rot & schedules(corpses, spoilage, NPC clocks, faction clocks — cheap, staggered)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 inhexwork-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
rmof 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-ansidumps 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) #
- No gameplay logic outside
core+ data files. 2. No nondeterminism incore(noInstant, no threads, no HashMap iteration affecting outcomes — useBTreeMap/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.").