Skip to content

Latest commit

 

History

History
293 lines (236 loc) · 14.1 KB

File metadata and controls

293 lines (236 loc) · 14.1 KB

ATOMFALL — Module Contracts

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.


1. File ownership — absolute

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.


2. Module shape

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.

The ctx object (this is the Engine instance)

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 EventBuson/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.


3. Service APIs you may rely on

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) }

Contested scene state — settled ownership

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.

4. Event channels

Emit and listen; never reach across modules directly.

Playerplayer: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}

Combatcombat: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}

VATSvats:enter · vats:exit · vats:queued {entity,limb,chance} · vats:shotResolved {hit,entity,limb,damage,crit} · vats:killcam {entity}

Worldworld:discovered {locationId,label} · world:weather {id,intensity} · world:timeOfDay {hours} · world:interact {target} · world:lootOpened {container}

UIui: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}

Engineengine: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}

Canonical channels for things that were being guessed at

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 }, where level is one of 'hidden' | 'caution' | 'danger' | 'detected' and meter is 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 invented ai:detection, which nothing emits.)
  • Weapon spread. COMBAT exposes currentSpread(): number in radians (half angle of the cone). HUD projects it through the camera FOV to size the crosshair.
  • WeaponInstance shape. COMBAT publishes exactly these field names: { defId, name, magazine, magazineSize, reserve, condition (0..1), fireMode, isReloading, reloadDuration }. Consumers must not probe for variants.
  • combat:reload carries { weapon, phase, duration }duration in seconds, so the HUD can run a reload timer.
  • Respawn. HUD's death screen emits game:reload; core handles it via GameState.respawn() and repositions the player. Progression is kept, rads are halved, caps are docked.
  • Freezing the player. player:freeze { frozen, look? }. look defaults to following frozen; pass look: false to stop the feet while leaving the camera free, which is what V.A.T.S. and any aim-while-locked state wants. Don't write player.__c.yaw/pitch directly to work around this.
  • Object3D.lookAt vs cameras. lookAt points an object's +Z at the target, but a camera looks down −Z. Any camera solver using it aims 180° away. Use Matrix4.lookAt for 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 writing player.skills / player.special directly. SPECIAL costs 4 points per rank and caps at 10; skills cap at 100.

5. Art direction — non-negotiable

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).


6. Performance budget

Target 60fps at 1080p on integrated graphics. Per module:

  • ≤ 40 draw calls added at rest. Merge static geometry; use InstancedMesh for 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 CanvasTexture or DataTexture, ≤ 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.


7. Definition of done

  1. npm run build succeeds with no errors.
  2. The dev server loads with zero console errors or warnings from your module.
  3. Your feature is visible/audible/playable without touching another agent's code.
  4. ctx.renderer.info.render.calls stays within budget with your module active.
  5. You wrote a short README.md inside 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.