terainia/ARCHITECTURE.md
Maximus Gorog 8aafc7a939 Add ARCHITECTURE.md mapping the functional-core/imperative-shell split
Documents every module's responsibility, the tick pipeline, the
sim::lighting shared-truth invariant (sun direction shared between
Rust and WGSL via shader_source.rs), and the test coverage. Companion
to DEPLOY.md — that one covers ops, this one covers code.
2026-05-23 23:33:02 -06:00

6.5 KiB

voxel-game architecture

Functional-core / imperative-shell split. Every game-logic transformation is a pure function (value, value) -> value; the shell threads them through the GPU + winit + JS bridges each tick.

Module map

src/
├── lib.rs               module declarations + run()
├── main.rs              native binary entry
├── app.rs               IMPERATIVE SHELL — App + winit handler + tick().
│                        Composes sim/ + net/ + render/ through bridges.
├── bridges.rs           THE ONLY MUTABLE-STATE OWNER. thread_local cells
│                        for touch input, game status, network, settings,
│                        plus the wasm-bindgen JS interface. Typed
│                        accessor functions hide the RefCells.
├── camera.rs            Camera + InputState + KbHeld (pure value types).
├── world.rs             World + Chunk + Block + raycast + natural_surface_y.
├── mesh.rs              Vertex + build_chunk_mesh (greedy meshing + AO);
│                        emit_oriented_box + name_hash for remote players.
├── proto.rs             Wire format for the multiplayer server.
├── shader.wgsl          Terrain + sky + outline pipelines (WGSL).
├── post.wgsl            Post-process pass-through.
├── shader_source.rs     Assembles shader source by prepending Rust-
│                        derived constants (DAY_PERIOD, SUN_OFFSET) to
│                        shader.wgsl — one source of truth shared with
│                        sim::lighting.
│
├── sim/                 PURE SIMULATION CORE. No GPU, no winit, no JS.
│   ├── mod.rs           re-exports
│   ├── body.rs          PlayerBody { feet, velocity, hp, alive, ... }
│   ├── collision.rs     AABB + sweep_axis(world, feet, delta, axis) →
│   │                    (Vec3, bool)
│   ├── edit.rs          apply_edit(world, EditRec) + chunks_for_edit
│   ├── event.rs         SimEvent { Landed, VoidDeath, BlockEdited }
│   ├── input.rs         TouchBridge + merge_held(kb, bridge) → KbHeld
│   ├── lighting.rs      ⭐ SHARED SUN/SKY TRUTH ⭐
│   │                    sun_direction(t), day_strength, twilight_amount,
│   │                    LightingFrame::at(t), is_in_direct_sun(world, p, t).
│   │                    Constants DAY_PERIOD / SUN_OFFSET exported to the
│   │                    WGSL shader via shader_source.rs so the visual
│   │                    sun direction can never disagree with the
│   │                    mechanical one consumed by future mob burn /
│   │                    plant growth / shade pathfinding.
│   ├── physics.rs       step_movement(world, body, MoveInput) →
│   │                    MoveOutcome { body', [SimEvent] }
│   ├── spawn.rs         find_safe_spawn, fall_damage
│   └── visibility.rs    compute_visible_chunks (frustum + radial cull)
│
├── net/                 PURE NETWORK PARSING
│   └── mod.rs           parse_inbox([raw line]) → [NetEvent]
│
└── render/              GPU SHELL — wgpu device, surface, pipelines
    ├── mod.rs           Renderer struct + new() + all draw methods
    ├── pipelines.rs     Pipeline + bind-group-layout factories
    ├── scene_target.rs  depth / scene-color / post-bind-group helpers
    └── uniform.rs       CameraUniform (matches WGSL camera.frame)
                          + OutlineVertex + ChunkBuffers

Tick pipeline

app::App::tick() is one frame, in this order:

dt + scaled shader_time
   ↓
(paused?) clear inputs, render last frame, return
   ↓
drain touch bridge → per-tick input snapshot
   ↓
honor respawn-request one-shot
   ↓
drain net inbox → parse_inbox → fold NetEvents into world + remote-player map
   ↓
send Hello on (re)connection
   ↓
apply mouse look to camera (yaw/pitch)
   ↓
sim::step_movement(world, body, MoveInput) → (body', [SimEvent])
   ↓
fold sim events into damage
   ↓
camera.position = body.feet + EYE_HEIGHT * Y
   ↓
raycast → break/place → maybe broadcast edit
   ↓
apply damage; periodic state broadcast
   ↓
render_frame(settings, outline) → Renderer

The shell only mediates between pure morphisms and side-effecting boundaries (GPU upload, network outbox, JS thread-locals).

The lighting invariant

Why this matters: in Minecraft-class games it's common for the visual shadow renderer and the "mob burns in sunlight" logic to use different sun directions / different occlusion tests, and they drift. A mob can stand in visible shade and burn anyway.

We avoid that by construction:

        DAY_PERIOD, SUN_OFFSET (defined once in sim::lighting)
                          │
              ┌───────────┴───────────┐
              ▼                       ▼
   sim::lighting::sun_direction(t)    shader_source::wgsl_constants_header()
              │                              │  (prepended to shader.wgsl)
              ▼                              ▼
   is_in_direct_sun(world, p, t)      shader.wgsl::sun_direction(t)
              │                              │
              ▼                              ▼
   mob burn, plant growth, AI pathing   visual shading (terrain + sky)

When real shadows land in the shader, they ray-march the same voxel grid using the same sun_direction(t). Visual shadow edges will agree with mechanical sun edges by construction.

Test coverage (53 tests as of this writing)

  • mesh: 8 — winding correctness, greedy meshing, AO darkening, oriented-box winding
  • sim::body: 3 — damage, immunity-when-dead, respawn reset
  • sim::collision: 3 — air sweep, ground snap, no-clip invariant
  • sim::edit: 2 — u8↔Block roundtrip + garbage fallback
  • sim::input: 5 — merge_held release semantics (the joystick-stick bug)
  • sim::lighting: 4 — night below horizon, lit at noon, occluded under overhead, ramp shapes
  • sim::physics: 4 — gravity, jump-only-when-grounded, Landed event, void death, dead-body-doesn't-move
  • sim::spawn: 7 — fall damage boundaries; spawn anchored to natural surface
  • net: 4 — malformed dropped, Welcome / Edit / order
  • world: 4 — terrain determinism, raycast hits/misses
  • shader_source: 2 — header contains constants, terrain source contains body
  • app: (no unit tests — orchestrator; coverage comes from the pure pieces it composes)

Running: cargo test --lib from the repo root.