tinyworld-flight-sim
Flyable plane flight, ported from the standalone ships/flight-sim3.html arcade
model. The plane is NOT a bespoke tool or object kind — it is the existing
crop-duster / stunt-plane that the codebase already ships as a model-stamp
(models/stunt_plane.glb, stamp id stunt-plane, label "Stunt Plane"), placed
through the Stamps system. The placed cell is therefore
kind: 'model-stamp' with appearance.modelStampId === 'stunt-plane'.
Do not reintroduce a separate plane tool/kind — that was a wrong turn. Flight
attaches to whatever model-stamp the player already placed, detected by signature.
Where it lives
engine/world/34-flight-sim.js — the whole system: trimmed physics, flight cameras,
enterFlight/exitFlight, tickFlight, HUD, Enter/Fly menu, key capture, and
isFlyableStampCell(cell) (the detector). Loaded between 33-radial-menu.js
and 99-late-boot.js.
isFlyableStampCell(cell) returns true when cell.kind === 'model-stamp' and
the resolved getModelStamp(appearance.modelStampId) signature matches
/plane|aircraft|airplane|stunt|crop-?duster|jet/. Exposed as
window.isFlyableStampCell.
- Wiring hooks:
20-input-place-erase.js — applyTool gains if (window.__flightActive) return;.
In the pointerup click-resolution block, a plain (unmodified) click on a
flyable stamp opens the Enter/Fly menu regardless of the active tool (you
still erase it with the eraser). This runs BEFORE the place/select branches so
a click never stacks/replaces the plane. NOTE: a plain Select click resolves
here, NOT in applyTool (applyTool is only reached by non-select tools).
25-animation-loop-schema.js — if (window.tickFlight) window.tickFlight(dt);
right before renderScene().
styles/tiny-world.css — .flight-hud, .flight-menu, .flight-menu-btn,
body.flight-active.
The scale trick (do not "fix" this)
The ships model is tuned for a kilometre-scale world (cruise ~77 m/s). Physics
runs untouched in sim space; the flown object (the placed stamp group) position
is mapped into the scene via a single similarity transform:
scenePos = sceneOrigin + yawQuat * ((simPos - simOrigin) * FLIGHT_SIM_TO_SCENE)
(FLIGHT_SIM_TO_SCENE = 0.09). sceneOrigin/yaw are captured from the parked
stamp at enterFlight. Ground is a flat sim plane at spawn height.
- Camera is the exception: the chase cam is framed in scene units (~3–5.5
units behind, ~1.45 up — the plane is ~1.35 units wide), NOT run through the sim-scale
transform. Running the camera offset through the sim scale parks it inside the
tail (the original bug). Controls: arrows Up/Down = throttle, Left/Right = rudder.
Flow
- Place the Stunt Plane from Stamps (it is a model-stamp).
- Plain-click the placed plane →
showFlightMenu → "Enter / Fly" →
enterFlight(x,z): swaps the global camera to a flightCam (FOV 60), captures
keys, and uses the placed stamp group as the flown mesh (flightJet); spins
the named propeller mesh in place and adds the Dusty-style translucent strobe
disc whose opacity flickers with throttle.
- Controls: W = nose down, S or X = nose up (pitch), A/D roll, Q/E yaw, Shift/Ctrl OR
ArrowUp/ArrowDown = throttle, ArrowLeft/ArrowRight = rudder, B brake.
- Combat controls live in
41-flight-combat.js while flying: left mouse or
Space holds guns, X fires a missile, and right-click fires one missile via
window.__flightMissilePressed / window.__flightMissileHeld. The pointer
handler in 34-flight-sim.js must prevent the context menu only while
flightActive is true. Missile hits and destruction should use the pooled
flight explosion sprites (tw_flight_explosions) plus smoke trails rather
than adding DOM effects or build-mode controls.
- Default view while flying is the LOS drone camera, not the chase camera. It
captures a fixed observer anchor near the launch/island position and then pans
and adjusts FOV to keep filming the plane. The enlarged map panel carries the
close chase/cockpit feed, and the swap button can make either feed primary.
Escape → exitFlight(): restores the previous camera, calls updateCamera(),
re-renders the cell to re-park the stamp.
- Lobby vehicles are self-replenishing:
enterFlight() detaches the clicked
stamp mesh as the active flyer, clears entry.object, and immediately
renderCellObject()s a fresh parked copy in the same cell. Do not mutate the
world cell or broadcast a cell edit for this handoff; the placed stamp remains
the shared lobby spawn so every player can take a plane independently.
- Flight combat has finite gun and missile ammo.
41-flight-combat.js owns the
ammo counters plus the tw_flight_resupply_rewards pool: glowing airborne
rings placed ahead of the flyer that refill guns and missiles when flown
through, then respawn farther ahead.
Gotchas
- Module 34 shares the global scope with every other
engine/world module — all
its top-level scratch globals are _fl…-prefixed to avoid the duplicate-
identifier instantiation failure (see AGENTS.md "Project shape"). A duplicate
top-level const/let/function name silently kills the whole module.
- The tool picker is a search palette (
#palette-search → #palette-results),
not a fixed toolbar; the stunt plane is found under Stamps, not a tool button.
- The stunt plane propeller mesh (
SM_Veh_Plane_Stunt_01_Prop) already has its
local origin at the hub. Match the crop-duster path by rotating that mesh in
place around local Z; do not wrap it in a new AABB-centred pivot, or it orbits
off-centre instead of spinning like the ambient dusting plane.
- The flight propeller must read like the ambient Dusty/crop-duster prop while
borrowing the duplicate-island engine tint: high apparent RPM, a dark
translucent disc, and mostly faded physical blades at cruise throttle. A solid
blade screenshot means the visual balance is wrong even if the mesh is
technically rotating.
- The blur shape should be a round camera-facing disc, not a projected oval from
a tilted local mesh. Use the circular sprite treatment for flight-camera
readability.
- Flight is arcade-scale for a tiny world:
enterFlight launches the plane
already cruising just above the board (initial forward speed + throttle 0.6)
so there is no runway taxi phase. Collision and landing are checked in scene space against the
TinyWorld board surface plus object bounds, then converted back to sim-space Y.
The collision hot path must stay candidate-based: collect the small 3x3-ish
cell window around the plane in home/world coordinates plus the matching local
window for each editable island, then test only those rendered cellMeshes
entries. Do not return to splitting and scanning every cellMeshes key per
frame. Shallow, upright touchdowns become ROLLING / LANDED; hard terrain
strikes or object hits stop the plane and show a collision/hard-landing status.
Tuning lives in FCFG, FLIGHT_SIM_TO_SCENE (0.09), and the
FLIGHT_SCENE_* collision constants.
Verify (real app, real pointer pipeline — not synthetic shortcuts)
npm run dev, then via agent-browser eval:
- Place:
setCell(x,z,{terrain:'grass',terrainFloors:1,kind:'model-stamp',floors:1,appearance:{modelStampId:'stunt-plane'}});
isFlyableStampCell(getWorldCell(x,z)) is true once the GLB loads.
- Find a screen point where
pickTile(px,py) resolves to the plane cell (project +
search a small window — projecting the centre alone misses, terrain raycast is
angled), then dispatch real pointerdown/pointerup there → .flight-menu appears.
- Click
.flight-menu-btn → camera.fov === 60, window.__flightActive === true.
- Dispatch
keydown ShiftLeft+KeyW, run ~2.5s → the stamp's world position moves.
Escape → camera.fov === 28, __flightActive === false, stamp re-parked.
1---2name: tinyworld-flight-sim3description: tinyworld-flight-sim4---5# tinyworld-flight-sim67Flyable plane flight, ported from the standalone `ships/flight-sim3.html` arcade8model. **The plane is NOT a bespoke tool or object kind** — it is the existing9crop-duster / stunt-plane that the codebase already ships as a **model-stamp**10(`models/stunt_plane.glb`, stamp id `stunt-plane`, label "Stunt Plane"), placed11through the **Stamps** system. The placed cell is therefore12`kind: 'model-stamp'` with `appearance.modelStampId === 'stunt-plane'`.1314Do not reintroduce a separate `plane` tool/kind — that was a wrong turn. Flight15attaches to whatever model-stamp the player already placed, detected by signature.1617## Where it lives18- `engine/world/34-flight-sim.js` — the whole system: trimmed physics, flight cameras,19 `enterFlight`/`exitFlight`, `tickFlight`, HUD, Enter/Fly menu, key capture, and20 `isFlyableStampCell(cell)` (the detector). Loaded between `33-radial-menu.js`21 and `99-late-boot.js`.22- `isFlyableStampCell(cell)` returns true when `cell.kind === 'model-stamp'` and23 the resolved `getModelStamp(appearance.modelStampId)` signature matches24 `/plane|aircraft|airplane|stunt|crop-?duster|jet/`. Exposed as25 `window.isFlyableStampCell`.26- Wiring hooks:27 - `20-input-place-erase.js` — `applyTool` gains `if (window.__flightActive) return;`.28 In the `pointerup` click-resolution block, a **plain (unmodified) click on a29 flyable stamp opens the Enter/Fly menu regardless of the active tool** (you30 still erase it with the eraser). This runs BEFORE the place/select branches so31 a click never stacks/replaces the plane. NOTE: a plain Select click resolves32 here, NOT in `applyTool` (`applyTool` is only reached by non-select tools).33 - `25-animation-loop-schema.js` — `if (window.tickFlight) window.tickFlight(dt);`34 right before `renderScene()`.35 - `styles/tiny-world.css` — `.flight-hud`, `.flight-menu`, `.flight-menu-btn`,36 `body.flight-active`.3738## The scale trick (do not "fix" this)39The ships model is tuned for a kilometre-scale world (cruise ~77 m/s). Physics40runs untouched in **sim space**; the flown object (the placed stamp group) position41is mapped into the scene via a single **similarity transform**:42`scenePos = sceneOrigin + yawQuat * ((simPos - simOrigin) * FLIGHT_SIM_TO_SCENE)`43(`FLIGHT_SIM_TO_SCENE = 0.09`). `sceneOrigin`/yaw are captured from the parked44stamp at `enterFlight`. Ground is a flat sim plane at spawn height.45- **Camera is the exception**: the chase cam is framed in **scene units** (~3–5.546 units behind, ~1.45 up — the plane is ~1.35 units wide), NOT run through the sim-scale47 transform. Running the camera offset through the sim scale parks it inside the48 tail (the original bug). Controls: arrows Up/Down = throttle, Left/Right = rudder.4950## Flow51- Place the **Stunt Plane** from Stamps (it is a model-stamp).52- Plain-click the placed plane → `showFlightMenu` → "Enter / Fly" →53 `enterFlight(x,z)`: swaps the global `camera` to a `flightCam` (FOV 60), captures54 keys, and uses the placed stamp group as the flown mesh (`flightJet`); spins55 the named propeller mesh in place and adds the Dusty-style translucent strobe56 disc whose opacity flickers with throttle.57- Controls: W = nose down, S or X = nose up (pitch), A/D roll, Q/E yaw, Shift/Ctrl OR58 ArrowUp/ArrowDown = throttle, ArrowLeft/ArrowRight = rudder, B brake.59- Combat controls live in `41-flight-combat.js` while flying: left mouse or60 Space holds guns, `X` fires a missile, and right-click fires one missile via61 `window.__flightMissilePressed` / `window.__flightMissileHeld`. The pointer62 handler in `34-flight-sim.js` must prevent the context menu only while63 `flightActive` is true. Missile hits and destruction should use the pooled64 flight explosion sprites (`tw_flight_explosions`) plus smoke trails rather65 than adding DOM effects or build-mode controls.66- Default view while flying is the LOS drone camera, not the chase camera. It67 captures a fixed observer anchor near the launch/island position and then pans68 and adjusts FOV to keep filming the plane. The enlarged map panel carries the69 close chase/cockpit feed, and the swap button can make either feed primary.70- `Escape` → `exitFlight()`: restores the previous camera, calls `updateCamera()`,71 re-renders the cell to re-park the stamp.72- Lobby vehicles are self-replenishing: `enterFlight()` detaches the clicked73 stamp mesh as the active flyer, clears `entry.object`, and immediately74 `renderCellObject()`s a fresh parked copy in the same cell. Do not mutate the75 world cell or broadcast a cell edit for this handoff; the placed stamp remains76 the shared lobby spawn so every player can take a plane independently.77- Flight combat has finite gun and missile ammo. `41-flight-combat.js` owns the78 ammo counters plus the `tw_flight_resupply_rewards` pool: glowing airborne79 rings placed ahead of the flyer that refill guns and missiles when flown80 through, then respawn farther ahead.8182## Gotchas83- Module 34 shares the global scope with every other `engine/world` module — all84 its top-level scratch globals are `_fl…`-prefixed to avoid the duplicate-85 identifier instantiation failure (see AGENTS.md "Project shape"). A duplicate86 top-level `const`/`let`/`function` name silently kills the whole module.87- The tool picker is a **search palette** (`#palette-search` → `#palette-results`),88 not a fixed toolbar; the stunt plane is found under Stamps, not a tool button.89- The stunt plane propeller mesh (`SM_Veh_Plane_Stunt_01_Prop`) already has its90 local origin at the hub. Match the crop-duster path by rotating that mesh in91 place around local Z; do not wrap it in a new AABB-centred pivot, or it orbits92 off-centre instead of spinning like the ambient dusting plane.93- The flight propeller must read like the ambient Dusty/crop-duster prop while94 borrowing the duplicate-island engine tint: high apparent RPM, a dark95 translucent disc, and mostly faded physical blades at cruise throttle. A solid96 blade screenshot means the visual balance is wrong even if the mesh is97 technically rotating.98- The blur shape should be a round camera-facing disc, not a projected oval from99 a tilted local mesh. Use the circular sprite treatment for flight-camera100 readability.101- Flight is arcade-scale for a tiny world: `enterFlight` launches the plane102 already cruising just above the board (initial forward speed + throttle 0.6)103 so there is no runway taxi phase. Collision and landing are checked in scene space against the104 TinyWorld board surface plus object bounds, then converted back to sim-space Y.105 The collision hot path must stay candidate-based: collect the small 3x3-ish106 cell window around the plane in home/world coordinates plus the matching local107 window for each editable island, then test only those rendered `cellMeshes`108 entries. Do not return to splitting and scanning every `cellMeshes` key per109 frame. Shallow, upright touchdowns become `ROLLING` / `LANDED`; hard terrain110 strikes or object hits stop the plane and show a collision/hard-landing status.111 Tuning lives in `FCFG`, `FLIGHT_SIM_TO_SCENE` (0.09), and the112 `FLIGHT_SCENE_*` collision constants.113114## Verify (real app, real pointer pipeline — not synthetic shortcuts)115`npm run dev`, then via agent-browser `eval`:1161. Place: `setCell(x,z,{terrain:'grass',terrainFloors:1,kind:'model-stamp',floors:1,appearance:{modelStampId:'stunt-plane'}})`;117 `isFlyableStampCell(getWorldCell(x,z))` is true once the GLB loads.1182. Find a screen point where `pickTile(px,py)` resolves to the plane cell (project +119 search a small window — projecting the centre alone misses, terrain raycast is120 angled), then dispatch real `pointerdown`/`pointerup` there → `.flight-menu` appears.1213. Click `.flight-menu-btn` → `camera.fov === 60`, `window.__flightActive === true`.1224. Dispatch `keydown` ShiftLeft+KeyW, run ~2.5s → the stamp's world position moves.1235. `Escape` → `camera.fov === 28`, `__flightActive === false`, stamp re-parked.