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 05-renderer-spec.md
12 kB
Markdown
at main

05 — Renderer Spec: 3D ASCII That Reads, Moves, and Sings #

How the game looks in motion, how we build it, and how we keep it readable at 60fps on a toaster over SSH.

Read this with doc 01 §1/§6 (the feel) and doc 04 §4.5 (the pipeline). This doc is build-to-print for the engine team.


5.1 The look — "1982 dreaming about 2032" #

Core thesis: ASCII is not a limitation aesthetic — it is a resolution-independent vector-ish medium. A wall 2 meters away and a wall 200 meters away both render as characters, but density, color, and motion sell the depth. The reference images in the reader's head: terminal raycasters, caca-style demos, phosphor CRTs, DOS ANSI art — but with modern truecolor, smooth motion, and art direction.

The frame, described precisely (120×36 reference target):

  • Depth as density. Distance ramp per material class, 10 steps, near→far: █▓▓▒▒░░==--::..·· — nearest surfaces nearly solid, far surfaces dissolve into mist-dots. Fog is diegetic: dungeon damp + lantern falloff, not a shader trick. Fog color shifts by stratum (Shallows: warm brown-black; Works: teal-black ozone; Deep: violet-black + drifting · motes).
  • Edges as line-art. Hard geometry edges (doorframes, stair lips, grate bars, elevator cables) snap to / \ | - + ─ │ ┌ ┐ └ ┘ ├ ┤ box-drawing when the surface angle is edge-on to the ray. This single rule makes architecture pop out of the density field — a doorway reads instantly at 40m.
  • Faces as half-block gradients. Interior of surfaces uses ▀▄█ ░▒▓ + truecolor foreground/background pairs to fake sub-cell vertical resolution (two vertical samples per cell — effectively doubles perceived vertical res for floors/ceilings/walls).
  • Actors as glyphs-with-posture. Player @ (gold when lit, dim-blue when dark). NPCs by role-glyph: townsfolk & variants, wardens §, tinkers %, monsters per-species (r cable-rat with amber spark-pixel when charged, V Vendric with too-clean white glass pixels, O Drowned bell with swinging ~ clapper offset by animation phase, cherubs ✦ brass). Posture modifies: climbing ≋, swimming ≈, rope-hanging |o, prone –. Status overlays: burning = red * flicker on the glyph cell, blessed = gold . halo ring (one-cell radius, sparse), cursed = violet : drip.
  • Light as truth. Every cell's brightness = computed light level (doc 03 §3.3) quantized to 24 steps and multiplied into truecolor RGB. Lantern cone is visibly a cone: warm +* core → amber = falloff → cold blue . darkness. Muzzle-flash of a wand whites out 3 frames (with no-flicker palette option clamping this). Phosphor flora (tinker-moss) renders as sparse green */· constellations — navigation beacons players learn to love.
  • Sound made visible. Noise events draw expanding ( ) arcs for ~6 frames, intensity = arc count + brightness, direction readable from arc center offset. A slammed door two floors up renders as faint arcs leaking through the grate glyphs — the player sees upstairs consequences.
  • Motion language. 60fps interpolation of 10Hz sim (doc 04 §4.2): actors glide between ticks, lantern flame shivers (2-frame ~ wobble), wheat ,/;/' fields outside ripple in wind bands, elevator cables │ scroll, water ≈ flows downhill visibly, gas ░ churns upward. The world never sits still — stillness means death or pause, and pause draws a ‖ border.

Three cameras, one sim (switching never pauses in Classic):

  1. First-person (default): eye at actor head height, 75° FOV cap (motion-sickness law), subtle head-bob (toggleable, off by default in no-shake mode). Crosshair is a NetHack-voice hint cell, not a shooter reticle.
  2. Chase-orbit (press Tab to toggle; M restores the saved perspective on map exit): camera 3m behind/up, mouse-drag orbits, auto-collides (pulls in rather than clipping). For ropework, climbing, and admiring the mule.
  3. Top-tactical (M): orthographic ASCII plan-view, 2.5D stacked-glyph rendering (^ stairs up, v down, = bridges, ~ water, ░ gas) with Z-slider (PageUp/PageDown; Home recenters; optional in legacy keymaps). The planner: chalk marks, rope-length preview arcs, thrown-trajectory · dots. Pause-step auto-engages here in Story mode; Classic keeps ticking (display a ⏳ LIVE tag so nobody feels cheated).

The title-screen resolve (doc 01 §1) is a renderer feature: new-game boot plays a 2-second density ramp from → full scene, left-to-right like developing film. Cheap (animates fog density scalar), unforgettable.

5.2 The algorithm — CPU raycast, DDA, diffed ANSI #

