- Detailed flight dynamics: aero lift/drag/side-force with smooth stall, control-surface moments, stability derivatives, engine spool with prop torque and gyro precession, inertial coupling, ISA atmosphere, ground effect, 3-point gear handling, landing vs crash classification. - Pure ESM, zero build step; three.js vendored locally. Deterministic simulation; physics core is Node-testable (14 node:test cases). - Procedural 8 km world with runway, water, sky dome, cloud wrap; DOM instrument panel (attitude indicator, tapes, VSI, lamps, stall warning). - Controls: WASD/arrows, Q/E rudder, Shift/Ctrl throttle, F flaps, G gear, B brakes, C camera (chase/cockpit/orbit), R reset, P pause, D debug.
15 KiB
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)
SPEC.mdis ground truth for all interfaces. Read it fully before coding. Readsrc/config.jsandsrc/world.jsbefore coding anything that uses them.- 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 referencewindow,document, orTHREEthere. - SI units everywhere internally (m, kg, s, rad, N, Pa). Display conversions
(
kts,ft,fpm) live only in instruments.js/main.js. - 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). - 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.
- After writing any
.js/.mjsfile, runnode --check <file>and fix everything it flags. (The integrator must add apackage.jsonwith"type": "module"so Node treats.jsfiles as ESM for--checkand tests.) - 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 fromsrc/). - No console.log spam in hot paths (one-shot messages OK but keep minimal).
- 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),δepositive = trailing edge down.aileron > 0→ roll right (right wing down, bank right).rudder > 0→ yaw right (nose to starboard).- Body rates:
proll right,qpitch up,ryaw right (about body +X/+Y/+Z). - Vertical speed > 0 = climbing.
- The physics
aircraft.quatorients 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/.positioncan 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)
{
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
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
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
buildAircraftModel() -> { group, animate(state, inputs, dt), cockpitOffset() -> [x,y,z] }
groupis 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 byanimate. 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. ProvidegetDimensions()→ { length, height } if easy.
src/terrain.js exports
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
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
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)fromatmoDensityin 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_groundEffectwith 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·CYalong +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
- pitch:
- Engine: spool
rpm → throttlewithτ=engTau; thrustT = Tmax·rpm·(ρ/ρ0)^0.7along +X; prop torqueQz = −propTorque·Tmax·rpmabout +X (unwinds to left... pick sign that makes torque oppose prop spin; document choice); gyro momentM_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=truewhile touching;rolling = groundContact && v_horiz > 0.5.
- compute sink speed & attitude; classify crash (gear down, landing
allowed ≤
- Crash: set
crashed=true, freeze inputs effect, keep gentle settle for ~2 s so the scene looks right;impactSpeedrecorded for the message.
Required unit tests (tests/physics.test.mjs, node:test)
node --test tests/physics.test.mjs must pass, deterministic:
- 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). - Stall: full up elevator, 30% throttle, from trimmed cruise → α exceeds
α_crit within 25 s and
stallflag true (lift collapse / sink). - Inverted sign: at α = −10°, CL is negative (lift points down).
- 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).
- Heavy impact: vel.y = −8 m/s at ground with gear up → crashed true.
- Quaternion unit: after 2000 substeps |quat| within 1e-6 of 1.
- 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
stepat 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.setAnimationLoopnot 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:<canvas id="sim"></canvas>,<div id="hud"></div>,<script type="module" src="./src/main.js"></script>. MUST load three viaimport * 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.serverornpx serve), architecture bullets, physics model summary.