# Flight Sim — Build Contract v1 A 3D flight simulator with detailed 6-DOF physics, rendered with Three.js. Zero build step: pure ES modules loaded directly in the browser, all local files (three.js is vendored at `vendor/three.module.min.js`, r160). ## Ground rules (EVERY agent must obey) 1. **`SPEC.md` is ground truth for all interfaces.** Read it fully before coding. Read `src/config.js` and `src/world.js` before coding anything that uses them. 2. Pure modules (Node-safe, no DOM, no three.js import): `src/config.js`, `src/world.js`, `src/physics.js`. These must be importable from Node to run unit tests. **Never** reference `window`, `document`, or `THREE` there. 3. SI units everywhere internally (m, kg, s, rad, N, Pa). Display conversions (`kts`, `ft`, `fpm`) live only in instruments.js/main.js. 4. **Deterministic simulation.** No `Math.random()` anywhere in physics. Any "turbulence" must be a deterministic function of state (e.g. sine of position/time). Randomness allowed only in cosmetics (cloud placement in terrain.js is OK because it's static). 5. **File ownership is exclusive.** Touch only the files in your scope. The integrator may make tiny wiring fixes in any file, and must report them. 6. After writing any `.js`/`.mjs` file, run `node --check ` and fix everything it flags. (The integrator must add a `package.json` with `"type": "module"` so Node treats `.js` files as ESM for `--check` and tests.) 7. No build tools, no npm installs, no TypeScript, no external CDN imports at runtime. Three.js comes only from `../vendor/three.module.min.js` (relative from `src/`). 8. No console.log spam in hot paths (one-shot messages OK but keep minimal). 9. Do not use git. Do not create files outside your scope unless explicitly allowed. ## Coordinate system (CRITICAL, shared by everything) - World: right-handed, **+Y up**, **+X east**, **+Z south** (north = −Z, three.js default). Heading 0° = facing north (−Z); heading increases clockwise viewed from above (east = 90°). - Aircraft **body frame**: **+X forward** (nose), **+Y right** (starboard), **+Z down**. Quaternions are `[w, x, y, z]`. - Positive conventions (right-hand rule, all consistent): - `elevator > 0` → nose up (pitch up), `δe` positive = trailing edge down. - `aileron > 0` → roll right (right wing down, bank right). - `rudder > 0` → yaw right (nose to starboard). - Body rates: `p` roll right, `q` pitch up, `r` yaw right (about body +X/+Y/+Z). - Vertical speed > 0 = climbing. - The physics `aircraft.quat` orients the vehicle. At spawn (heading north, wings level) the correct quaternion is `[0.5, 0.5, 0.5, -0.5]` (120° about (1,1,-1)/√3 — verified against three.js r160; the earlier literal `[√½,0,√½,0]` rolls the airframe 90° left). Three.js Object3D `.quaternion`/`.position` can be assigned directly from physics state (same handedness/axis convention). ## File ownership | File | Owner | |---|---| | `SPEC.md` | core (do not edit) | | `src/config.js` | core (pre-written, do not edit) | | `src/world.js` | core (pre-written, do not edit) | | `src/physics.js`, `tests/physics.test.mjs` | physics agent | | `src/controls.js` | controls agent | | `src/aircraft-model.js` | model agent | | `src/terrain.js` | environment agent | | `src/instruments.js` | instruments agent | | `src/audio.js` | audio agent | | `index.html`, `css/style.css`, `src/main.js`, `package.json`, `README.md` | integrator | | fixes | QA-1: physics/config/world/tests · QA-2: model/terrain/instruments/audio/main/css/index · gate: any (small only) | ## Shared interfaces ### Inputs object (controls → physics/model/instruments) ```js { elevator: number, // -1 (nose down) .. +1 (nose up), smoothed aileron: number, // -1 (bank left) .. +1 (bank right), smoothed rudder: number, // -1 (yaw left) .. +1 (yaw right), smoothed throttle: number, // 0 .. 1, smoothed (slew ~0.4/s) flaps: number, // 0 | 0.25 | 0.5 | 1 gear: boolean, brakes: boolean, } ``` ### `src/physics.js` exports ```js createAircraft() -> aircraft // fresh aircraft at spawn resetAircraft(aircraft) -> void // back to spawn step(aircraft, inputs, dt) -> void // dt <= 1/120 (caller guarantees); internally substeps to <= 1/240 getInstrumentData(aircraft) -> object // flat, all fields below getAeroSummary(aircraft) -> object // { alpha, beta, q, liftN, dragN, sideN, cl, cd, stall, vtas } ``` `getInstrumentData` must return **all** of: ``` airspeedMps, airspeedKts, altM, altFt, vsMps, vsFpm, headingDeg, pitchDeg, bankDeg, rollRateDps, pitchRateDps, yawRateDps, alphaDeg, betaDeg, gLoad, stall(bool), engineOn(bool), rpm(0..1), throttle, flapsPos(0..1), gearDown(bool), brakes(bool), fuelKg, groundContact, landed, crashed, onGround, rolling, airTimeS ``` Public aircraft fields (readable; do not hand-edit): `pos:[x,y,z]`, `quat:[w,x,y,z]`, `vel:[x,y,z]` (body), `angVel:[p,q,r]` (body), `throttle`, `rpm`, `flaps`, `gearDown`, `brakes`, `fuelKg`, `engineOn`, `crashed`, `landed`, `groundContact`, `groundTime`, `airTime`, `impactSpeed`, `spawnPos`, `spawnQuat`. ### `src/controls.js` exports ```js attachInput(canvas, opts?) -> { getInputs() -> Inputs, // current smoothed inputs (fresh object ok) reset(), // zero all inputs dispose(), onAction(cb) // cb(action) for: 'reset','camera','pause','flaps','gear','brakes','sound','debug' } KEY_BINDINGS -> [{key, label}] // for the help panel on the start screen ``` Keymap (make `KEY_BINDINGS` match reality): WASD/arrows elevator+aileron, Q/E rudder, Shift/Ctrl throttle up/down, F flaps (cycle 0→0.25→0.5→1→0), G gear toggle, B brakes, C camera, R reset, P pause, D debug overlay, S or Enter sound toggle. Ignore key repeat for toggle keys (track `pressed` state). `getInputs()` returns stick values smoothly slewed toward targets (max slew ~1.6/s for sticks, 0.4/s throttle). ### `src/aircraft-model.js` exports ```js buildAircraftModel() -> { group, animate(state, inputs, dt), cockpitOffset() -> [x,y,z] } ``` - `group` is a THREE.Group whose **forward is +X** (nose at +X) so physics quaternion/position assign directly: `group.position.set(...pos); group.quaternion.set(...quat)`. - Primitive-built (~1500 tris budget) single-engine prop: fuselage (capsule/ tapered cylinder), main wing (slightly tapered, span from config `b`), tail plane + fin, prop (3 blades + spinner), wheels+gear struts, flaps/ailerons/ elevator/rudder as **separate child meshes** driven by `animate`. - `animate(state, inputs, dt)`: elevator surface ∝ inputs.elevator (visual trailing-edge-down for positive), ailerons differentially, rudder, flaps rotate down ∝ inputs.flaps, gear retract when !inputs.gear (translate/ rotate into fuselage over ~1s, lerp by dt), prop spins ∝ state.rpm. - Blend deflections with dt-based lerp for smoothness. Must be cheap per frame. - `cockpitOffset()` ≈ pilot eye in body frame (e.g. `[0.9, 1.1, 0.15]`), used by cockpit camera. Provide `getDimensions()` → { length, height } if easy. ### `src/terrain.js` exports ```js buildEnvironment(scene) -> { update(camPos, dt), groundHeight(x,z), dispose() } ``` - Ground: ~8×8 km plane, 192×192 segments, elevation from `world.getGroundHeight` (single source of truth!), vertex colors by height: sand (<2m), grass (2–45), rock (45–120), snow (>120). MeshStandardMaterial vertexColors. - Runway: from `WORLD.runway` (flattened to y=0 by world.js) — lap it with a concrete strip + centerline/numbers via CanvasTexture, slightly above (y=+0.02) to avoid z-fighting. - Water plane at `WORLD.waterLevel` (semi-transparent, ~0.55 opacity). - Sky: scene.background gradient (big backside sphere with shader or vertex colors), sun DirectionalLight + shadows (only where affordable; if shadow map cost is too high, disable shadows entirely — visual quality first), HemisphereLight. - Clouds: ~30 billboard sprites (soft radial-gradient canvas) in a 5 km box, **wrap around camera** each frame (fog + clouds re-centered, cheap). - Fog: scene.fog ≈ 6000 m. Performance budget: < 150 draw calls. - `update(camPos, dt)`: recenter clouds; nothing else needed. ### `src/instruments.js` exports ```js createInstruments(rootEl) -> { update(data), message(text, kind), clearMessage(), debug(lines), reset() } ``` - Pure DOM overlay (no canvas): attitude indicator (artificial horizon that pitches/rolls via transform), altimeter + vertical speed, airspeed tape, heading tape, engine rpm bar + throttle, flaps/gear/brakes lamps, stall warning (flashing, only when `data.stall`), message banner (`message(text, 'info'|'warn'|'crash')`), debug panel (`debug([line,...])`, hidden until toggled). - `update(data)` called every frame; keep writes cheap: transform updates every frame, text updates only when values change by a threshold. - All DOM created inside rootEl; no styles inline except dynamic transforms — classes come from `css/style.css`. - Pitot/static realism is cosmetic: altimeter in ft, airspeed in kts, VS in fpm. ### `src/audio.js` exports ```js createAudio() -> { start(), setEngine(rpm01, throttle), setWind(airspeedMps), setStall(bool), touchdown(), setMuted(bool), dispose() } ``` - WebAudio, no files: engine = sawtooth + sub osc + noise → lowpass keyed to rpm, gain ∝ throttle; wind = bandpass noise ∝ airspeed; stall = rhythmic buffet pulse while true; touchdown() = one low thump. - Must be safe before user gesture (context suspended until `start()` called from a click handler). `setMuted(true)` → master gain 0. ### physics model spec (implement in physics.js) Forces in **body frame** (formulas from standard stability-axis theory): - Airspeed `V = |vel|`; `ρ(h)` from `atmoDensity` in config; `q = 0.5 ρ V²`. - α = atan2(vel.z, vel.x) (vel.z is body down; positive α = nose-up into the flow, positive lift — do NOT flip the sign in a +Z-down body frame) - Lift: `CL = CL0 + CL_alpha·α + CL_flaps(flaps) + ΔCL_groundEffect` with smooth stall: if |α| > α_crit, blend CL toward flat-plate regime so that CL peaks near α_crit (CLmax) then decays (use a smooth exponential or sine-blend; must be continuous+differentiable enough for stable integration). `L = q·S·CL`, directed **perpendicular to velocity** in the body x-z plane (apply via rotating the force vector: up = −z_body rotated by α). - Drag: `CD = CD0 + k·CL² + ΔCD_flaps + ΔCD_gear(false→true) + flatPlate(α)`, `D = −q·S·CD · (vel/V)`. - Side force: `CY = CYβ·β + CYδr·δr`, `Y = q·S·CY` along +Y body. - Moments (radians): - pitch: `Cm = Cm0 + CmAlpha·α + Cmδe·δe + Cmp·(q·c̄/(2V))`, `M_pitch = q·S·c̄·Cm` - roll: `Cl = Clβ·β + Clδa·δa + Clp·(p·b/(2V))`, `M_roll = q·S·b·Cl` - yaw: `Cn = Cnβ·β + Cnδr·δr + Cnδa·δa + Cnr·(r·b/(2V))`, `M_yaw = q·S·b·Cn` - Engine: spool `rpm → throttle` with `τ=engTau`; thrust `T = Tmax·rpm·(ρ/ρ0)^0.7` along +X; prop torque `Qz = −propTorque·Tmax·rpm` about +X (unwinds to left... pick sign that makes torque oppose prop spin; document choice); gyro moment `M_gyro = −ω × H`, `H = Iprop·ω_prop·[1,0,0]` (ω_prop rad/s from rpm). - Inertial coupling: `a_ang = invI · (M_total − ω × (I·ω))` with full I matrix (Ixx ≈ 820, Iyy ≈ 3100, Izz ≈ 3300, Ixz ≈ 60 — use 3×3 symmetric). - Gravity: transform g·[0,−1,0] into body frame, add to force. - Integration: semi-implicit Euler, substep dt = 1/240 (internal; caller passes ≤ 1/120). Position integrate with world velocity (rotate body vel by quat). **Normalize quaternion every substep.** Fuel burn ∝ rpm·throttle·fuelBurn. - Ground: if `pos.y ≤ getGroundHeight(x,z) + wheelH`: - compute sink speed & attitude; classify **crash** (gear down, landing allowed ≤ `maxTouchVz`=3 m/s; gear up ≤ 1.2 m/s; |roll|>75° or inverted → crash; nose-down pitch < −25° → crash) else **landed/rolling**. - soft ground constraint: clamp y to ground, kill normal velocity, apply rolling friction (∝ μ·m·g along velocity), braking multiplies μ ×3, rudder steers on ground (add yaw moment ∝ rudder·speed), taxi thrust. - `groundContact=true` while touching; `rolling = groundContact && v_horiz > 0.5`. - Crash: set `crashed=true`, freeze inputs effect, keep gentle settle for ~2 s so the scene looks right; `impactSpeed` recorded for the message. ### Required unit tests (`tests/physics.test.mjs`, node:test) `node --test tests/physics.test.mjs` must pass, deterministic: 1. **Trim/cruise**: at 1000 m, trim elevator per config trim, throttle 0.7, after 20 s simulated airspeed within ±5% of design cruise (≈ `sqrt(2·W/(ρ·S·CL_trim))`; compute expected from config, assert 120–180 kts). 2. **Stall**: full up elevator, 30% throttle, from trimmed cruise → α exceeds α_crit within 25 s and `stall` flag true (lift collapse / sink). 3. **Inverted sign**: at α = −10°, CL is negative (lift points down). 4. **Ground stop**: spawn on runway, wheels down, brakes on, from 30 m/s: comes to rest in < 400 m and never sinks below ground (y never < ground−0.1). 5. **Heavy impact**: vel.y = −8 m/s at ground with gear up → crashed true. 6. **Quaternion unit**: after 2000 substeps |quat| within 1e-6 of 1. 7. **Lateral symmetry**: roll right for 3 s vs roll left for 3 s → |bank| within 2% after equal time. Tests may spawn helpers; use `createAircraft()` then set state directly. ## Integrator checklist (main.js + index.html + css + README) - Fixed-timestep loop: accumulate real dt (clamp 0.05), run `step` at 1/120 increments, max 8 per frame, then render. Use THREE.Clock. - Camera modes (C cycles): chase (smooth follow ~[−8, 4, 0] behind nose, lerp), cockpit (at `cockpitOffset`, slight g-force sway), orbit (rotate around ship; drag to rotate is a nice-to-have). - Renderer: antialias, pixelRatio min(devicePixelRatio, 2), shadowMap off unless cheap; `renderer.setAnimationLoop` not needed — own rAF loop. - Start overlay (title, controls from `KEY_BINDINGS`, "click to start" → audio.start(), input focus). P pause (freeze physics, keep HUD), R reset, D debug panel (rpm/fps/fuel/lift-drag numbers). - Crash → red message, auto reset after 2.5 s (or R). - Spawn: `WORLD.spawn`, gear down, flaps 0.25, throttle 0, brakes on, engine idle on. - Styling: dark cockpit frame, HUD fonts monospace, glare-free. One CSS file. - `index.html`: ``, `
`, ``. MUST load three via `import * as THREE from '../vendor/three.module.min.js'` — no external network at runtime. - README.md: what it is, controls table, how to serve (`python3 -m http.server` or `npx serve`), architecture bullets, physics model summary.