Per frame (render thread, reads triple-buffered snapshot — never touches live sim):

  1. Snapshot acquire (~200KB: visible chunks + actor list + light/sound overlay buffers + particle sparks). Non-blocking try-read; on contention, re-render last frame (tearing beats stalling).
  2. Primary rays: one ray per cell column-half (half-blocks give 2 vertical samples per cell → cast W rays × 2H samples; at 120×36 that's 120×72 = 8,640 rays/frame — trivial for CPU). DDA through voxel grid (Amanatides & Woo), max distance per quality tier, early-out on opacity. Fixed-point DDA with integer stepping — no float drift, deterministic screenshots for tests.
  3. Shading per hit: material base color (palette-indexed, truecolor-expanded) × light level (24-step) × distance fog (density ramp select) × facing (top faces +10% warm from lantern law, side faces neutral, bottom faces −15% cold). Edge detection: if the ray grazes a voxel boundary within ε OR neighbor depth discontinuity > threshold → line-art glyph instead of density glyph. Normal estimation via face hit (DDA gives this free). Terminal exposure convention: material-table channels are linear reflectance coefficients. Encode the lit foreground product with the sRGB transfer function before emitting RGB bytes; direct linear bytes on an sRGB display double-darken the lantern falloff. Zero-light crush bypasses this encoding. Density and edge glyphs retain a deliberately dark background partner (quarter of the unencoded product); only actual oriented half-block glyphs may use another ray's foreground as background. Fog remains a post-shade display-color blend. The production opening regression requires at least 35% nonblank viewport cells with foreground relative luminance ≥0.10 and fg/bg contrast ≥3:1, in truecolor and decoded ANSI256 at default settings; this is a contrast floor, not certification of the three-glance experience.
  4. Actors & transparents: billboard glyph selection by species+posture+anim phase, depth-tested against ray depth buffer, drawn brightest-first so overlapping torch-bearers composite sensibly. Transparent/gas/fluid overlays (≈ water sheen, ░ gas churn, ( sound arcs, · thrown trails, flour-dust : puffs) composited after opaques with max-depth ordering.
  5. Post: vignette (corners −20% brightness beyond 70% radius — focuses the eye, hides narrow-terminal stretch), hint-ribbon glow (key hints brighten when contextually relevant — doc 01 §2), damage flash (red edge ! ticks, clamped by no-flicker setting), darkness crush (below-light-threshold cells snap to near-black blue, not gray — darkness must feel total).
  6. ANSI diff encode: compare cell buffer (glyph + fg + bg) against last-flushed frame; emit minimal cursor-move + SGR sequences. Typical frame diff <15% of cells → SSH-playable. Full redraw on resize/palette swap. 256-color fallback path (quantize truecolor via precomputed 6×6×6 cube LUT) and no-color path (density-only + bold — the game stays fully playable; tests assert this).
  7. Flush + frame pacing: target 60fps, floor 30fps (slice gate). Adaptive render-scale: if frame time > budget 3 frames running, drop horizontal res 10% (re-expand when headroom returns). Sim fidelity never degrades — only pixels.

Performance budget (slice gate, measured cargo bench -p hexwork-render + real terminals):

  • Raycast+shade ≤ 6ms @120×36 on 5-yr-old laptop CPU (single thread; worker-pool feature for >2 cores splits screen bands — deterministic band boundaries for test-stable output).
  • ANSI encode+flush ≤ 4ms typical (diff path), ≤ 12ms worst (full redraw).
  • Snapshot copy ≤ 0.5ms. Memory: triple buffer ≤ 1MB total. Zero per-frame allocations in hot path (pooled buffers — assert with a debug allocator tripwire).

5.3 Terminal reality — backends, capability matrix, degradation #

Backend: crossterm (portability) behind the hexwork-term trait so we can swap. Capability probe at boot (truecolor? unicode? mouse? focus? bell? response-time for SSH lag estimate):

Capability Full Degraded Floor
Color 24-bit truecolor 256-color LUT no-color density+bold (tested)
Unicode box-drawing + half-blocks + ✦≋≈ ASCII-only (`# /-+|`)
Size 120×36+ 80×24 minimum (tighter FOV, larger glyphs) refuse <80×24 with kind message + resize hint
Input keys + mouse-drag orbit keys only keys only, full game completable
Latency local 60fps SSH adaptive (render-scale + 30fps floor + input prediction dots) turn-step mode (Shift per tick) always available
Motion full anim reduced (no bob, no flash, static water) screenreader stream mode (sim→text, §5.5)

Detect-and-adapt automatically; every adaptation logged once ("Downshifted to 256-color — set HEXWORK_FORCE_TC=1 to override"). No silent ugliness.

5.4 Readability laws (playtested in slice, non-negotiable) #

  1. Three-glance rule: threat (red glyph + movement), exit (lit edge-art + draft lines), loot (glint pixel) each readable within 3 eye-glances of entering a room. Worldgen + palette tests assert glint/edge contrast minimums.
  2. No camouflage kills: no lethal monster shares glyph+color with a harmless one at the same light level. Mimics break this deliberately (Vendric) and are therefore required to have the tell (off-key hum + too-clean pixels) — tested.
  3. Darkness is honest: underexposed cells never fake-hide an enemy that the sim says is visible, and never show one the sim says is hidden. Render is a pure function of snapshot + camera — assert with screenshot-hash tests on scripted scenes.
  4. FOV ≤ 75°, bob off by default in reduced-motion, flash clamped. Motion-sickness toggles ship in slice, not 1.0.

5.5 Accessibility rendering paths #

  • Colorblind palettes: protan/deutan/tritan-safe + high-contrast + no-flicker, each with measured contrast ratios (tests). Threat/loot/exit never distinguished by hue alone — always glyph+brightness redundant.
  • Screenreader mode: bypasses raycast; emits prioritized text event stream (threat > exit > loot > flavor, throttled, interruptible). Fully completable — a blind delver should be able to win; test with scripted playthrough asserting winnability from stream alone.
  • Dyslexia/log options: alternate log font rendering (terminal font is user's — so: shorter lines, more spacing, no italics in critical warnings; all caps reserved for death).

5.6 What the engine team builds (ordered) #

  1. Cell buffer + ANSI diff + crossterm bring-up with a static test scene (slice week 1 — something on screen day 3).
  2. DDA raycaster + density ramp + fog on voxel snapshots (grayscale first — color is step 4).
  3. Edge-art detection + half-block faces + light-level multiply.
  4. Truecolor + palettes + 256/no-color fallbacks.
  5. Cameras (FP → orbit → tactical) + interpolation + vignette + sound arcs.
  6. Adaptive scale + SSH path + capability probe + benchmark harness.
  7. Screenshot-hash test rig + readability bots (three-glance assertions on generated rooms).
  8. Screenreader stream + reduced-motion + all palettes certified.