From 21fb16f722db541a58f0393953592f2e5f28a5fe Mon Sep 17 00:00:00 2001
From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com>
Date: Wed, 19 Aug 2026 22:16:08 -0400
Subject: [PATCH 1/3] flight: native free-flight
(creative/spectator/noclip/hover) + correct strafe
---
.claude/skills/jgengine-gameplay/api.md | 1 +
.claude/skills/jgengine-world/api.md | 37 +-
.claude/skills/jgengine-world/capabilities.md | 4 +
packages/core/src/game/playableGame.ts | 33 ++
packages/core/src/movement/freeFlight.test.ts | 203 ++++++++++
packages/core/src/movement/freeFlight.ts | 356 ++++++++++++++++++
packages/core/src/movement/playerMovement.ts | 124 +++++-
packages/core/src/runtime/headlessRunner.ts | 11 +-
packages/core/src/world.ts | 17 +
packages/shell/src/drivers/FrameDriver.tsx | 1 +
scripts/export-manifest.json | 1 +
11 files changed, 783 insertions(+), 5 deletions(-)
create mode 100644 packages/core/src/movement/freeFlight.test.ts
create mode 100644 packages/core/src/movement/freeFlight.ts
diff --git a/.claude/skills/jgengine-gameplay/api.md b/.claude/skills/jgengine-gameplay/api.md
index a2e9f4dd6..15d7a8d93 100644
--- a/.claude/skills/jgengine-gameplay/api.md
+++ b/.claude/skills/jgengine-gameplay/api.md
@@ -694,6 +694,7 @@
- `DirectionalLightingConfig` (interface): interface DirectionalLightingConfig — ⚠ undocumented
- `EntitySpriteConfig` (interface): interface EntitySpriteConfig — ⚠ undocumented
- `FirstPersonCameraConfig` (interface): interface FirstPersonCameraConfig — ⚠ undocumented
+- `FlightConfig` (interface): interface FlightConfig — Free-flight families moved into the walk controller so one seam covers ground + air — creative/spectator/noclip/hover are weightless, aircraft/rotorcraft stay on `flightDynamics`.
- `GameCameraConfig` (interface): interface GameCameraConfig — Camera tuning for the shell's rig stack: pick the rig via `rig`, then tune it through its matching config block. All fields optional — the default is the third-person orbit rig.
- `GameCaptureConfig` (interface): interface GameCaptureConfig — How a game makes itself screenshottable: the commands that reach live play or a named screen, the framings worth re-capturing, and the progress metrics a bot playtest samples. Declaring these once is what lets a capture host reproduce a view instead of re-deriving it by hand every run.
- `GameCaptureView` (interface): interface GameCaptureView — How a screenshot host reaches live gameplay in this game — the data behind `shoot --mode play`.
diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md
index 7760decb5..5629bf81e 100644
--- a/.claude/skills/jgengine-world/api.md
+++ b/.claude/skills/jgengine-world/api.md
@@ -526,6 +526,24 @@
- `placeFormation` (function): function placeFormation(destination: Vec2, facing: number, count: number, generator: FormationSlotGenerator): Vec2[] — Transform a generator's local slot offsets into world XZ positions around a `destination`, rotated by `facing` (engine yaw). Slot `i` in the returned array is `generator(count)[i]` mapped through the group frame, so it stays aligned with {@link assignFormationSlots}' slot indices. Pure and allocation- light: one array of `count` points, no per-call closures retained.
- `wedgeFormation` (function): function wedgeFormation(options: WedgeFormationOptions): FormationSlotGenerator — A "V"/arrowhead with the apex at the destination and arms trailing back — a flying-wedge charge or a goose skein. Slot 0 is the tip; later slots alternate right then left, each rank stepping one `spacing` outward and backward.
+## @jgengine/core/movement/freeFlight
+
+- `CREATIVE_FLIGHT_TUNING` (const): const CREATIVE_FLIGHT_TUNING: FreeFlightTuning — Preset for Minecraft-like creative flight — weightless, collides, yaw-relative strafe.
+- `FreeFlightController` (interface): interface FreeFlightController — Stateful handle for any free-flight actor with snapshot/restore/retune.
+- `FreeFlightIntent` (interface): interface FreeFlightIntent — Intent for one free-flight tick — forward/right from WASD/analog, vertical from jump/crouch.
+- `FreeFlightMode` (type): type FreeFlightMode = "creative" | "spectator" | "noclip" | "hover" — Free-flight families — character-scale 6DOF movement, distinct from vehicle aerodynamics.
+- `FreeFlightState` (interface): interface FreeFlightState — Velocity state for a free-flight actor — serializable and ownable by the caller.
+- `FreeFlightStep` (interface): interface FreeFlightStep — World displacement produced by one free-flight tick.
+- `FreeFlightTuning` (interface): interface FreeFlightTuning — Data-first tuning for one free-flight profile.
+- `HOVER_FLIGHT_TUNING` (const): const HOVER_FLIGHT_TUNING: FreeFlightTuning — Preset for hover/jetpack — gravity + thrust, useful for short bursts.
+- `NOCLIP_FLIGHT_TUNING` (const): const NOCLIP_FLIGHT_TUNING: FreeFlightTuning — Preset for noclip — weightless, noclips, yaw-relative with independent vertical.
+- `SPECTATOR_FLIGHT_TUNING` (const): const SPECTATOR_FLIGHT_TUNING: FreeFlightTuning — Preset for spectator — weightless, noclips, flies where the camera looks.
+- `advanceFreeFlight` (function): function advanceFreeFlight(state: FreeFlightState, intent: FreeFlightIntent, yaw: number, pitch: number | undefined, dt: number, tuning: FreeFlightTuning): FreeFlightStep — Advance one frame of free-flight kinematics. Horizontal uses yaw-relative strafe (A = left, D = right) — never roll/bank. Vertical is independent of pitch unless `alignWithLook` is true (spectator).
+- `createFreeFlightController` (function): function createFreeFlightController(initialTuning: FreeFlightTuning, options: { yaw?: number; pitch?: number; state?: FreeFlightState } = {}): FreeFlightController — Totally free flight for any actor — character, camera, drone, or debug rig. Horizontal is always yaw-relative strafe; vertical is jump/crouch. Use `alignWithLook` for a 6DOF spectator that flies where the camera looks.
+- `createFreeFlightState` (function): function createFreeFlightState(): FreeFlightState — Create an empty flight velocity state.
+- `resolveFlightStep` (function): function resolveFlightStep(position: readonly [number, number, number], stepX: number, stepY: number, stepZ: number, obstacles: readonly CollisionObstacle[], radius: number = DEFAULT_OBSTACLE_PLAYER_RADIUS): { stepX: number; stepY: number; stepZ: number } — Resolve a flight step against world solids (XZ slide + Y clamp).
+- `resolveFreeFlightIntent` (function): function resolveFreeFlightIntent(keys: MovementKeysState, analog: AnalogMoveIntent | null | undefined): FreeFlightIntent — Translate held keys + analog into a free-flight intent. Vertical is `space - (control|c)` so jump ascends and crouch descends — the same keys that walk/jump/crouch use, re-mapped for flight without re-binding.
+
## @jgengine/core/movement/glideModel
- `GlideInput` (interface): interface GlideInput — ⚠ undocumented
@@ -573,7 +591,7 @@
- `playerMovementHeading` (function): function playerMovementHeading(ctx: GameContext, userId: string): number — One player's current heading (radians), integrated by {@link stepPlayerMovement} — the shell reads it back into its camera/aim yaw.
- `resolvePhysicsTuning` (function): function resolvePhysicsTuning(physics: PhysicsConfig | undefined): MovementTuningOverrides | undefined — Maps a game's declared `physics` onto the movement controllers' tuning. `PhysicsConfig.gravity` is a signed world acceleration (negative points down), but the controllers integrate `velocityY -= gravityAcceleration * dt` and expect a positive downward magnitude — so gravity is negated here to keep down-pointing gravity pulling down.
- `resolvePlayerMovementTuning` (function): function resolvePlayerMovementTuning(opts: { collision?: VoxelCollisionConfig; movement?: PlayerMovementConfig; physics?: PhysicsConfig; world?: WorldFeature; }): PlayerMovementTuning — Gather a game's collision/movement/physics/world config into a {@link PlayerMovementTuning} — call once per world; both the shell and a host pass the result to {@link stepPlayerMovement}.
-- `stepPlayerMovement` (function): function stepPlayerMovement(ctx: GameContext, userId: string, input: InputFrame, dt: number, tuning: PlayerMovementTuning, heading?: number): void — Integrate one player's movement for a tick from their held-input frame and commit the pose — the single genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call, so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain, scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions.
+- `stepPlayerMovement` (function): function stepPlayerMovement(ctx: GameContext, userId: string, input: InputFrame, dt: number, tuning: PlayerMovementTuning, heading?: number, pitch?: number): void — Integrate one player's movement for a tick from their held-input frame and commit the pose — the single genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call, so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain, scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. Pass `pitch` for 6DOF spectator flight when `alignWithLook` is set.
## @jgengine/core/movement/poseState
@@ -1657,6 +1675,7 @@
- `CITY_TREE_SPECIES` (const): const CITY_TREE_SPECIES: readonly CityTreeSpecies[] — All species, for schema hints and validation.
- `CITY_ZONE_KIND` (const): const CITY_ZONE_KIND: "cityzone" — The editor volume kind that locally overrides a city's zone band/mix — a district in a district.
- `CITY_ZONE_SCHEMA` (const): const CITY_ZONE_SCHEMA: ParamSchema — Zone-override schema for `cityzone` volumes: pin a band and optionally a bespoke class mix.
+- `CREATIVE_FLIGHT_TUNING` (const): const CREATIVE_FLIGHT_TUNING: FreeFlightTuning — Preset for Minecraft-like creative flight — weightless, collides, yaw-relative strafe.
- `CameraShakeConfig` (interface): interface CameraShakeConfig — Configuration for {@link createCameraShake}. Every field has a game-feel default.
- `CameraShakeController` (interface): interface CameraShakeController — A live, dt-driven, trauma-based camera shake controller.
- `CameraShakeOffset` (interface): interface CameraShakeOffset — A per-frame camera shake offset: three positional axes plus three rotational axes (radians).
@@ -1765,6 +1784,12 @@
- `ForceVolume` (class): class ForceVolume — A trigger region that pushes bodies passing through it — boost pads (`impulse` + `once`), conveyors (`velocity`), fans/wind (`accelerate`). Call `apply` each tick; `once` mode fires only on entry by tracking membership between ticks.
- `FormationSlotGenerator` (type): type FormationSlotGenerator = (count: number) => Vec2[] — Produces `count` slot offsets in the group's local frame — `[right, forward]` where `+forward` points where the group faces. Index order is the slot order; a generator must be pure (same `count` → same offsets) so placement stays deterministic. Sample generators below cover common shapes; games pass their own for anything else (crowds, convoys, sports positions) with no engine edit.
- `FramingConfig` (interface): interface FramingConfig — ⚠ undocumented
+- `FreeFlightController` (interface): interface FreeFlightController — Stateful handle for any free-flight actor with snapshot/restore/retune.
+- `FreeFlightIntent` (interface): interface FreeFlightIntent — Intent for one free-flight tick — forward/right from WASD/analog, vertical from jump/crouch.
+- `FreeFlightMode` (type): type FreeFlightMode = "creative" | "spectator" | "noclip" | "hover" — Free-flight families — character-scale 6DOF movement, distinct from vehicle aerodynamics.
+- `FreeFlightState` (interface): interface FreeFlightState — Velocity state for a free-flight actor — serializable and ownable by the caller.
+- `FreeFlightStep` (interface): interface FreeFlightStep — World displacement produced by one free-flight tick.
+- `FreeFlightTuning` (interface): interface FreeFlightTuning — Data-first tuning for one free-flight profile.
- `FreezeMonitor` (interface): interface FreezeMonitor — ⚠ undocumented
- `FreezeViolation` (interface): interface FreezeViolation — ⚠ undocumented
- `Frustum` (interface): interface Frustum — ⚠ undocumented
@@ -1788,6 +1813,7 @@
- `GroundMode` (type): type GroundMode = "flat" | "round" | "voxel" | "board" — Canonical ground modes after normalization (`stage` → `board`).
- `GroundPoint` (type): type GroundPoint = readonly [number, number] — A world-space point on the ground plane as an `[x, z]` pair.
- `GuideRegion` (interface): interface GuideRegion — An axis-aligned XZ rectangle to generate terrain-readability guides within.
+- `HOVER_FLIGHT_TUNING` (const): const HOVER_FLIGHT_TUNING: FreeFlightTuning — Preset for hover/jetpack — gravity + thrust, useful for short bursts.
- `HeatConfig` (interface): interface HeatConfig — Tuning for {@link createHeatState}/{@link advanceHeat} — levels, decay, and pursuit-spawn ring.
- `HeatGain` (interface): interface HeatGain — One crime tick's contribution — only `witnessed` gains raise heat (unseen crimes are free, GTA-style).
- `HeatLevelDef` (interface): interface HeatLevelDef — One escalation tier — the heat threshold it begins at and the pursuer count it wants active.
@@ -1857,6 +1883,7 @@
- `MovementPose` (type): type MovementPose = "standing" | "crouch" | "prone" | "running" — ⚠ undocumented
- `MusicInstrument` (type): type MusicInstrument = | "strings" | "flute" | "harp" | "horn" | "choir" | "bell" | "timpani" | "bass" | "stacc" | "pad" | "lute" | "dulcimer" | "frameDrum" | "warDrum" | "reed" | "pipe" | "squareLead" | "woodBlock" | "tinyBell" | "piano" | "shaker" | "brassStab" | "cymSwell" | "oboe" — Named synthesised instrument. Each maps to a voice in the shell's instrument library (`@jgengine/shell/audio/musicVoices`); an unknown name falls back to a plain sine voice so a theme is never silent.
- `MusicTheme` (interface): interface MusicTheme — A through-composed, looping music track. `events` need not be sorted; the director schedules them ahead against a fixed anchor so loops are seamless.
+- `NOCLIP_FLIGHT_TUNING` (const): const NOCLIP_FLIGHT_TUNING: FreeFlightTuning — Preset for noclip — weightless, noclips, yaw-relative with independent vertical.
- `NavGrid` (interface): interface NavGrid — ⚠ undocumented
- `NavPoint` (type): type NavPoint = readonly [number, number] — ⚠ undocumented
- `NoiseFieldConfig` (interface): interface NoiseFieldConfig — Configuration for {@link noiseField}: seed, amplitude, and fractal noise shaping.
@@ -1961,6 +1988,7 @@
- `SHAPE_SPHERE` (const): const SHAPE_SPHERE: 1 — ⚠ undocumented
- `SOIL_KIND` (const): const SOIL_KIND: "soil" — The editor volume kind marking a box as a soil crack/moss patch.
- `SOIL_SCHEMA` (const): const SOIL_SCHEMA: ParamSchema — The soil parameter schema — drives the inspector and `meta` parse via the studio seam.
+- `SPECTATOR_FLIGHT_TUNING` (const): const SPECTATOR_FLIGHT_TUNING: FreeFlightTuning — Preset for spectator — weightless, noclips, flies where the camera looks.
- `SampleBatchOptions` (interface): interface SampleBatchOptions
— Inputs for a {@link sampleBatch} draw of many spaced points.
- `SampleBatchResult` (interface): interface SampleBatchResult
— Structured batch outcome: the placed points plus whether the full count was met.
- `SampleConstraints` (interface): interface SampleConstraints
— Post-draw gates a candidate must clear. All are optional; an empty constraint set accepts every in-region draw. `exclude` discs/spheres are the keep-out distance primitive ("no closer than R to this center"); `accept` is a free-form caller predicate (biome, slope, ownership); `project` snaps a candidate onto a surface/navmesh before validation and may reject by returning `null`.
@@ -2127,6 +2155,7 @@
- `WorldXZ` (type): type WorldXZ = readonly [number, number] — ⚠ undocumented
- `acquireTarget` (function): function acquireTarget(policy: AcquisitionPolicy, selfId: string, held: string | null = null): AcquisitionResult — Run one acquisition pass and pick the best target under `policy`. Pass the currently `held` target so retention hysteresis (`switchMargin`, `dropRangeScale`) can keep the lock stable; pass `null` for a cold acquire. Pure and allocation-light — the caller owns the held-target state.
- `advanceBehaviors` (function): function advanceBehaviors(ctx: GameContext, dt: number): void — Advance every spawned entity carrying a `patrol` or `wander` {@link BehaviorDescriptor} one tick — the engine reads the descriptor, keeps the per-entity nav state itself, and poses the entity, so ambient traffic and idle NPC routes are register-once (attach the behavior at spawn) instead of a per-game per-frame `advancePathFollow` + `setPose` loop. Instances that are paused or disabled through {@link behaviorControl} retain their state and are skipped. The shell/host call this each frame; a game never does.
+- `advanceFreeFlight` (function): function advanceFreeFlight(state: FreeFlightState, intent: FreeFlightIntent, yaw: number, pitch: number | undefined, dt: number, tuning: FreeFlightTuning): FreeFlightStep — Advance one frame of free-flight kinematics. Horizontal uses yaw-relative strafe (A = left, D = right) — never roll/bank. Vertical is independent of pitch unless `alignWithLook` is true (spectator).
- `advanceInterestGate` (function): function advanceInterestGate(state: InterestGateState, config: InterestSchedulerConfig, dt: number, input: InterestGateInput = {}): InterestGateStep — Advance one gate by `dt` seconds against this tick's `input`, mutating `state` in place and returning what the caller should do. Sleeping skips the expensive work (`active: false`) while the gate's timers keep advancing, so state is preserved; a `wake` signal or crossing `wakeRadius` flips it back to active and fires an immediate tick.
- `advancePathFollow` (function): function advancePathFollow(config: PathFollowConfig, state: PathFollowState, dt: number): PathFollowState — Advance a path-follower by `speed * dt` along its authored polyline. Pure — returns the next state. Crosses multiple waypoints in one step, loops when configured, and reports `done` at the end of a non-looping path. No navmesh required (#52); feed it a navmesh route via `pathFromNav` for click-to-move (#51).
- `advanceSpawnDirector` (function): function advanceSpawnDirector(config: SpawnDirectorConfig, state: SpawnDirectorState, dt: number, ctx: DirectorContext): DirectorStep — ⚠ undocumented
@@ -2193,6 +2222,8 @@
- `createFireGrid` (function): function createFireGrid(config: FireGridConfig): FireGrid — ⚠ undocumented
- `createFogField` (function): function createFogField(config: FogConfig): FogField — ⚠ undocumented
- `createFootprintGrid` (function): function createFootprintGrid(options: FootprintGridOptions = {}): FootprintGrid — Multi-cell footprint occupancy/reservation on a shared build grid — `world/placementController` only owns the ghost preview; this is the persistent claim a committed placement holds so the next hover's `isFree` check (or another player's, in a shared world) sees it. Bridge into `world/placement`'s `PlacementRules.obstacles` with {@link footprintObstacles} instead of hand-rolling an occupancy map per game.
+- `createFreeFlightController` (function): function createFreeFlightController(initialTuning: FreeFlightTuning, options: { yaw?: number; pitch?: number; state?: FreeFlightState } = {}): FreeFlightController — Totally free flight for any actor — character, camera, drone, or debug rig. Horizontal is always yaw-relative strafe; vertical is jump/crouch. Use `alignWithLook` for a 6DOF spectator that flies where the camera looks.
+- `createFreeFlightState` (function): function createFreeFlightState(): FreeFlightState — Create an empty flight velocity state.
- `createGlideModel` (function): function createGlideModel(config: GlideModelConfig = {}): GlideModel — Gliding/wingsuit descent control — lift, drag, and steering from a launch.
- `createGrappleSwing` (function): function createGrappleSwing(config: GrappleSwingConfig = {}): GrappleSwing — Grappling-hook rope swing physics with anchor, pendulum motion, and reel-in.
- `createInterestCensus` (function): function createInterestCensus(): InterestCensusAccumulator — Create a census accumulator so the caller can tally active/dormant gates during the loop it already runs, avoiding any separate full-world scan just to report scheduler metrics.
@@ -2355,6 +2386,8 @@
- `resolveControlGroupIntent` (function): function resolveControlGroupIntent(input: ControlGroupInput, options: ControlGroupOptions = {}): ControlGroupIntent — Resolve a control-group key press into a {@link ControlGroupIntent}: Ctrl+digit binds, a bare digit recalls, and a second recall of the same group within `doubleTapMs` focuses. Pure — the caller applies the intent against the store and its own focus hook, and records the returned recall for the next call.
- `resolveEmitterGain` (function): function resolveEmitterGain(distance: number, sound: Pick, busGain: number): number — ⚠ undocumented
- `resolveFacingRotationY` (function): function resolveFacingRotationY(headingDegrees: number, space?: Pick): number — The Three.js Y-rotation that makes a model whose front is authored at `forwardDegrees` visually point `headingDegrees` (engine north = `0`). This is the catalog-owned replacement for per-game corrective yaw: a model authored facing south (`forwardDegrees: 180`) placed toward north resolves to `Math.PI`.
+- `resolveFlightStep` (function): function resolveFlightStep(position: readonly [number, number, number], stepX: number, stepY: number, stepZ: number, obstacles: readonly CollisionObstacle[], radius: number = DEFAULT_OBSTACLE_PLAYER_RADIUS): { stepX: number; stepY: number; stepZ: number } — Resolve a flight step against world solids (XZ slide + Y clamp).
+- `resolveFreeFlightIntent` (function): function resolveFreeFlightIntent(keys: MovementKeysState, analog: AnalogMoveIntent | null | undefined): FreeFlightIntent — Translate held keys + analog into a free-flight intent. Vertical is `space - (control|c)` so jump ascends and crouch descends — the same keys that walk/jump/crouch use, re-mapped for flight without re-binding.
- `resolveGridInstances` (function): function resolveGridInstances(config: WorldGridConfig | GridWorldFeature): readonly GridInstanceTransform[] — ⚠ undocumented
- `resolveLocalAvoidance` (function): function resolveLocalAvoidance(agents: AvoidanceAgent[], options: LocalAvoidanceOptions = {}): number — Resolve overlaps in `agents` in place and return how many overlapping pairs remained on the final pass (`0` = fully separated). Uses a bounded uniform hash grid sized to the largest agent, so only nearby agents are ever compared. Deterministic: corrections are accumulated then applied per pass, independent of agent order. Pass `weights` to pin or differentially push agents.
- `resolvePlaceAsset` (function): function resolvePlaceAsset(input: ResolvePlaceAssetInput): PlaceAssetResult — Resolve a place-asset intent into a shared payload (editor + games, one verb).
@@ -2405,7 +2438,7 @@
- `sphereRegion` (function): function sphereRegion(center: Point3, radius: number, options: { distribution?: VolumeDistribution } = {}): SampleRegion — A filled ball. `"volume"` is volume-uniform (∛-corrected radius); `"radial"` is radius-uniform. Direction is drawn first (two draws), then radius.
- `steerYaw` (function): function steerYaw(yaw: number, steerRight: number, turnRatePerSecond: number, dt: number): number — Integrate one steering step. `steerRight` is the signed steer input (+1 = turn right, matching `DRIVE_AXIS_BINDINGS`' KeyD/ArrowRight), `turnRatePerSecond` is radians per second at full lock. Steering right decreases yaw in the engine frame; this helper owns that sign so game code never re-derives it.
- `stepLock` (function): function stepLock(spec: LockSpec, col: number, row: number, action: LockAction): { result: LockStepResult; col: number; row: number } — Authoritative single step. The caller owns the lives economy: a slip/bind/trap does not advance the pick and should cost a life; advanced/success move the pick.
-- `stepPlayerMovement` (function): function stepPlayerMovement(ctx: GameContext, userId: string, input: InputFrame, dt: number, tuning: PlayerMovementTuning, heading?: number): void — Integrate one player's movement for a tick from their held-input frame and commit the pose — the single genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call, so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain, scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions.
+- `stepPlayerMovement` (function): function stepPlayerMovement(ctx: GameContext, userId: string, input: InputFrame, dt: number, tuning: PlayerMovementTuning, heading?: number, pitch?: number): void — Integrate one player's movement for a tick from their held-input frame and commit the pose — the single genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call, so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain, scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. Pass `pitch` for 6DOF spectator flight when `alignWithLook` is set.
- `sumMagnitude` (function): function sumMagnitude(memberships: readonly AreaMembership
[], magnitudeOf: MagnitudeOf
): number — Sum a numeric magnitude across memberships — additive aggregation for a total field strength (total damage per tick, total slow). A terminal reducer, not a filter, so it returns the number.
- `summarizeElevation` (function): function summarizeElevation(sampleHeight: HeightSampler, region: GuideRegion, resolution = 64, maxSamples = 256): ElevationSummary — Summarises elevation across a region on a bounded sample grid: min, max, mean, relief range, and the world points of the extremes — the selection min/max and legend feedback the readability overlay reports, and the input to {@link chooseContourInterval}. Pure math, renderer-agnostic.
- `summarizeEnvironment` (function): function summarizeEnvironment(feature: EnvironmentWorldFeature): EnvironmentSummary — ⚠ undocumented
diff --git a/.claude/skills/jgengine-world/capabilities.md b/.claude/skills/jgengine-world/capabilities.md
index f3f9d228a..c21f8d08f 100644
--- a/.claude/skills/jgengine-world/capabilities.md
+++ b/.claude/skills/jgengine-world/capabilities.md
@@ -279,6 +279,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
- `DEFAULT_FORWARD` (const) · `import { DEFAULT_FORWARD } from "@jgengine/core/world"`
+## free-flight — camera-relative creative/spectator/noclip/hover flight with correct strafe, sprint, and optional gravity
+
+- `createFreeFlightController` (function) · `import { createFreeFlightController } from "@jgengine/core/world"`
+
## glide — gliding/wingsuit descent control from a launch
- `createGlideModel` (function) · `import { createGlideModel } from "@jgengine/core/world"`
diff --git a/packages/core/src/game/playableGame.ts b/packages/core/src/game/playableGame.ts
index 57a247ee5..74651a5d4 100644
--- a/packages/core/src/game/playableGame.ts
+++ b/packages/core/src/game/playableGame.ts
@@ -315,6 +315,30 @@ export interface BackdropConfig {
fog?: BackdropFogConfig;
}
+/** Free-flight families moved into the walk controller so one seam covers ground + air — creative/spectator/noclip/hover are weightless, aircraft/rotorcraft stay on `flightDynamics`. */
+export interface FlightConfig {
+ /** Which free-flight family to use when flight is active. Default "creative". */
+ mode?: "creative" | "spectator" | "noclip" | "hover";
+ /** Horizontal base speed (units/s). Default 8. */
+ speed?: number;
+ /** Vertical base speed for ascend/descend. Default = `speed`. */
+ verticalSpeed?: number;
+ /** Sprint multiplier while shift is held. Default 1.8. */
+ sprintMultiplier?: number;
+ /** Lerp rate toward target velocity. Default 20. */
+ acceleration?: number;
+ /** Downward acceleration for hover mode. Default 0 (weightless). */
+ gravity?: number;
+ /** Upward thrust while ascending in hover. Default 32. */
+ thrust?: number;
+ /** When true, W moves along the look pitch (6DOF spectator). Default false. */
+ alignWithLook?: boolean;
+ /** Whether flight slides against world solids. Default true for creative/hover, false for spectator/noclip. */
+ collide?: boolean;
+ /** Gate flight behind live game state — creative toggle, stamina check, etc. `false` stays walking. Default true when `flight` is present. */
+ canFly?: (ctx: GameContext) => boolean;
+}
+
/** Movement-control levers for the shell-driven local player walk controller. */
export interface PlayerMovementConfig {
/** "free" (default) moves camera-relative across the plane; "axis" locks travel to one world axis; "grid" snaps each committed position to cell centers. */
@@ -345,6 +369,15 @@ export interface PlayerMovementConfig {
swim?: { speedMultiplier?: number } | boolean;
/** Slide the player downhill on terrain steeper than they can stand on (heightfield worlds only). `true` uses defaults; default off. */
slopeSlide?: { maxClimbSlope?: number } | boolean;
+ /**
+ * Free-flight — creative/spectator/noclip/hover — folded into the same walk controller so a game
+ * doesn't hand-roll a second movement loop to get Minecraft-like flight. `true` uses creative defaults;
+ * an object tunes speed/vertical/sprint/collide/canFly. Horizontal is always yaw-relative strafe
+ * (A = left, D = right — never roll/bank), vertical is Space/Ctrl, and aircraft/rotorcraft remain
+ * on `physics/flightDynamics`. The shell drives this automatically when present; `canFly` toggles
+ * between walking and flying per tick without rebuilding the config.
+ */
+ flight?: FlightConfig | boolean;
}
/** One frame's movement resolution handed to `PlayerMovementConfig.beforeCommit`. */
diff --git a/packages/core/src/movement/freeFlight.test.ts b/packages/core/src/movement/freeFlight.test.ts
new file mode 100644
index 000000000..48aff0f8c
--- /dev/null
+++ b/packages/core/src/movement/freeFlight.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, test } from "bun:test";
+
+import {
+ advanceFreeFlight,
+ createFreeFlightController,
+ createFreeFlightState,
+ resolveFreeFlightIntent,
+ resolveFlightStep,
+} from "./freeFlight";
+import { createEmptyMovementKeys } from "./movementModel";
+
+const DT = 1 / 60;
+
+function keysFrom(held: string[]): ReturnType {
+ const k = createEmptyMovementKeys();
+ for (const h of held) {
+ if (h === "moveForward" || h === "w") k.w = true;
+ if (h === "moveBack" || h === "s") k.s = true;
+ if (h === "moveLeft" || h === "a") k.a = true;
+ if (h === "moveRight" || h === "d") k.d = true;
+ if (h === "jump" || h === "space") k.space = true;
+ if (h === "crouch" || h === "control" || h === "c") { k.control = true; k.c = true; }
+ if (h === "sprint" || h === "shift") k.shift = true;
+ }
+ return k;
+}
+
+describe("resolveFreeFlightIntent", () => {
+ test("WASD maps to forward/right with correct strafe sign", () => {
+ const a = resolveFreeFlightIntent(keysFrom(["a"]), null);
+ expect(a.right).toBe(-1);
+ expect(a.forward).toBe(0);
+ expect(a.vertical).toBe(0);
+ const d = resolveFreeFlightIntent(keysFrom(["d"]), null);
+ expect(d.right).toBe(1);
+ const w = resolveFreeFlightIntent(keysFrom(["w"]), null);
+ expect(w.forward).toBe(1);
+ const s = resolveFreeFlightIntent(keysFrom(["s"]), null);
+ expect(s.forward).toBe(-1);
+ });
+
+ test("space ascends, control descends", () => {
+ const up = resolveFreeFlightIntent(keysFrom(["space"]), null);
+ expect(up.vertical).toBe(1);
+ const down = resolveFreeFlightIntent(keysFrom(["control"]), null);
+ expect(down.vertical).toBe(-1);
+ const both = resolveFreeFlightIntent(keysFrom(["space", "control"]), null);
+ expect(both.vertical).toBe(0);
+ });
+});
+
+describe("advanceFreeFlight creative", () => {
+ test("A moves +X when facing +Z (south), D moves -X — strafe stays yaw-relative, never roll", () => {
+ const tuning = { mode: "creative" as const, speed: 8, acceleration: 40 };
+ const yaw = 0;
+ let state = createFreeFlightState();
+ const aIntent = resolveFreeFlightIntent(keysFrom(["a"]), null);
+ let step = advanceFreeFlight(state, aIntent, yaw, 0, DT, tuning);
+ for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, aIntent, yaw, 0, DT, tuning);
+ expect(step.stepX).toBeGreaterThan(0);
+ expect(Math.abs(step.stepZ)).toBeLessThan(0.01);
+
+ state = createFreeFlightState();
+ const dIntent = resolveFreeFlightIntent(keysFrom(["d"]), null);
+ step = advanceFreeFlight(state, dIntent, yaw, 0, DT, tuning);
+ for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, dIntent, yaw, 0, DT, tuning);
+ expect(step.stepX).toBeLessThan(0);
+ });
+
+ test("W moves +Z, S moves -Z when yaw 0", () => {
+ const tuning = { mode: "creative" as const, speed: 8, acceleration: 40 };
+ const yaw = 0;
+ let state = createFreeFlightState();
+ const wIntent = resolveFreeFlightIntent(keysFrom(["w"]), null);
+ let step = advanceFreeFlight(state, wIntent, yaw, 0, DT, tuning);
+ for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, wIntent, yaw, 0, DT, tuning);
+ expect(step.stepZ).toBeGreaterThan(0);
+ expect(Math.abs(step.stepX)).toBeLessThan(0.01);
+
+ state = createFreeFlightState();
+ const sIntent = resolveFreeFlightIntent(keysFrom(["s"]), null);
+ step = advanceFreeFlight(state, sIntent, yaw, 0, DT, tuning);
+ for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, sIntent, yaw, 0, DT, tuning);
+ expect(step.stepZ).toBeLessThan(0);
+ });
+
+ test("W with yaw 90° (east) moves +X, A strafe is still yaw-relative", () => {
+ const tuning = { mode: "creative" as const, speed: 8, acceleration: 40 };
+ const yaw = Math.PI / 2;
+ let state = createFreeFlightState();
+ const wIntent = resolveFreeFlightIntent(keysFrom(["w"]), null);
+ let step = advanceFreeFlight(state, wIntent, yaw, 0, DT, tuning);
+ for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, wIntent, yaw, 0, DT, tuning);
+ expect(step.stepX).toBeGreaterThan(0);
+ expect(Math.abs(step.stepZ)).toBeLessThan(0.02);
+
+ state = createFreeFlightState();
+ const aIntent = resolveFreeFlightIntent(keysFrom(["a"]), null);
+ step = advanceFreeFlight(state, aIntent, yaw, 0, DT, tuning);
+ for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, aIntent, yaw, 0, DT, tuning);
+ expect(step.stepZ).toBeLessThan(0);
+ });
+
+ test("space ascends, crouch descends, vertical independent of yaw", () => {
+ const tuning = { mode: "creative" as const, speed: 8, acceleration: 40 };
+ const yaw = 0.7;
+ let state = createFreeFlightState();
+ const up = resolveFreeFlightIntent(keysFrom(["space"]), null);
+ let step = advanceFreeFlight(state, up, yaw, 0, DT, tuning);
+ for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, up, yaw, 0, DT, tuning);
+ expect(step.stepY).toBeGreaterThan(0);
+ expect(Math.abs(step.stepX)).toBeLessThan(0.01);
+
+ state = createFreeFlightState();
+ const down = resolveFreeFlightIntent(keysFrom(["control"]), null);
+ step = advanceFreeFlight(state, down, yaw, 0, DT, tuning);
+ for (let i = 0; i < 20; i++) step = advanceFreeFlight(state, down, yaw, 0, DT, tuning);
+ expect(step.stepY).toBeLessThan(0);
+ });
+
+ test("sprint multiplies speed, diagonal stays normalized", () => {
+ const tuning = { mode: "creative" as const, speed: 8, sprintMultiplier: 2, acceleration: 40 };
+ const yaw = 0;
+ let state = createFreeFlightState();
+ const w = resolveFreeFlightIntent(keysFrom(["w"]), null);
+ let step = advanceFreeFlight(state, w, yaw, 0, DT, tuning);
+ for (let i = 0; i < 30; i++) step = advanceFreeFlight(state, w, yaw, 0, DT, tuning);
+ const walkZ = step.stepZ;
+
+ state = createFreeFlightState();
+ const wSprint = resolveFreeFlightIntent(keysFrom(["w", "shift"]), null);
+ step = advanceFreeFlight(state, wSprint, yaw, 0, DT, tuning);
+ for (let i = 0; i < 30; i++) step = advanceFreeFlight(state, wSprint, yaw, 0, DT, tuning);
+ expect(step.stepZ).toBeGreaterThan(walkZ * 1.8);
+
+ state = createFreeFlightState();
+ const diag = resolveFreeFlightIntent(keysFrom(["w", "d"]), null);
+ step = advanceFreeFlight(state, diag, yaw, 0, DT, tuning);
+ for (let i = 0; i < 30; i++) step = advanceFreeFlight(state, diag, yaw, 0, DT, tuning);
+ const diagSpeed = Math.hypot(step.stepX, step.stepZ) / DT;
+ expect(diagSpeed).toBeLessThan(8.1);
+ expect(diagSpeed).toBeGreaterThan(7.5);
+ });
+
+ test("spectator alignWithLook: forward along pitch climbs", () => {
+ const tuning = { mode: "spectator" as const, speed: 8, acceleration: 40, alignWithLook: true };
+ const yaw = 0;
+ const pitch = Math.PI / 4;
+ let state = createFreeFlightState();
+ const w = resolveFreeFlightIntent(keysFrom(["w"]), null);
+ let step = advanceFreeFlight(state, w, yaw, pitch, DT, tuning);
+ for (let i = 0; i < 30; i++) step = advanceFreeFlight(state, w, yaw, pitch, DT, tuning);
+ expect(step.stepY).toBeGreaterThan(0.05);
+ expect(step.stepZ).toBeGreaterThan(0.05);
+ });
+
+ test("hover with gravity falls when no input, rises when ascending", () => {
+ const tuning = { mode: "hover" as const, speed: 6, gravity: 20, thrust: 40, acceleration: 20 };
+ let state = createFreeFlightState();
+ const idle = resolveFreeFlightIntent(keysFrom([]), null);
+ let step = advanceFreeFlight(state, idle, 0, 0, DT, tuning);
+ for (let i = 0; i < 60; i++) step = advanceFreeFlight(state, idle, 0, 0, DT, tuning);
+ expect(step.stepY).toBeLessThan(0);
+
+ state = createFreeFlightState();
+ const up = resolveFreeFlightIntent(keysFrom(["space"]), null);
+ step = advanceFreeFlight(state, up, 0, 0, DT, tuning);
+ for (let i = 0; i < 60; i++) step = advanceFreeFlight(state, up, 0, 0, DT, tuning);
+ expect(step.stepY).toBeGreaterThan(0);
+ });
+});
+
+describe("createFreeFlightController", () => {
+ test("snapshot/restore round-trip and retune changes speed", () => {
+ const ctrl = createFreeFlightController({ mode: "creative", speed: 8 });
+ const intent = resolveFreeFlightIntent(keysFrom(["w"]), null);
+ ctrl.tick(DT, intent, 0);
+ for (let i = 0; i < 10; i++) ctrl.tick(DT, intent, 0);
+ const snap = ctrl.snapshot();
+ expect(snap.vx).toBeDefined();
+ ctrl.tick(DT, intent, 0);
+ ctrl.restore(snap);
+ expect(ctrl.snapshot()).toEqual(snap);
+ ctrl.retune({ mode: "creative", speed: 16 });
+ let s = ctrl.snapshot();
+ expect(s).toEqual(snap);
+ const stepFast = ctrl.tick(DT, intent, 0);
+ for (let i = 0; i < 20; i++) ctrl.tick(DT, intent, 0);
+ const fast = ctrl.velocity();
+ expect(Math.hypot(fast[0], fast[2])).toBeGreaterThan(7);
+ expect(stepFast.stepX).toBeDefined();
+ ctrl.reset();
+ expect(ctrl.snapshot().vx).toBe(0);
+ });
+
+ test("resolveFlightStep clamps vertical ceiling", () => {
+ const pos: [number, number, number] = [0, 1, 0];
+ const stepY = 2;
+ const obstacles = [{ position: [0, 3, 0] as const, halfExtents: [1, 0.5, 1] as const }];
+ const res = resolveFlightStep(pos, 0, stepY, 0, obstacles, 0.3);
+ expect(res.stepY).toBeLessThan(stepY);
+ });
+});
diff --git a/packages/core/src/movement/freeFlight.ts b/packages/core/src/movement/freeFlight.ts
new file mode 100644
index 000000000..9f6ca23a4
--- /dev/null
+++ b/packages/core/src/movement/freeFlight.ts
@@ -0,0 +1,356 @@
+import { MOVEMENT_TUNING, type AnalogMoveIntent, type CollisionObstacle, type MovementKeysState } from "./movementModel";
+import { DEFAULT_OBSTACLE_PLAYER_RADIUS } from "./movementModel";
+
+/** Free-flight families — character-scale 6DOF movement, distinct from vehicle aerodynamics. */
+export type FreeFlightMode = "creative" | "spectator" | "noclip" | "hover";
+
+/** Data-first tuning for one free-flight profile. */
+export interface FreeFlightTuning {
+ mode: FreeFlightMode;
+ /** Horizontal base speed (units/s) before sprint. Default 8. */
+ speed?: number;
+ /** Vertical base speed for ascend/descend. Default = `speed`. */
+ verticalSpeed?: number;
+ /** Sprint multiplier while shift is held. Default 1.8. */
+ sprintMultiplier?: number;
+ /** Lerp rate toward target velocity. Default 20. */
+ acceleration?: number;
+ /** Extra damping applied when no input (0 = rely on lerp). Default 0. */
+ damping?: number;
+ /** Downward acceleration for hover mode. Default 0 (weightless). */
+ gravity?: number;
+ /** Upward thrust acceleration while ascending in hover. Default 32. */
+ thrust?: number;
+ /** When true, forward moves along the look pitch (spectator). Default false. */
+ alignWithLook?: boolean;
+ /** Top vertical fall speed in hover. Default 20. */
+ maxFallSpeed?: number;
+ /** Whether to slide against world solids. Default true for creative/hover, false for spectator/noclip. */
+ collide?: boolean;
+}
+
+/** Velocity state for a free-flight actor — serializable and ownable by the caller. */
+export interface FreeFlightState {
+ vx: number;
+ vy: number;
+ vz: number;
+}
+
+/** Intent for one free-flight tick — forward/right from WASD/analog, vertical from jump/crouch. */
+export interface FreeFlightIntent {
+ forward: number;
+ right: number;
+ vertical: number;
+ sprint: boolean;
+ moving: boolean;
+}
+
+/** World displacement produced by one free-flight tick. */
+export interface FreeFlightStep {
+ stepX: number;
+ stepY: number;
+ stepZ: number;
+}
+
+/** Stateful handle for any free-flight actor with snapshot/restore/retune. */
+export interface FreeFlightController {
+ tick(dt: number, intent: FreeFlightIntent, yaw: number, pitch?: number): FreeFlightStep;
+ velocity(): readonly [number, number, number];
+ snapshot(): FreeFlightState;
+ restore(next: FreeFlightState): void;
+ retune(next: FreeFlightTuning): void;
+ reset(): void;
+}
+
+const ANALOG_EPSILON = 0.02;
+
+/** Create an empty flight velocity state. */
+export function createFreeFlightState(): FreeFlightState {
+ return { vx: 0, vy: 0, vz: 0 };
+}
+
+/**
+ * Translate held keys + analog into a free-flight intent.
+ * Vertical is `space - (control|c)` so jump ascends and crouch descends — the same
+ * keys that walk/jump/crouch use, re-mapped for flight without re-binding.
+ */
+export function resolveFreeFlightIntent(
+ keys: MovementKeysState,
+ analog: AnalogMoveIntent | null | undefined,
+): FreeFlightIntent {
+ let forward = (keys.w ? 1 : 0) - (keys.s ? 1 : 0);
+ let right = (keys.d ? 1 : 0) - (keys.a ? 1 : 0);
+ if (analog !== undefined && analog !== null) {
+ forward = Math.abs(analog.forward) < ANALOG_EPSILON ? 0 : Math.max(-1, Math.min(1, analog.forward));
+ right = Math.abs(analog.right) < ANALOG_EPSILON ? 0 : Math.max(-1, Math.min(1, analog.right));
+ }
+ const vertical = (keys.space ? 1 : 0) - (keys.control || keys.c ? 1 : 0);
+ const sprint = keys.shift && vertical >= 0;
+ const moving = forward !== 0 || right !== 0 || vertical !== 0;
+ return { forward, right, vertical, sprint, moving };
+}
+
+/**
+ * Advance one frame of free-flight kinematics.
+ * Horizontal uses yaw-relative strafe (A = left, D = right) — never roll/bank.
+ * Vertical is independent of pitch unless `alignWithLook` is true (spectator).
+ */
+export function advanceFreeFlight(
+ state: FreeFlightState,
+ intent: FreeFlightIntent,
+ yaw: number,
+ pitch: number | undefined,
+ dt: number,
+ tuning: FreeFlightTuning,
+): FreeFlightStep {
+ const delta = Math.min(dt, MOVEMENT_TUNING.maxFrameSeconds);
+ if (delta <= 0) return { stepX: 0, stepY: 0, stepZ: 0 };
+ const speed = tuning.speed ?? 8;
+ const verticalSpeed = tuning.verticalSpeed ?? speed;
+ const sprintMult = tuning.sprintMultiplier ?? 1.8;
+ const accel = tuning.acceleration ?? 20;
+ const damping = tuning.damping ?? 0;
+ const gravity = tuning.gravity ?? 0;
+ const thrust = tuning.thrust ?? 32;
+ const maxFall = tuning.maxFallSpeed ?? 20;
+ const align = tuning.alignWithLook === true && pitch !== undefined;
+
+ const sprintScale = intent.sprint ? sprintMult : 1;
+
+ let targetX = 0;
+ let targetZ = 0;
+ let targetY = 0;
+
+ if (align) {
+ const cp = Math.cos(pitch!);
+ const sp = Math.sin(pitch!);
+ const sy = Math.sin(yaw);
+ const cy = Math.cos(yaw);
+ const fx = sy * cp;
+ const fy = sp;
+ const fz = cy * cp;
+ const rx = cy;
+ const rz = -sy;
+ const rawX = fx * intent.forward + rx * intent.right;
+ const rawY = fy * intent.forward;
+ const rawZ = fz * intent.forward + rz * intent.right;
+ const hLen = Math.hypot(rawX, rawY, rawZ);
+ if (hLen > 1e-6) {
+ const scale = Math.min(1, hLen) / hLen;
+ const sx = rawX * scale;
+ const sy2 = rawY * scale;
+ const sz = rawZ * scale;
+ const horizScale = speed * sprintScale;
+ const vertScale = verticalSpeed * sprintScale;
+ targetX = sx * horizScale;
+ targetY = sy2 * horizScale + intent.vertical * vertScale;
+ targetZ = sz * horizScale;
+ } else {
+ targetY = intent.vertical * verticalSpeed * sprintScale;
+ }
+ } else {
+ const sy = Math.sin(yaw);
+ const cy = Math.cos(yaw);
+ let fx = sy;
+ let fz = cy;
+ const lenSq = fx * fx + fz * fz;
+ if (lenSq < 1e-6) {
+ fx = 0;
+ fz = -1;
+ } else {
+ const inv = 1 / Math.sqrt(lenSq);
+ fx *= inv;
+ fz *= inv;
+ }
+ const rx = -fz;
+ const rz = fx;
+ let rawX = fx * intent.forward + rx * intent.right;
+ let rawZ = fz * intent.forward + rz * intent.right;
+ const len = Math.hypot(rawX, rawZ);
+ if (len > 1e-6) {
+ const scale = Math.min(1, len) / len;
+ rawX *= scale;
+ rawZ *= scale;
+ } else {
+ rawX = 0;
+ rawZ = 0;
+ }
+ targetX = rawX * speed * sprintScale;
+ targetZ = rawZ * speed * sprintScale;
+ targetY = intent.vertical * verticalSpeed * sprintScale;
+ }
+
+ if (gravity > 0) {
+ const alpha = 1 - Math.exp(-accel * delta);
+ state.vx += (targetX - state.vx) * alpha;
+ state.vz += (targetZ - state.vz) * alpha;
+ if (damping > 0 && !intent.moving) {
+ const damp = Math.exp(-damping * delta);
+ state.vx *= damp;
+ state.vz *= damp;
+ }
+ const thrustAccel = intent.vertical * thrust;
+ state.vy += (thrustAccel - gravity) * delta;
+ if (intent.vertical !== 0) {
+ const vyTarget = targetY;
+ const vyAlpha = 1 - Math.exp(-accel * delta);
+ state.vy += (vyTarget - state.vy) * vyAlpha * 0.5;
+ }
+ if (damping > 0 && intent.vertical === 0) {
+ state.vy *= Math.exp(-damping * delta * 0.5);
+ }
+ if (state.vy < -maxFall) state.vy = -maxFall;
+ } else {
+ const alpha = 1 - Math.exp(-accel * delta);
+ state.vx += (targetX - state.vx) * alpha;
+ state.vy += (targetY - state.vy) * alpha;
+ state.vz += (targetZ - state.vz) * alpha;
+ if (damping > 0 && !intent.moving) {
+ const damp = Math.exp(-damping * delta);
+ state.vx *= damp;
+ state.vy *= damp;
+ state.vz *= damp;
+ }
+ }
+
+ return { stepX: state.vx * delta, stepY: state.vy * delta, stepZ: state.vz * delta };
+}
+
+/** Resolve a flight step against world solids (XZ slide + Y clamp). */
+export function resolveFlightStep(
+ position: readonly [number, number, number],
+ stepX: number,
+ stepY: number,
+ stepZ: number,
+ obstacles: readonly CollisionObstacle[],
+ radius: number = DEFAULT_OBSTACLE_PLAYER_RADIUS,
+): { stepX: number; stepY: number; stepZ: number } {
+ const feetY = position[1];
+ const headY = feetY + 1.8;
+ let clampedY = stepY;
+ const nextY = feetY + stepY;
+ const nextHead = nextY + 1.8;
+ for (const ob of obstacles) {
+ const ox = ob.position[0] + (ob.offset?.[0] ?? 0);
+ const oy = ob.position[1] + (ob.offset?.[1] ?? 0);
+ const oz = ob.position[2] + (ob.offset?.[2] ?? 0);
+ const hx = ob.halfExtents?.[0] ?? 0.5;
+ const hy = ob.halfExtents?.[1] ?? 0.5;
+ const hz = ob.halfExtents?.[2] ?? 0.5;
+ if (ob.boxes !== undefined && ob.boxes.length > 0) {
+ for (const box of ob.boxes) {
+ const minX = ox + box.min[0] - radius;
+ const maxX = ox + box.max[0] + radius;
+ const minY = oy + box.min[1];
+ const maxY = oy + box.max[1];
+ const minZ = oz + box.min[2] - radius;
+ const maxZ = oz + box.max[2] + radius;
+ const insideXZ = position[0] > minX && position[0] < maxX && position[2] > minZ && position[2] < maxZ;
+ if (!insideXZ) continue;
+ if (stepY > 0 && feetY < maxY && nextHead > minY && nextHead > maxY && feetY < minY) {
+ clampedY = Math.min(clampedY, minY - headY - 1e-3);
+ } else if (stepY < 0 && headY > minY && nextY < maxY && nextY < minY && headY > maxY) {
+ clampedY = Math.max(clampedY, maxY - feetY + 1e-3);
+ }
+ }
+ continue;
+ }
+ const minX = ox - hx - radius;
+ const maxX = ox + hx + radius;
+ const minY = oy - hy;
+ const maxY = oy + hy;
+ const minZ = oz - hz - radius;
+ const maxZ = oz + hz + radius;
+ const insideXZ = position[0] > minX && position[0] < maxX && position[2] > minZ && position[2] < maxZ;
+ if (!insideXZ) continue;
+ if (stepY > 0 && feetY < maxY && nextHead > minY && nextHead > maxY && feetY < minY) {
+ clampedY = Math.min(clampedY, minY - headY - 1e-3);
+ } else if (stepY < 0 && headY > minY && nextY < maxY && nextY < minY && headY > maxY) {
+ clampedY = Math.max(clampedY, maxY - feetY + 1e-3);
+ }
+ }
+ return { stepX, stepY: clampedY, stepZ };
+}
+
+/**
+ * Totally free flight for any actor — character, camera, drone, or debug rig.
+ * Horizontal is always yaw-relative strafe; vertical is jump/crouch. Use `alignWithLook`
+ * for a 6DOF spectator that flies where the camera looks.
+ * @capability free-flight camera-relative creative/spectator/noclip/hover flight with correct strafe, sprint, and optional gravity
+ */
+export function createFreeFlightController(
+ initialTuning: FreeFlightTuning,
+ options: { yaw?: number; pitch?: number; state?: FreeFlightState } = {},
+): FreeFlightController {
+ let tuning = initialTuning;
+ const state: FreeFlightState = options.state !== undefined ? { ...options.state } : createFreeFlightState();
+ let yaw = options.yaw ?? 0;
+ let pitch = options.pitch ?? 0;
+
+ return {
+ tick(dt, intent, nextYaw, nextPitch) {
+ yaw = nextYaw;
+ if (nextPitch !== undefined) pitch = nextPitch;
+ return advanceFreeFlight(state, intent, yaw, pitch, dt, tuning);
+ },
+ velocity: () => [state.vx, state.vy, state.vz],
+ snapshot: () => ({ ...state }),
+ restore(next) {
+ state.vx = next.vx;
+ state.vy = next.vy;
+ state.vz = next.vz;
+ },
+ retune(next) {
+ tuning = next;
+ },
+ reset() {
+ state.vx = 0;
+ state.vy = 0;
+ state.vz = 0;
+ },
+ };
+}
+
+/** Preset for Minecraft-like creative flight — weightless, collides, yaw-relative strafe. */
+export const CREATIVE_FLIGHT_TUNING: FreeFlightTuning = {
+ mode: "creative",
+ speed: 8,
+ verticalSpeed: 8,
+ sprintMultiplier: 2.2,
+ acceleration: 22,
+ collide: true,
+};
+
+/** Preset for spectator — weightless, noclips, flies where the camera looks. */
+export const SPECTATOR_FLIGHT_TUNING: FreeFlightTuning = {
+ mode: "spectator",
+ speed: 12,
+ verticalSpeed: 12,
+ sprintMultiplier: 2.5,
+ acceleration: 18,
+ collide: false,
+ alignWithLook: true,
+};
+
+/** Preset for noclip — weightless, noclips, yaw-relative with independent vertical. */
+export const NOCLIP_FLIGHT_TUNING: FreeFlightTuning = {
+ mode: "noclip",
+ speed: 14,
+ verticalSpeed: 14,
+ sprintMultiplier: 3,
+ acceleration: 18,
+ collide: false,
+ alignWithLook: false,
+};
+
+/** Preset for hover/jetpack — gravity + thrust, useful for short bursts. */
+export const HOVER_FLIGHT_TUNING: FreeFlightTuning = {
+ mode: "hover",
+ speed: 6,
+ verticalSpeed: 6,
+ sprintMultiplier: 1.6,
+ acceleration: 16,
+ gravity: 18,
+ thrust: 34,
+ collide: true,
+};
diff --git a/packages/core/src/movement/playerMovement.ts b/packages/core/src/movement/playerMovement.ts
index 07aab703b..662e57532 100644
--- a/packages/core/src/movement/playerMovement.ts
+++ b/packages/core/src/movement/playerMovement.ts
@@ -21,6 +21,14 @@ import {
type MovementTuningOverrides,
type PlayerMotionState,
} from "./movementModel";
+import {
+ advanceFreeFlight,
+ createFreeFlightState,
+ resolveFlightStep,
+ resolveFreeFlightIntent,
+ type FreeFlightState,
+ type FreeFlightTuning,
+} from "./freeFlight";
import { solidObstaclesNear } from "./solidObstacles";
import { approachYaw, steerYaw } from "./steering";
import {
@@ -91,6 +99,7 @@ interface PlayerMovementState {
facing: number | null;
voxelBody: VoxelPlayerBody | null;
motion: PlayerMotionState | null;
+ flight: FreeFlightState | null;
}
interface CtxMovementStore {
@@ -115,7 +124,7 @@ function storeFor(ctx: GameContext): CtxMovementStore {
function stateFor(store: CtxMovementStore, userId: string): PlayerMovementState {
let state = store.players.get(userId);
if (state === undefined) {
- state = { heading: 0, facing: null, voxelBody: null, motion: null };
+ state = { heading: 0, facing: null, voxelBody: null, motion: null, flight: null };
store.players.set(userId, state);
}
return state;
@@ -159,13 +168,38 @@ function resolveBodyFacing(
return next;
}
+function flightTuningFor(
+ movement: PlayerMovementConfig | undefined,
+ ctx: GameContext,
+): FreeFlightTuning | null {
+ const cfg = movement?.flight;
+ if (cfg === undefined || cfg === false) return null;
+ if (cfg === true) return { mode: "creative" };
+ if (typeof cfg === "object") {
+ if (cfg.canFly !== undefined && !cfg.canFly(ctx)) return null;
+ return {
+ mode: cfg.mode ?? "creative",
+ speed: cfg.speed,
+ verticalSpeed: cfg.verticalSpeed,
+ sprintMultiplier: cfg.sprintMultiplier,
+ acceleration: cfg.acceleration,
+ gravity: cfg.gravity,
+ thrust: cfg.thrust,
+ alignWithLook: cfg.alignWithLook,
+ collide: cfg.collide,
+ };
+ }
+ return null;
+}
+
/**
* Integrate one player's movement for a tick from their held-input frame and commit the pose — the single
* genre-agnostic controller both the shell (its local player) and a host (each connected player in `onTick`) call,
* so single-player and server-authoritative movement are identical. Reads the player's controlled entity, terrain,
* scene solids, and pending motion impulses; writes the entity pose via `setPose`. Retains heading + kinematic body
* per `userId` on the `ctx`. Pass `heading` to override the internally-integrated yaw (the shell owns yaw for its
- * camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions.
+ * camera); omit it and the controller turns from the frame's `turnLeft`/`turnRight` actions. Pass `pitch` for
+ * 6DOF spectator flight when `alignWithLook` is set.
*/
export function stepPlayerMovement(
ctx: GameContext,
@@ -174,6 +208,7 @@ export function stepPlayerMovement(
dt: number,
tuning: PlayerMovementTuning,
heading?: number,
+ pitch?: number,
): void {
const playerId = ctx.player.possession.active(userId);
const player = ctx.scene.entity.get(playerId);
@@ -219,6 +254,91 @@ export function stepPlayerMovement(
const motionBatch = ctx.player.motionFor(userId).takePending();
const walkSpeed = player.movement?.walkSpeed ?? DEFAULT_WALK_SPEED;
+ const flightTuning = flightTuningFor(tuning.movement, ctx);
+ if (flightTuning !== null) {
+ keys.control = isDown("crouch") || isDown("sneak") || isDown("control") || isDown("c");
+ keys.c = keys.control;
+ const flightIntent = resolveFreeFlightIntent(keys, analogMove);
+ let flightState = state.flight;
+ if (flightState === null) {
+ flightState = createFreeFlightState();
+ state.flight = flightState;
+ }
+ if (motionBatch !== null) {
+ flightState.vy = applyMotionImpulses(flightState.vy, motionBatch);
+ const [hx, hz] = applyHorizontalImpulses(flightState.vx, flightState.vz, motionBatch);
+ flightState.vx = hx;
+ flightState.vz = hz;
+ }
+ const normalizedFlightTuning: FreeFlightTuning = {
+ mode: flightTuning.mode,
+ speed: flightTuning.speed,
+ verticalSpeed: flightTuning.verticalSpeed,
+ sprintMultiplier: flightTuning.sprintMultiplier,
+ acceleration: flightTuning.acceleration,
+ gravity: flightTuning.gravity,
+ thrust: flightTuning.thrust,
+ alignWithLook: flightTuning.alignWithLook,
+ collide: flightTuning.collide,
+ };
+ if (normalizedFlightTuning.collide === undefined) {
+ normalizedFlightTuning.collide = normalizedFlightTuning.mode === "creative" || normalizedFlightTuning.mode === "hover";
+ }
+ const step = advanceFreeFlight(flightState, flightIntent, state.heading, pitch, dt, normalizedFlightTuning);
+ let stepX = step.stepX;
+ let stepY = step.stepY;
+ let stepZ = step.stepZ;
+ if (normalizedFlightTuning.collide !== false && tuning.movement?.collideObjects !== false) {
+ const obstacles = gatherMovementObstacles(ctx, player.position, stepX, stepZ);
+ const verticalResolved = resolveFlightStep(player.position, stepX, stepY, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS);
+ stepY = verticalResolved.stepY;
+ const xzResolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, 0);
+ stepX = xzResolved.stepX;
+ stepZ = xzResolved.stepZ;
+ const ground = tuning.ground.sampleHeight(player.position[0] + stepX, player.position[2] + stepZ);
+ const nextY = player.position[1] + stepY;
+ if (nextY < ground + 0.2) {
+ stepY = ground + 0.2 - player.position[1];
+ if (flightState.vy < 0) flightState.vy = 0;
+ }
+ }
+ let nextX = player.position[0] + stepX;
+ let nextY = player.position[1] + stepY;
+ let nextZ = player.position[2] + stepZ;
+ if (motionBatch !== null && motionBatch.y !== null) {
+ nextY = motionBatch.y;
+ }
+ if (tuning.movement?.beforeCommit !== undefined) {
+ const frame: MovementCommitFrame = {
+ entityId: playerId,
+ current: player.position,
+ next: [nextX, nextY, nextZ],
+ dt,
+ ctx,
+ };
+ const replacement = tuning.movement.beforeCommit(frame);
+ if (replacement !== undefined) {
+ nextX = replacement[0];
+ nextY = replacement[1];
+ nextZ = replacement[2];
+ }
+ }
+ ctx.scene.entity.setPose(playerId, {
+ position: [nextX, nextY, nextZ],
+ rotationY: resolveBodyFacing(
+ state,
+ flightIntent.moving,
+ flightState.vx,
+ flightState.vz,
+ player.rotationY,
+ tuning.movement?.turnSpeed,
+ dt,
+ ),
+ dt,
+ });
+ return;
+ }
+
if (tuning.collision?.voxel === true) {
let body = state.voxelBody;
if (body === null) {
diff --git a/packages/core/src/runtime/headlessRunner.ts b/packages/core/src/runtime/headlessRunner.ts
index f96ea25d9..24fe0d104 100644
--- a/packages/core/src/runtime/headlessRunner.ts
+++ b/packages/core/src/runtime/headlessRunner.ts
@@ -36,8 +36,12 @@ export interface HeadlessRunnerOptions
Date: Wed, 19 Aug 2026 22:42:53 -0400
Subject: [PATCH 2/3] flight: bindings + camera per bound config
---
.claude/skills/jgengine-world/api.md | 6 +++
packages/core/src/game/playableGame.ts | 6 +++
packages/core/src/movement/freeFlight.ts | 48 +++++++++++++++++++
packages/core/src/movement/playerMovement.ts | 22 +++++++--
packages/core/src/physics/flightDynamics.ts | 9 ++++
packages/core/src/physics/kinematicVehicle.ts | 7 ++-
packages/core/src/world.ts | 3 ++
7 files changed, 96 insertions(+), 5 deletions(-)
diff --git a/.claude/skills/jgengine-world/api.md b/.claude/skills/jgengine-world/api.md
index 5629bf81e..b0b7d7de6 100644
--- a/.claude/skills/jgengine-world/api.md
+++ b/.claude/skills/jgengine-world/api.md
@@ -529,6 +529,7 @@
## @jgengine/core/movement/freeFlight
- `CREATIVE_FLIGHT_TUNING` (const): const CREATIVE_FLIGHT_TUNING: FreeFlightTuning — Preset for Minecraft-like creative flight — weightless, collides, yaw-relative strafe.
+- `FreeFlightBindings` (interface): interface FreeFlightBindings — Per-axis bindings for free-flight — which actions drive each flight axis. Positive is forward/right/up.
- `FreeFlightController` (interface): interface FreeFlightController — Stateful handle for any free-flight actor with snapshot/restore/retune.
- `FreeFlightIntent` (interface): interface FreeFlightIntent — Intent for one free-flight tick — forward/right from WASD/analog, vertical from jump/crouch.
- `FreeFlightMode` (type): type FreeFlightMode = "creative" | "spectator" | "noclip" | "hover" — Free-flight families — character-scale 6DOF movement, distinct from vehicle aerodynamics.
@@ -543,6 +544,7 @@
- `createFreeFlightState` (function): function createFreeFlightState(): FreeFlightState — Create an empty flight velocity state.
- `resolveFlightStep` (function): function resolveFlightStep(position: readonly [number, number, number], stepX: number, stepY: number, stepZ: number, obstacles: readonly CollisionObstacle[], radius: number = DEFAULT_OBSTACLE_PLAYER_RADIUS): { stepX: number; stepY: number; stepZ: number } — Resolve a flight step against world solids (XZ slide + Y clamp).
- `resolveFreeFlightIntent` (function): function resolveFreeFlightIntent(keys: MovementKeysState, analog: AnalogMoveIntent | null | undefined): FreeFlightIntent — Translate held keys + analog into a free-flight intent. Vertical is `space - (control|c)` so jump ascends and crouch descends — the same keys that walk/jump/crouch use, re-mapped for flight without re-binding.
+- `resolveFreeFlightIntentFromInput` (function): function resolveFreeFlightIntentFromInput(isDown: (action: string) => boolean, value: (action: string) => number, pointer: PointerAxisState | null, bindings?: FreeFlightBindings): FreeFlightIntent — Translate live input (held actions + analog + pointer) into a flight intent using per-profile axis bindings. Unlisted bindings fall back to the defaults above so existing games keep WASD/Space/Ctrl/Shift without declaring anything.
## @jgengine/core/movement/glideModel
@@ -786,6 +788,7 @@
## @jgengine/core/physics/flightDynamics
+- `AircraftAxis` (type): type AircraftAxis = "pitch" | "roll" | "yaw" | "throttle" | "collective" | "airbrake" | "afterburner" | "vectoring" — Axes an aircraft can bind — which actions drive each flight control.
- `AircraftDynamics` (interface): interface AircraftDynamics — Stateful six-degree-of-freedom aircraft simulation.
- `AircraftKind` (type): type AircraftKind = "fixedWing" | "rotorcraft" | "vtol" — Supported aerodynamic/propulsion families.
- `AircraftOptions` (interface): interface AircraftOptions — Spawn state and injectable world-field samplers for an aircraft instance.
@@ -1611,6 +1614,7 @@
- `AcquisitionRetention` (interface): interface AcquisitionRetention — Retention hysteresis that keeps an already-held target from flickering under churn.
- `AddBodyOptions` (type): type AddBodyOptions = BoxBodyOptions | SphereBodyOptions — ⚠ undocumented
- `Aim` (type): type Aim = | { origin: EntityPosition; direction: EntityPosition } | { yaw: number; pitch: number; spread?: number } — ⚠ undocumented
+- `AircraftAxis` (type): type AircraftAxis = "pitch" | "roll" | "yaw" | "throttle" | "collective" | "airbrake" | "afterburner" | "vectoring" — Axes an aircraft can bind — which actions drive each flight control.
- `AircraftDynamics` (interface): interface AircraftDynamics — Stateful six-degree-of-freedom aircraft simulation.
- `AircraftKind` (type): type AircraftKind = "fixedWing" | "rotorcraft" | "vtol" — Supported aerodynamic/propulsion families.
- `AircraftOptions` (interface): interface AircraftOptions — Spawn state and injectable world-field samplers for an aircraft instance.
@@ -1784,6 +1788,7 @@
- `ForceVolume` (class): class ForceVolume — A trigger region that pushes bodies passing through it — boost pads (`impulse` + `once`), conveyors (`velocity`), fans/wind (`accelerate`). Call `apply` each tick; `once` mode fires only on entry by tracking membership between ticks.
- `FormationSlotGenerator` (type): type FormationSlotGenerator = (count: number) => Vec2[] — Produces `count` slot offsets in the group's local frame — `[right, forward]` where `+forward` points where the group faces. Index order is the slot order; a generator must be pure (same `count` → same offsets) so placement stays deterministic. Sample generators below cover common shapes; games pass their own for anything else (crowds, convoys, sports positions) with no engine edit.
- `FramingConfig` (interface): interface FramingConfig — ⚠ undocumented
+- `FreeFlightBindings` (interface): interface FreeFlightBindings — Per-axis bindings for free-flight — which actions drive each flight axis. Positive is forward/right/up.
- `FreeFlightController` (interface): interface FreeFlightController — Stateful handle for any free-flight actor with snapshot/restore/retune.
- `FreeFlightIntent` (interface): interface FreeFlightIntent — Intent for one free-flight tick — forward/right from WASD/analog, vertical from jump/crouch.
- `FreeFlightMode` (type): type FreeFlightMode = "creative" | "spectator" | "noclip" | "hover" — Free-flight families — character-scale 6DOF movement, distinct from vehicle aerodynamics.
@@ -2388,6 +2393,7 @@
- `resolveFacingRotationY` (function): function resolveFacingRotationY(headingDegrees: number, space?: Pick): number — The Three.js Y-rotation that makes a model whose front is authored at `forwardDegrees` visually point `headingDegrees` (engine north = `0`). This is the catalog-owned replacement for per-game corrective yaw: a model authored facing south (`forwardDegrees: 180`) placed toward north resolves to `Math.PI`.
- `resolveFlightStep` (function): function resolveFlightStep(position: readonly [number, number, number], stepX: number, stepY: number, stepZ: number, obstacles: readonly CollisionObstacle[], radius: number = DEFAULT_OBSTACLE_PLAYER_RADIUS): { stepX: number; stepY: number; stepZ: number } — Resolve a flight step against world solids (XZ slide + Y clamp).
- `resolveFreeFlightIntent` (function): function resolveFreeFlightIntent(keys: MovementKeysState, analog: AnalogMoveIntent | null | undefined): FreeFlightIntent — Translate held keys + analog into a free-flight intent. Vertical is `space - (control|c)` so jump ascends and crouch descends — the same keys that walk/jump/crouch use, re-mapped for flight without re-binding.
+- `resolveFreeFlightIntentFromInput` (function): function resolveFreeFlightIntentFromInput(isDown: (action: string) => boolean, value: (action: string) => number, pointer: PointerAxisState | null, bindings?: FreeFlightBindings): FreeFlightIntent — Translate live input (held actions + analog + pointer) into a flight intent using per-profile axis bindings. Unlisted bindings fall back to the defaults above so existing games keep WASD/Space/Ctrl/Shift without declaring anything.
- `resolveGridInstances` (function): function resolveGridInstances(config: WorldGridConfig | GridWorldFeature): readonly GridInstanceTransform[] — ⚠ undocumented
- `resolveLocalAvoidance` (function): function resolveLocalAvoidance(agents: AvoidanceAgent[], options: LocalAvoidanceOptions = {}): number — Resolve overlaps in `agents` in place and return how many overlapping pairs remained on the final pass (`0` = fully separated). Uses a bounded uniform hash grid sized to the largest agent, so only nearby agents are ever compared. Deterministic: corrections are accumulated then applied per pass, independent of agent order. Pass `weights` to pin or differentially push agents.
- `resolvePlaceAsset` (function): function resolvePlaceAsset(input: ResolvePlaceAssetInput): PlaceAssetResult — Resolve a place-asset intent into a shared payload (editor + games, one verb).
diff --git a/packages/core/src/game/playableGame.ts b/packages/core/src/game/playableGame.ts
index 74651a5d4..de00637b1 100644
--- a/packages/core/src/game/playableGame.ts
+++ b/packages/core/src/game/playableGame.ts
@@ -8,7 +8,9 @@ import type { GameSettingsConfig } from "../settings/settingsModel";
import type { GameOrientation } from "../ui/orientation";
import type { HudPlatform, HudViewportConfig } from "../ui/hudScale";
import type { PositionedPrompt } from "../interaction/proximityPrompt";
+import type { ChaseCameraTuning } from "../runtime/cameraDirector";
import type { CatalogEntityRole, GameContext, GameContextContent } from "../runtime/gameContext";
+import type { FreeFlightBindings } from "../movement/freeFlight";
import type { ModelDims } from "../scene/assetCatalog";
import type { PartMotionParams, PartRole } from "./partAnimation";
import type { CollisionMeshData } from "../scene/collisionMesh";
@@ -335,6 +337,10 @@ export interface FlightConfig {
alignWithLook?: boolean;
/** Whether flight slides against world solids. Default true for creative/hover, false for spectator/noclip. */
collide?: boolean;
+ /** Axis bindings for this flight — which actions drive each axis. Unlisted axes keep the defaults above. */
+ bindings?: FreeFlightBindings;
+ /** Camera POV while this flight is active — chase tuning overlay applied on mount, cleared on dismount. */
+ camera?: ChaseCameraTuning | null;
/** Gate flight behind live game state — creative toggle, stamina check, etc. `false` stays walking. Default true when `flight` is present. */
canFly?: (ctx: GameContext) => boolean;
}
diff --git a/packages/core/src/movement/freeFlight.ts b/packages/core/src/movement/freeFlight.ts
index 9f6ca23a4..100af04ca 100644
--- a/packages/core/src/movement/freeFlight.ts
+++ b/packages/core/src/movement/freeFlight.ts
@@ -1,9 +1,24 @@
import { MOVEMENT_TUNING, type AnalogMoveIntent, type CollisionObstacle, type MovementKeysState } from "./movementModel";
import { DEFAULT_OBSTACLE_PLAYER_RADIUS } from "./movementModel";
+import { sampleAxisBindings, type AxisBinding } from "../input/axisInput";
+import type { PointerAxisState } from "../input/pointerAxis";
+import type { ChaseCameraTuning } from "../runtime/cameraDirector";
/** Free-flight families — character-scale 6DOF movement, distinct from vehicle aerodynamics. */
export type FreeFlightMode = "creative" | "spectator" | "noclip" | "hover";
+/** Per-axis bindings for free-flight — which actions drive each flight axis. Positive is forward/right/up. */
+export interface FreeFlightBindings {
+ /** Forward/back — default `moveForward` / `moveBack` (W/S). */
+ forward?: AxisBinding;
+ /** Strafe right/left — default `moveRight` / `moveLeft` (D/A). */
+ strafe?: AxisBinding;
+ /** Ascend/descend — default `jump` / `crouch` (Space / Ctrl+C). */
+ vertical?: AxisBinding;
+ /** Sprint — default `sprint` (Shift) held. */
+ sprint?: AxisBinding;
+}
+
/** Data-first tuning for one free-flight profile. */
export interface FreeFlightTuning {
mode: FreeFlightMode;
@@ -27,6 +42,10 @@ export interface FreeFlightTuning {
maxFallSpeed?: number;
/** Whether to slide against world solids. Default true for creative/hover, false for spectator/noclip. */
collide?: boolean;
+ /** Axis bindings for this flight profile — which actions drive each axis. Unlisted axes keep the defaults above. */
+ bindings?: FreeFlightBindings;
+ /** Camera POV while this flight is active — chase tuning overlay applied on mount, cleared on dismount. Null clears. */
+ camera?: ChaseCameraTuning | null;
}
/** Velocity state for a free-flight actor — serializable and ownable by the caller. */
@@ -69,6 +88,11 @@ export function createFreeFlightState(): FreeFlightState {
return { vx: 0, vy: 0, vz: 0 };
}
+const DEFAULT_FORWARD_BINDING: AxisBinding = { positive: ["moveForward"], negative: ["moveBack"] };
+const DEFAULT_STRAFE_BINDING: AxisBinding = { positive: ["moveRight"], negative: ["moveLeft"] };
+const DEFAULT_VERTICAL_BINDING: AxisBinding = { positive: ["jump"], negative: ["crouch", "control", "c"] };
+const DEFAULT_SPRINT_BINDING: AxisBinding = { positive: ["sprint"] };
+
/**
* Translate held keys + analog into a free-flight intent.
* Vertical is `space - (control|c)` so jump ascends and crouch descends — the same
@@ -90,6 +114,30 @@ export function resolveFreeFlightIntent(
return { forward, right, vertical, sprint, moving };
}
+/**
+ * Translate live input (held actions + analog + pointer) into a flight intent using
+ * per-profile axis bindings. Unlisted bindings fall back to the defaults above so
+ * existing games keep WASD/Space/Ctrl/Shift without declaring anything.
+ */
+export function resolveFreeFlightIntentFromInput(
+ isDown: (action: string) => boolean,
+ value: (action: string) => number,
+ pointer: PointerAxisState | null,
+ bindings?: FreeFlightBindings,
+): FreeFlightIntent {
+ const forwardBinding = bindings?.forward ?? DEFAULT_FORWARD_BINDING;
+ const strafeBinding = bindings?.strafe ?? DEFAULT_STRAFE_BINDING;
+ const verticalBinding = bindings?.vertical ?? DEFAULT_VERTICAL_BINDING;
+ const sprintBinding = bindings?.sprint ?? DEFAULT_SPRINT_BINDING;
+ const forward = sampleAxisBindings({ forward: forwardBinding }, isDown, pointer, undefined, value).forward;
+ const right = sampleAxisBindings({ strafe: strafeBinding }, isDown, pointer, undefined, value).strafe;
+ const vertical = sampleAxisBindings({ vertical: verticalBinding }, isDown, pointer, undefined, value).vertical;
+ const sprintVal = sampleAxisBindings({ sprint: sprintBinding }, isDown, pointer, { sprint: { min: 0, max: 1 } }, value).sprint;
+ const sprint = sprintVal > 0.5;
+ const moving = forward !== 0 || right !== 0 || vertical !== 0;
+ return { forward, right, vertical, sprint, moving };
+}
+
/**
* Advance one frame of free-flight kinematics.
* Horizontal uses yaw-relative strafe (A = left, D = right) — never roll/bank.
diff --git a/packages/core/src/movement/playerMovement.ts b/packages/core/src/movement/playerMovement.ts
index 662e57532..4c6a5b5f5 100644
--- a/packages/core/src/movement/playerMovement.ts
+++ b/packages/core/src/movement/playerMovement.ts
@@ -25,7 +25,7 @@ import {
advanceFreeFlight,
createFreeFlightState,
resolveFlightStep,
- resolveFreeFlightIntent,
+ resolveFreeFlightIntentFromInput,
type FreeFlightState,
type FreeFlightTuning,
} from "./freeFlight";
@@ -187,6 +187,8 @@ function flightTuningFor(
thrust: cfg.thrust,
alignWithLook: cfg.alignWithLook,
collide: cfg.collide,
+ bindings: cfg.bindings,
+ camera: cfg.camera ?? null,
};
}
return null;
@@ -256,9 +258,11 @@ export function stepPlayerMovement(
const flightTuning = flightTuningFor(tuning.movement, ctx);
if (flightTuning !== null) {
- keys.control = isDown("crouch") || isDown("sneak") || isDown("control") || isDown("c");
- keys.c = keys.control;
- const flightIntent = resolveFreeFlightIntent(keys, analogMove);
+ const value = (action: string): number => input.analog?.[action] ?? (isDown(action) ? 1 : 0);
+ let flightIntent = resolveFreeFlightIntentFromInput(isDown, value, input.pointer, flightTuning.bindings);
+ if (tuning.movement?.canSprint !== undefined && !tuning.movement.canSprint(ctx)) {
+ flightIntent = { ...flightIntent, sprint: false };
+ }
let flightState = state.flight;
if (flightState === null) {
flightState = createFreeFlightState();
@@ -280,10 +284,16 @@ export function stepPlayerMovement(
thrust: flightTuning.thrust,
alignWithLook: flightTuning.alignWithLook,
collide: flightTuning.collide,
+ bindings: flightTuning.bindings,
+ camera: flightTuning.camera,
};
if (normalizedFlightTuning.collide === undefined) {
normalizedFlightTuning.collide = normalizedFlightTuning.mode === "creative" || normalizedFlightTuning.mode === "hover";
}
+ if (normalizedFlightTuning.camera !== undefined) {
+ const cam = (ctx as unknown as { camera?: { setChaseTuning: (t: unknown) => void } }).camera;
+ cam?.setChaseTuning(normalizedFlightTuning.camera ?? null);
+ }
const step = advanceFreeFlight(flightState, flightIntent, state.heading, pitch, dt, normalizedFlightTuning);
let stepX = step.stepX;
let stepY = step.stepY;
@@ -338,6 +348,10 @@ export function stepPlayerMovement(
});
return;
}
+ if (flightTuning === null && state.flight !== null) {
+ const cam = (ctx as unknown as { camera?: { setChaseTuning: (t: unknown) => void } }).camera;
+ cam?.setChaseTuning(null);
+ }
if (tuning.collision?.voxel === true) {
let body = state.voxelBody;
diff --git a/packages/core/src/physics/flightDynamics.ts b/packages/core/src/physics/flightDynamics.ts
index bd15615b5..2f14a5e0f 100644
--- a/packages/core/src/physics/flightDynamics.ts
+++ b/packages/core/src/physics/flightDynamics.ts
@@ -1,3 +1,5 @@
+import type { AxisBinding } from "../input/axisInput";
+import type { ChaseCameraTuning } from "../runtime/cameraDirector";
import { uniformGravity, type GravityField } from "./gravityField";
/** Three-dimensional world-space vector used by the flight model. */
@@ -28,6 +30,9 @@ export interface FlightControlRates {
stability: number;
}
+/** Axes an aircraft can bind — which actions drive each flight control. */
+export type AircraftAxis = "pitch" | "roll" | "yaw" | "throttle" | "collective" | "airbrake" | "afterburner" | "vectoring";
+
/** Data-first physical tuning shared by all aircraft instances of one catalog type. */
export interface AircraftTuning {
kind: AircraftKind;
@@ -46,6 +51,10 @@ export interface AircraftTuning {
groundEffectHeight?: number;
vtolTransitionSpeed?: number;
groundClearance?: number;
+ /** Per-axis bindings for this aircraft — which actions drive each control. Unlisted axes keep the defaults (WASD + flightThrottleUp/Down etc.). */
+ bindings?: Partial>;
+ /** Camera POV while this aircraft is piloted — chase tuning overlay applied on mount, cleared on dismount. */
+ camera?: ChaseCameraTuning | null;
}
/** Spawn state and injectable world-field samplers for an aircraft instance. */
diff --git a/packages/core/src/physics/kinematicVehicle.ts b/packages/core/src/physics/kinematicVehicle.ts
index c214108ce..c4d7af796 100644
--- a/packages/core/src/physics/kinematicVehicle.ts
+++ b/packages/core/src/physics/kinematicVehicle.ts
@@ -1,4 +1,5 @@
-import type { AxisInput } from "../input/axisInput";
+import type { AxisBinding, AxisInput } from "../input/axisInput";
+import type { ChaseCameraTuning } from "../runtime/cameraDirector";
import { steerYaw } from "../movement/steering";
import { DEFAULT_GRIP_CURVE, sampleGripCurve, type GripCurve } from "./vehicleBody";
@@ -49,6 +50,10 @@ export interface KinematicVehicleTuning {
* Omit to preserve the direct arcade acceleration model exactly.
*/
chassis?: KinematicChassisTuning;
+ /** Per-axis bindings for this vehicle — which actions drive throttle/brake/steer/handbrake. Unlisted axes keep car defaults (W/S/A/D/Space). */
+ bindings?: Partial>;
+ /** Camera POV while this vehicle is piloted — chase tuning overlay applied on mount, cleared on dismount. */
+ camera?: ChaseCameraTuning | null;
}
/**
diff --git a/packages/core/src/world.ts b/packages/core/src/world.ts
index 75d9eeea9..64dd65091 100644
--- a/packages/core/src/world.ts
+++ b/packages/core/src/world.ts
@@ -164,6 +164,8 @@ export {
createFreeFlightState,
resolveFlightStep,
resolveFreeFlightIntent,
+ resolveFreeFlightIntentFromInput,
+ type FreeFlightBindings,
type FreeFlightController,
type FreeFlightIntent,
type FreeFlightMode,
@@ -243,6 +245,7 @@ export { createDamageModel } from "./physics/damageZones";
export { tickDrivableVehicle } from "./physics/drivableVehicle";
export {
createAircraftDynamics,
+ type AircraftAxis,
type AircraftDynamics,
type AircraftKind,
type AircraftOptions,
From fbe7c7cd61ab088b7536c597281dd16ce2d98052 Mon Sep 17 00:00:00 2001
From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com>
Date: Wed, 19 Aug 2026 22:57:06 -0400
Subject: [PATCH 3/3] flight-lab: proper test arena for flight
---
Games/flight-demo/src/editor.scene.json | 42 +++++++++++
Games/flight-demo/src/game.config.ts | 43 +++++++++++
Games/flight-demo/src/game/ui/GameUI.tsx | 95 ++++++++++++++++++++++++
Games/flight-demo/src/world.ts | 29 ++++++++
4 files changed, 209 insertions(+)
create mode 100644 Games/flight-demo/src/editor.scene.json
create mode 100644 Games/flight-demo/src/game.config.ts
create mode 100644 Games/flight-demo/src/game/ui/GameUI.tsx
create mode 100644 Games/flight-demo/src/world.ts
diff --git a/Games/flight-demo/src/editor.scene.json b/Games/flight-demo/src/editor.scene.json
new file mode 100644
index 000000000..ed82dca03
--- /dev/null
+++ b/Games/flight-demo/src/editor.scene.json
@@ -0,0 +1,42 @@
+{
+ "version": 1,
+ "markers": [
+ { "id": "player_spawn", "kind": "player_spawn", "position": { "x": 0, "y": 2, "z": 0 }, "label": "Player spawn — center of lab", "color": "#22d3ee" },
+ { "id": "runway_-100", "kind": "prop", "position": { "x": 0, "y": 0, "z": -100 }, "label": "Runway -100", "catalogId": "crate" },
+ { "id": "runway_-80", "kind": "prop", "position": { "x": 0, "y": 0, "z": -80 }, "label": "Runway -80", "catalogId": "crate" },
+ { "id": "runway_-60", "kind": "prop", "position": { "x": 0, "y": 0, "z": -60 }, "label": "Runway -60", "catalogId": "crate" },
+ { "id": "runway_-40", "kind": "prop", "position": { "x": 0, "y": 0, "z": -40 }, "label": "Runway -40", "catalogId": "crate" },
+ { "id": "runway_-20", "kind": "prop", "position": { "x": 0, "y": 0, "z": -20 }, "label": "Runway -20", "catalogId": "crate" },
+ { "id": "runway_20", "kind": "prop", "position": { "x": 0, "y": 0, "z": 20 }, "label": "Runway +20", "catalogId": "crate" },
+ { "id": "runway_40", "kind": "prop", "position": { "x": 0, "y": 0, "z": 40 }, "label": "Runway +40", "catalogId": "crate" },
+ { "id": "runway_60", "kind": "prop", "position": { "x": 0, "y": 0, "z": 60 }, "label": "Runway +60", "catalogId": "crate" },
+ { "id": "runway_80", "kind": "prop", "position": { "x": 0, "y": 0, "z": 80 }, "label": "Runway +80", "catalogId": "crate" },
+ { "id": "runway_100", "kind": "prop", "position": { "x": 0, "y": 0, "z": 100 }, "label": "Runway +100", "catalogId": "crate" },
+ { "id": "gate_12", "kind": "prop", "position": { "x": 0, "y": 12, "z": -40 }, "label": "Gate 12m", "catalogId": "crate" },
+ { "id": "gate_18", "kind": "prop", "position": { "x": 0, "y": 18, "z": 0 }, "label": "Gate 18m", "catalogId": "crate" },
+ { "id": "gate_24", "kind": "prop", "position": { "x": 0, "y": 24, "z": 40 }, "label": "Gate 24m", "catalogId": "crate" },
+ { "id": "pillar_10", "kind": "prop", "position": { "x": 40, "y": 10, "z": 0 }, "label": "10m", "catalogId": "crate" },
+ { "id": "pillar_20", "kind": "prop", "position": { "x": 40, "y": 20, "z": 0 }, "label": "20m", "catalogId": "crate" },
+ { "id": "pillar_30", "kind": "prop", "position": { "x": 40, "y": 30, "z": 0 }, "label": "30m", "catalogId": "crate" },
+ { "id": "pillar_50", "kind": "prop", "position": { "x": 40, "y": 50, "z": 0 }, "label": "50m", "catalogId": "crate" },
+ { "id": "pillar_80", "kind": "prop", "position": { "x": 40, "y": 80, "z": 0 }, "label": "80m", "catalogId": "crate" },
+ { "id": "pillar_100", "kind": "prop", "position": { "x": 40, "y": 100, "z": 0 }, "label": "100m", "catalogId": "crate" },
+ { "id": "pillar2_10", "kind": "prop", "position": { "x": -40, "y": 10, "z": 0 }, "label": "10m", "catalogId": "crate" },
+ { "id": "pillar2_20", "kind": "prop", "position": { "x": -40, "y": 20, "z": 0 }, "label": "20m", "catalogId": "crate" },
+ { "id": "pillar2_30", "kind": "prop", "position": { "x": -40, "y": 30, "z": 0 }, "label": "30m", "catalogId": "crate" },
+ { "id": "grid_-60_-60", "kind": "prop", "position": { "x": -60, "y": 0, "z": -60 }, "label": "Grid", "catalogId": "tree" },
+ { "id": "grid_0_-60", "kind": "prop", "position": { "x": 0, "y": 0, "z": -60 }, "label": "Grid", "catalogId": "tree" },
+ { "id": "grid_60_-60", "kind": "prop", "position": { "x": 60, "y": 0, "z": -60 }, "label": "Grid", "catalogId": "tree" },
+ { "id": "grid_-60_0", "kind": "prop", "position": { "x": -60, "y": 0, "z": 0 }, "label": "Grid", "catalogId": "tree" },
+ { "id": "grid_60_0", "kind": "prop", "position": { "x": 60, "y": 0, "z": 0 }, "label": "Grid", "catalogId": "tree" },
+ { "id": "grid_-60_60", "kind": "prop", "position": { "x": -60, "y": 0, "z": 60 }, "label": "Grid", "catalogId": "tree" },
+ { "id": "grid_0_60", "kind": "prop", "position": { "x": 0, "y": 0, "z": 60 }, "label": "Grid", "catalogId": "tree" },
+ { "id": "grid_60_60", "kind": "prop", "position": { "x": 60, "y": 0, "z": 60 }, "label": "Grid", "catalogId": "tree" },
+ { "id": "wall_150_1", "kind": "prop", "position": { "x": -10, "y": 0, "z": 150 }, "label": "Wall", "catalogId": "crate" },
+ { "id": "wall_150_2", "kind": "prop", "position": { "x": 0, "y": 0, "z": 150 }, "label": "Wall", "catalogId": "crate" },
+ { "id": "wall_150_3", "kind": "prop", "position": { "x": 10, "y": 0, "z": 150 }, "label": "Wall", "catalogId": "crate" },
+ { "id": "arch_low", "kind": "prop", "position": { "x": -15, "y": 8, "z": -20 }, "label": "Arch", "catalogId": "crate" },
+ { "id": "arch_high", "kind": "prop", "position": { "x": 15, "y": 15, "z": 20 }, "label": "Arch", "catalogId": "crate" },
+ { "id": "goal", "kind": "goal", "position": { "x": 0, "y": 0, "z": 140 }, "label": "Goal — end of runway", "color": "#22c55e", "meta": { "on": "enter", "action": "win", "message": "Runway complete!", "triggerRadius": 4 } }
+ ]
+}
diff --git a/Games/flight-demo/src/game.config.ts b/Games/flight-demo/src/game.config.ts
new file mode 100644
index 000000000..ff44f87d2
--- /dev/null
+++ b/Games/flight-demo/src/game.config.ts
@@ -0,0 +1,43 @@
+import { DEFAULT_WALK_CODES, defineGame } from "@jgengine/shell/gameKit";
+
+import { editorLayers } from "./editorLayers";
+import { assets } from "./game/assets";
+import { entityModels, objectModels } from "./game/models";
+import { GameUI } from "./game/ui/GameUI";
+import { onNewPlayer, systems } from "./loop";
+import { physics, world } from "./world";
+
+export const game = defineGame({
+ name: "Flight Lab",
+ assets,
+ world,
+ physics,
+ // Per-mode bindings + POV live on the thing they control.
+ // Creative: WASD strafe (A left, D right — never roll), Space/Ctrl vertical, Shift sprint.
+ // Each mount declares its own bindings + camera; here the player flight declares creative bindings + a slightly wider chase.
+ input: { ...DEFAULT_WALK_CODES, crouch: ["ControlLeft", "KeyC"], interact: ["KeyE"] },
+ movement: {
+ flight: {
+ mode: "creative",
+ speed: 10,
+ verticalSpeed: 10,
+ sprintMultiplier: 2.2,
+ // bindings are per-flight — e.g. Q/E for vertical instead of Space/Ctrl, or Arrow keys for a cockpit:
+ // bindings: { vertical: { positive: ["KeyQ"], negative: ["KeyE"] } },
+ // camera: { distance: 10, height: 3, fov: { base: 68, max: 88, speedForMax: 22 } },
+ camera: { distance: 12, height: 4 },
+ },
+ },
+ capture: {
+ probe: (ctx) => {
+ const p = ctx.scene.entity.get(ctx.player.userId)?.position ?? [0, 0, 0];
+ return { x: p[0], y: p[1], z: p[2] };
+ },
+ },
+ systems,
+ loop: { onNewPlayer },
+ GameUI,
+ editorLayers,
+ entityModels,
+ objectModels,
+});
diff --git a/Games/flight-demo/src/game/ui/GameUI.tsx b/Games/flight-demo/src/game/ui/GameUI.tsx
new file mode 100644
index 000000000..fc892cb8d
--- /dev/null
+++ b/Games/flight-demo/src/game/ui/GameUI.tsx
@@ -0,0 +1,95 @@
+import { useEffect, useRef, useState } from "react";
+import { HudCanvas, useHudLayout } from "@jgengine/react";
+import { useGameContext, useGameStore, useTicker } from "@jgengine/react";
+
+import { outcome } from "../../loop";
+
+function usePlayerTelemetry() {
+ const ctx = useGameContext();
+ const tick = useTicker(12);
+ const prev = useRef<{ pos: readonly [number, number, number]; time: number } | null>(null);
+ const [telemetry, setTelemetry] = useState({ speed: 0, vspeed: 0, altitude: 0, pos: [0, 0, 0] as const });
+
+ useEffect(() => {
+ const player = ctx.scene.entity.get(ctx.player.userId);
+ if (player === null) return;
+ const pos = player.position as readonly [number, number, number];
+ const ground = ctx.world.groundHeightAt(pos[0], pos[2]);
+ const altitude = pos[1] - ground;
+ const now = performance.now();
+ let speed = 0;
+ let vspeed = 0;
+ if (prev.current !== null) {
+ const dt = (now - prev.current.time) / 1000;
+ if (dt > 0.001) {
+ const dx = pos[0] - prev.current.pos[0];
+ const dy = pos[1] - prev.current.pos[1];
+ const dz = pos[2] - prev.current.pos[2];
+ speed = Math.hypot(dx, dy, dz) / dt;
+ vspeed = dy / dt;
+ }
+ }
+ prev.current = { pos, time: now };
+ setTelemetry({ speed, vspeed, altitude, pos: [pos[0], pos[1], pos[2]] });
+ // tick is dependency to re-run every 12hz
+ void tick;
+ }, [ctx, tick]);
+
+ return telemetry;
+}
+
+function FlightHud() {
+ const telemetry = usePlayerTelemetry();
+ const pos = telemetry.pos;
+ return (
+
+
+
FLIGHT LAB
+
+
W/A/S/D — yaw-relative strafe (A left +X, D right -X)
+
Space — ascend | Ctrl/C — descend | Shift — sprint ×2.2
+
Runway Z ±100 (crate every 20m) · Gates at 12/18/24m · Pillars X=±40 at 10/20/30/50/80/100m
+
Grid 30m · Wall at Z=150 · Terrain hills ±12m · Center flat 55m
+
+
+
+
+
POS
+
{pos[0].toFixed(1)}, {pos[1].toFixed(1)}, {pos[2].toFixed(1)}
+
ALT
+
{telemetry.altitude.toFixed(1)} m
+
SPEED
+
{telemetry.speed.toFixed(1)} m/s · {(telemetry.speed * 3.6).toFixed(0)} km/h
+
V-SPEED
+
{telemetry.vspeed.toFixed(1)} m/s
+
+
+
+ );
+}
+
+export function GameUI() {
+ const layout = useHudLayout({ storageKey: "flight-lab" });
+ const status = useGameStore((ctx) => {
+ // outcome is global, but also subscribe to ctx version to re-render on win
+ void ctx.version();
+ return outcome.get();
+ });
+
+ const tone = status.won
+ ? "bg-emerald-600/90 text-white"
+ : status.tone === "warn"
+ ? "bg-amber-600/90 text-white"
+ : "bg-slate-800/85 text-slate-100";
+ return (
+ <>
+
+
+ {status.message !== null ? (
+
+ {status.message}
+
+ ) : null}
+ >
+ );
+}
diff --git a/Games/flight-demo/src/world.ts b/Games/flight-demo/src/world.ts
new file mode 100644
index 000000000..ca9ef9cd1
--- /dev/null
+++ b/Games/flight-demo/src/world.ts
@@ -0,0 +1,29 @@
+import type { PhysicsConfig } from "@jgengine/core/game/defineGame";
+import { sky, terrain } from "@jgengine/core/world/features";
+import { environment } from "@jgengine/shell/gameKit";
+
+export const physics: PhysicsConfig = { gravity: -24 };
+
+export const world = environment({
+ terrain: terrain({
+ bounds: { w: 600, d: 600 },
+ height: 14,
+ seed: "flight-lab",
+ colors: { low: "#5d7a3a", high: "#c8e6a0", waterline: "#7a8a5a" },
+ detail: { detailScale: 3.2, macroScale: 64, strength: 0.85, rockSlopeStart: 0.45 },
+ segments: 128,
+ flatten: [
+ { center: [0, 0], radius: 55, falloff: 22 },
+ { center: [0, -120], radius: 22, falloff: 14 },
+ { center: [0, 120], radius: 22, falloff: 14 },
+ ],
+ }),
+ sky: sky({
+ preset: "day",
+ horizonColor: "#a6c6e0",
+ zenithColor: "#5f83b8",
+ sunIntensity: 1.35,
+ ambientIntensity: 1.05,
+ fog: { color: "#a6c6e0", near: 280, far: 900 },
+ }),
+});