Read this fully before writing a line. Every module is authored by a different agent working in parallel on the same working tree. The rules below are what keeps that from turning into a merge disaster.
You may create and edit files only inside your owned directory. You may read anything.
| Owner | Owns | Registered as |
|---|---|---|
| ART | src/art/** |
art |
| RENDER | src/render/** |
render |
| WORLD | src/world/WorldModule.js, src/world/terrain/**, src/world/props/** |
world |
| STORY | src/world/EnvironmentStory.js, src/world/story/** |
story |
| ENTITIES | src/entities/** |
entities |
| COMBAT | src/systems/combat/** |
combat |
| VATS | src/systems/vats/** |
vats |
| DIALOGUE | src/systems/dialogue/** |
dialogue |
| AUDIO | src/audio/** |
audio |
| HUD | src/ui/Hud.js, src/ui/hud/** |
hud |
| PIPBOY | src/ui/pipboy/** |
pipboy |
| (core, do not edit) | src/core/**, src/player/**, src/main.js, index.html, vite.config.js |
— |
If you need a change in core, say so in your final report. Do not edit it.
If you need a new manifest entry, say so. Do not edit src/main.js.
Never run npm install. three@0.180 and vite@7 are the only dependencies.
No new packages, no CDN fetches, no external asset downloads — every texture,
mesh, and sound in this project is generated in code.
Your entry file exports a class matching the name in the manifest:
export class WorldModule {
constructor() {
this.name = 'world'; // service key
this.label = 'SURVEYING WASTELAND'; // shown on the boot terminal
this.order = 10; // lower = boots earlier
}
async init(ctx) {} // build everything here
update(ctx) {} // per frame, gameplay (uses scaled dt)
lateUpdate(ctx) {} // after all updates — cameras, attachments
resize(w, h, ctx) {} // optional
dispose() {} // optional
}init may be async and may await. Long work should await new Promise(r => setTimeout(r, 0))
periodically so the boot terminal keeps painting.
| Field | Notes |
|---|---|
ctx.scene |
main world scene |
ctx.camera |
first-person camera, rotation.order = 'YXZ' |
ctx.viewScene / ctx.viewCamera |
viewmodel pass, rendered after main with cleared depth. Put first-person arms/weapons/Pip-Boy here, never in ctx.scene. |
ctx.renderer |
WebGLRenderer, ACES tonemapping, sRGB output, PCFSoft shadows |
ctx.bus |
EventBus — on/once/off/emit |
ctx.state |
GameState — SPECIAL, skills, HP/AP/rads, limbs, inventory, factions, flags |
ctx.input |
action(name), pressed(name), released(name), mousePressed('left'), locked |
ctx.collision |
heightAt, normalAt, addBox, addMesh, raycast, hasLineOfSight, resolveCapsule |
ctx.time |
{ dt, rawDt, elapsed, scale, frame, fps } — dt is time-scaled (VATS); rawDt is not. UI and input use rawDt. |
ctx.services |
Map; use ctx.provide(name, api), ctx.get(name), ctx.need(name) |
ctx.composer |
assign your EffectComposer here (RENDER only) |
ctx.setQuality(level) |
'low' | 'medium' | 'high' |
Dependency rule: never import another agent's file. Consume services via
ctx.get('name') and always handle undefined — that module may not have
landed yet, and the build must still run.
Publish exactly these shapes. Other agents are coding against them.
// world (WORLD)
{ spawnPoint: Vector3, spawnYaw: number, halfExtent: number,
heightAt(x, z): number,
normalAt(x, z, out?): Vector3,
surfaceAt(x, z): 'dirt'|'sand'|'ash'|'asphalt'|'rubble'|'metal'|'irradiated'|'grass',
biomeAt(x, z): string,
sun: DirectionalLight,
landmarks: Array<{ id, label, position: Vector3, radius: number }>,
isSafe(x, z): boolean } // false = hostile / irradiated zone
// art (ART)
{ palette: Record<string, THREE.Color>,
material(id, overrides?): THREE.Material, // cached, id from the material library
texture(id, opts?): THREE.Texture, // cached, procedurally generated
decal(id): THREE.Texture,
sign(text, style?): THREE.Texture, // rendered retro signage
registerMaterial(id, factory): void }
// render (RENDER)
{ composer, addPass(pass, order?), removePass(pass),
setWeather(id, intensity), setTimeOfDay(hours01),
setVatsGrade(on, blend), flash(color, strength), shake(amount),
bloom, grade } // pass handles for tuning
// entities (ENTITIES)
{ spawn(defId, position, opts?): Entity,
all(): Entity[], hostilesNear(pos, radius): Entity[],
byId(id): Entity|undefined,
applyDamage(entity, amount, { limb, source, crit }): void,
ragdoll(entity, impulse: Vector3, limb?): void }
// Entity: { id, defId, faction, isHostile, isAlive, root: Object3D,
// hitboxes: Array<{ limb, mesh, multiplier }>,
// health, maxHealth, headPosition(out): Vector3 }
// combat (COMBAT)
{ equippedWeapon(): WeaponInstance|null,
weaponDefs: Record<string, WeaponDef>,
fire(opts?), reload(), holster(v: boolean),
computeHit(originVec3, dirVec3, spread): HitResult|null,
damageResistanceOf(entity): { ballistic, energy, rad } }
// WeaponDef: { id, name, class, damage, apCost, fireRate, magazine, spread,
// recoil, range, critMult, ammoType, weight, value }
// vats (VATS)
{ isActive(): boolean, enter(), exit(), queueShot(entity, limb), execute(),
hitChance(entity, limb): number }
// dialogue (DIALOGUE)
{ start(npcId, nodeId?), isOpen(): boolean, close(),
registerTree(npcId, tree), quest(id): QuestState }
// audio (AUDIO)
{ play(id, opts?), playAt(id, position, opts?), stop(id),
setAmbience(id), radio: { on(), off(), next(), isOn() },
setListener(camera), setMuffled(amount) }
// hud (HUD)
{ notify(text, kind?), setCrosshair(state), showSubtitle(text, ms),
compass: { addMarker(m), removeMarker(id) } }
// pipboy (PIPBOY)
{ open(tab?), close(), isOpen(): boolean, setTab(tab) }Several modules reasonably believe they own these. Exactly one does. Written down because "claim it only if nobody else has" produced a deference deadlock whose winner depended on module boot order.
| Scene state | Owner | Everyone else |
|---|---|---|
scene.fog, scene.background |
RENDER | Don't set them. RENDER holds both at null and does aerial perspective analytically in its scene pass. |
scene.environment (IBL) |
RENDER | Claim unconditionally, refresh with time of day. A metalness = 1 surface with no environment renders pure black — this is load-bearing for every chrome, vault-plate and galvanised surface. |
| Ambient / hemisphere light | RENDER | Exactly one ambient in the project. Two stacked ambients is the fastest route to flat lighting. |
The sun DirectionalLight object + shadow camera |
WORLD | WORLD owns the object and its texel snapping in update(). |
| Sun direction, colour, intensity | RENDER | Driven in lateUpdate(). WORLD reads render.sunDirection rather than writing sun.position. |
| The first-person viewmodel draw | RENDER | Set engine.viewmodelHandled = true and call engine.drawViewmodel() inside the scene pass, so weapons receive grade/bloom/aerial perspective. Otherwise core draws it after the composer and the gun visibly belongs to a different world. |
ctx.time.scale |
VATS | Nobody else writes it. |
Emit and listen; never reach across modules directly.
Player — player:damaged {amount,limb,source,hp} · player:healed ·
player:died {source} · player:rads {rads,delta} · player:ap {ap} ·
player:levelUp {level} · player:xp {amount,reason} · player:limbCrippled {limb} ·
player:footstep {position,surface,intensity} · player:land {impact} ·
player:jump · player:stance {crouching} · player:teleport {x,z,y?} ·
player:freeze {frozen}
Combat — combat:fire {weapon,origin,direction} · combat:hit {entity,limb,damage,crit,point,normal} ·
combat:kill {entity,weapon,limb} · combat:recoil {pitch,yaw,trauma} ·
combat:reload {weapon,phase} · combat:impact {point,normal,surface,caliber} ·
combat:explosion {position,radius,damage} · combat:weaponChanged {weapon}
VATS — vats:enter · vats:exit · vats:queued {entity,limb,chance} ·
vats:shotResolved {hit,entity,limb,damage,crit} · vats:killcam {entity}
World — world:discovered {locationId,label} · world:weather {id,intensity} ·
world:timeOfDay {hours} · world:interact {target} · world:lootOpened {container}
UI — ui:notify {text,kind} · ui:subtitle {text,ms} · ui:prompt {text,key} ·
ui:promptClear · pipboy:opened · pipboy:closed · dialogue:opened {npcId} ·
dialogue:closed · dialogue:choice {nodeId,choiceId}
Engine — engine:booted · engine:fps {fps} · engine:resize {w,h} ·
engine:quality {level} · game:started · game:reload · game:reloaded ·
player:respawned {capsLost} · state:allocated {kind,key,value,remaining} ·
service:ready {name}
Consumers were probing several plausible channel names and field spellings because these weren't pinned down. These are now the only correct forms.
- Stealth / detection. ENTITIES emits
ai:stealthState { level, meter }, wherelevelis one of'hidden' | 'caution' | 'danger' | 'detected'andmeteris the 0..1 ramp. HUD listens to this one channel and stops probing for alternatives. (This documents what ENTITIES actually ships — an earlier revision of this file inventedai:detection, which nothing emits.) - Weapon spread. COMBAT exposes
currentSpread(): numberin radians (half angle of the cone). HUD projects it through the camera FOV to size the crosshair. WeaponInstanceshape. COMBAT publishes exactly these field names:{ defId, name, magazine, magazineSize, reserve, condition (0..1), fireMode, isReloading, reloadDuration }. Consumers must not probe for variants.combat:reloadcarries{ weapon, phase, duration }—durationin seconds, so the HUD can run a reload timer.- Respawn. HUD's death screen emits
game:reload; core handles it viaGameState.respawn()and repositions the player. Progression is kept, rads are halved, caps are docked. - Freezing the player.
player:freeze { frozen, look? }.lookdefaults to followingfrozen; passlook: falseto stop the feet while leaving the camera free, which is what V.A.T.S. and any aim-while-locked state wants. Don't writeplayer.__c.yaw/pitchdirectly to work around this. Object3D.lookAtvs cameras.lookAtpoints an object's +Z at the target, but a camera looks down −Z. Any camera solver using it aims 180° away. UseMatrix4.lookAtfor cameras. This bit the V.A.T.S. drift and cinematic rigs; assume it will bite you too.- Level-up allocation. UI calls
state.allocate('special'|'skill', key, n)rather than writingplayer.skills/player.specialdirectly. SPECIAL costs 4 points per rank and caps at 10; skills cap at 100.
The look is 1950s American atompunk, twenty years after the bombs. If your output could be dropped into a generic post-apoc shooter, it is wrong.
- Palette. Bleached bone, rust orange, olive drab, dust ochre, faded turquoise/pastel appliance colours, and a single acid-green accent reserved for Pip-Boy/terminal phosphor. Desaturate everything except the phosphor.
- Light. One hard, low, warm sun. Long shadows. Heavy aerial perspective — distance goes pale and blue-grey. Never neutral white light.
- Materials. Nothing is clean. Every surface has grime in its crevices, chalked-out paint on its sun-facing side, and rust bleeding downward from fasteners. Roughness maps carry the story, not just the albedo.
- Silhouette. Chrome fins, bulbous vacuum-tube shapes, riveted steel, wood veneer on machines, dial gauges instead of screens, nixie tubes, chunky toggle switches. Curves, not angles. Optimism corroded.
- Typography. Condensed slab serifs and Futura-style geometric sans on signage. Terminal text is monospace phosphor green with bloom and scanlines.
- Composition. Every vista needs a readable landmark on the horizon. Every interior needs one story told in props alone.
Reference silhouettes to evoke: Nuka-Cola bottle, Vault door gear, power armour pauldron, Red Rocket sign, Corvega tailfin, Mister Handy sphere. Do not copy trademarked names or logos — invent equivalents (ATOM-COLA, VAULT-COR, RED COMET, CORVAIRE).
Target 60fps at 1080p on integrated graphics. Per module:
- ≤ 40 draw calls added at rest. Merge static geometry; use
InstancedMeshfor anything appearing more than 8 times. - ≤ 3 shadow-casting lights total across the whole project (WORLD owns the sun; ask before adding another).
- Textures: procedural, generated once into a
CanvasTextureorDataTexture, ≤ 1024²,generateMipmaps = true, cached by id in ART. - No per-frame allocation in
update(). Preallocate vectors as instance fields. - Geometry with more than ~50k triangles needs LOD or instancing.
- Dispose everything you create in
dispose().
Check your own cost: ctx.renderer.info.render.calls and .triangles.
npm run buildsucceeds with no errors.- The dev server loads with zero console errors or warnings from your module.
- Your feature is visible/audible/playable without touching another agent's code.
ctx.renderer.info.render.callsstays within budget with your module active.- You wrote a short
README.mdinside your owned directory: what you built, what it provides, what it still needs from others.
A harsh visual reviewer will compare screenshots against real Fallout footage and send work back. Assume your first pass will be rejected; leave the code in a state where the next pass is cheap.