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/kin...
Scanned 9/3/2026
Install to Claude Code
npx -y skills add jasonkneen/tiny-world-builder --skill tinyworld-flight-sim --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Tinyworld Flight Sim?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/jasonkneen-tinyworld-flight-sim)More formats (shields.io, HTML) on the badges page.
# 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`:
1. 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.
2. 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.
3. Click `.flight-menu-btn` → `camera.fov === 60`, `window.__flightActive === true`.
4. Dispatch `keydown` ShiftLeft+KeyW, run ~2.5s → the stamp's world position moves.
5. `Escape` → `camera.fov === 28`, `__flightActive === false`, stamp re-parked.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!