A first-person post-apocalyptic RPG vertical slice built on Three.js. Every
texture, mesh, animation and sound in this project is generated in code —
there are no asset files, no downloads, and no external dependencies beyond
three and vite.
npm install
npm run dev # http://127.0.0.1:5173
npm run build # production bundle -> dist/Original work, not affiliated with anyone. This is an independent technical demo in the retrofuturist post-apocalyptic genre. It is not associated with, endorsed by, or derived from any commercial game or its publisher. Every name, mark, logo and character in it is invented for this project — VALCORP DYNAMICS, ARMLINK, ATOM-COLA, RED COMET, ATOM ANDY, Halloran, Meridian, Thresher — and no third-party trademark, asset or code is included. All content is procedurally generated at runtime.
Click to lock the pointer. WASD move · Shift sprint · Ctrl/C crouch ·
Space jump · Tab ARMLINK · Q V.A.T.S. · E interact · R reload ·
1-9 weapon slots · O settings · P perf readout.
| System | Highlights |
|---|---|
| Terrain | 1600 m heightfield: droplet hydraulic erosion, thermal slumping to the angle of repose, flow-accumulation gullies, a blast crater with vitrified pan and ejecta rays. Quadtree LOD, 8-layer height-blended triplanar splat. |
| World | 12-structure town with 7 enterable interiors, elevated interstate with a collapsed span, vault door, drive-in, relay mast, ~9 000 instanced props, ~1 960 collision AABBs. |
| Environmental storytelling | 22 authored vignettes, none of them labelled. Nine skeletons still queuing at a door that never opened. A cellar with 411 tally marks, the last stroke unfinished. A checkpoint whose sandbags face away from the city, all the brass on the soldiers' side. |
| Rendering | Rayleigh/Mie sky with two cloud decks, stars, moon and aurora. AtmospherePass does SSAO and analytic aerial perspective off one depth buffer, sharing the sky's own scattering GLSL so distance lifts toward the sky rather than hitting a fog wall. God rays, 10 cross-faded grades, 6 weather states. |
| Art | Periodic Perlin/Worley (seamless tiling), spectral fBm that drops sub-6-texel octaves, ORM channel packing, 30 materials + 94 aliases, 9 sign styles, 36 decals, 82-colour palette. |
| Weapons | 12 procedural weapons from a chamfered-box kit, per-node/per-material merging (a 60-piece rifle draws in ~6 calls), spring sway from camera-rotation velocity, accumulating muzzle climb, analytically solved ADS, two-bone IK arms with hands parented into the weapon so reloads drag the arm. |
| Entities | 10 archetypes / 21 variants in 22.6k triangles total. 21-bone procedural rigs, distance-driven gait with world-locking foot IK, verlet ragdoll that sleeps at 1.4 s and settles without sinking, dismemberment, HSM AI with a detection meter, cover seeking, ghoul dormancy. |
| V.A.T.S. | Rayleigh hit-chance model with eight declared penalty factors and raycast-sampled visibility. Time 1 → 0.06 → 0.18 with a 0.05 impact crush; never paused. Cinematic rig cuts on the shot; kill cam tracks the ragdoll. |
| ARMLINK | A physical 3D device on the forearm. CRT shader with barrel distortion, aperture grille, phosphor persistence, halation and convergence error. 1-bit blitted font. 17 sub-tabs. The selected item's real mesh renders to a target and composites into the raster. |
| HUD | DOM/CSS/SVG — zero draw calls, 0.28–0.44 ms/frame. Segmented bars, needle rad gauge, compass tape, spread-driven crosshair, directional damage arcs. |
| Dialogue | Branching graph with renderable conditions, gate and roll skill checks always shown, per-NPC memory driving ordered greetings. 5-stage main line with three divergent endings, 5 side quests, 8 speakers, full terminal hack and lockpick minigames. |
| Audio | 115 synthesised patches, no audio files. 6-layer gunshots, source-filter creature vocals, Poisson-distributed Geiger, 5 procedural impulse responses, an AM radio chain playing sequenced period music over real chord forms with a DJ who reads your kill count. |
src/core/Engine.js owns the renderer, the frame loop and the module
lifecycle. Every feature is a module registered against a manifest in
src/main.js, resolved through import.meta.glob so a module that fails to
load degrades that one feature instead of blanking the screen.
Modules never import each other. They communicate through ctx.bus
(EventBus) and ctx.services (a service locator), which is what let a dozen
of them be built in parallel by separate authors.
CONTRACTS.md is the source of truth for module boundaries, service APIs,
event channels, the art direction bible, the performance budget, and the
settled-ownership table for contested scene state (scene.environment, the
sun, ambient light, the viewmodel draw, time.scale). Read it before changing
anything that crosses a module boundary.
node tools/shoot.mjs # all 12 canonical shots -> review/round-N/
node tools/shoot.mjs --only 06-weapon-ads,12-night
node tools/gpucheck.mjs # probe available WebGL backendsIt builds a production bundle and serves it frozen, because capturing against the dev server means Vite hot-reloads the page mid-shot and silently produces a picture of the boot screen. It runs on hardware ANGLE/D3D11 — SwiftShader takes ~3.6 s per composited frame here and blows every timeout.
Each round writes PNGs, report.json (services online, per-shot draw calls and
triangles, console output, modules that failed to load) and REVIEW.md.
A shot whose setup throws is marked INVALID — DO NOT REVIEW. This exists
because an earlier revision called __renderDebug__?.setTimeOfDay?.() when the
method is named timeOfDay; optional chaining swallowed it, and three rounds
of "golden hour", "rad storm" and "night" were plain daylight frames that
nobody caught. Silent no-ops in a verification tool are worse than no tool.
review/RUBRIC.md is the grading scheme used by the adversarial reviewers.
Recorded because each one cost real time and every one of them is a trap that looks like success from the inside.
- Verify in the state that gets reviewed. Weapon ADS was measured as correct in a pointer-locked browser and was genuinely broken in the harness's unlocked state — the one state anybody ever screenshots. The solver was verified; the state under review was not.
- Sample the pixels, don't trust the change. Raising weapon metalness to physical values looked like a clean win and aliased the normal map into hard specular sparkle — 4.13% of scope pixels clipped, reading as crumpled foil. Only a histogram caught it.
- Measure the field before tuning a threshold.
stratusCover = 0.72against a cloud-deck field with mean 0.500 and sd 0.070 is dead across the entire sky. Three weather presets had thresholds in the same dead zone. - "Claim it only if nobody else has" is not ownership. Two modules deferring
to each other over
scene.environmentproduced a boot-order-dependent winner, and metal surfaces rendered black when the wrong one won. - A contract that contradicts shipped code is worse than no contract. An
earlier revision of
CONTRACTS.mdinvented anai:detectionchannel that nothing emitted, while consumers probed four spellings of the real one. Object3D.lookAtpoints +Z; a camera looks down −Z. Two camera rigs aimed 180° away before anyone noticed.setSize(w, h, false)is a trap after a plainsetSize. The constructor's call writes an inline px width/height onto the canvas; passingupdateStyle: falseon every later resize leaves that stale inline style pinned, beating the stylesheet'swidth: 100%. The game renders into its original rectangle and the rest of the window stays black. Listening only towindow.resizecompounds it — a container can change size on its own (side panel, docked inspector, split view) without the window ever resizing. Use aResizeObserveron the container. Regression test:tools/resizecheck.mjs.- Correct is not the same as legible. A physically accurate 300-triad aperture grille and 384 scanlines are sub-pixel at the size the ARMLINK tube occupies, so they resolved into a flat grey veil. Coarsening to 118/150 — technically wrong — looks right.