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.
140 lines
6.5 KiB
Markdown
140 lines
6.5 KiB
Markdown
# 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.
|