Pilot Matter Docs Play

API reference

Pilot Matter is two halves that can be used without each other.

The Pilot API is the aircraft: the flight model, the controls, and the telemetry, flown against a scene, a terrain, and an aircraft model the host supplies. The Matter API is the world: an assembled environment as one detachable group, a height sampler for whatever is flying over it, and a contract any aircraft can satisfy, including one driven by a control API that has never heard of this one.

js/api/index.js is the one module a host page imports to get either.

examples/host.html is both halves on one page and is the shortest way to see what the rest of this document is describing: the Pilot API flying over ground the page generated itself, beside the Matter API carrying an aircraft the page built, over a world assembled out of four tiles. Serve the project - npm run serve - and open /examples/host.html.

Importing

There is no build step. The modules are ES modules, and Three.js is resolved through an import map on the host page:

<script type="importmap">
    {
        "imports": {
            "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
        }
    }
</script>
<script type="module" src="app.js"></script>
import { createPilot, createEnvironment } from './js/api/index.js';

Under a package manager the same entry points are published by name:

import { createPilot } from 'pilot-matter';
import { createEnvironment } from 'pilot-matter/matter';
import { validateAircraftContract } from 'pilot-matter/contract';
SpecifierIs
pilot-matterBoth halves, and everything below
pilot-matter/pilotThe Pilot API on its own
pilot-matter/matterThe Matter API on its own
pilot-matter/contractThe contracts, which load no renderer
pilot-matter/configThe configured start, and the fields it is set through
pilot-matter/environmentThe assembled environments
pilot-matter/elementsThe element registry and the field
pilot-matter/day-nightThe day cycle: the hour, the light at it, and the sun's arc
pilot-matter/waterThe wave, the sheen, and the surface animation

Three.js is reached for by the two halves that build something with it, and by the root entry point that re-exports them both. pilot-matter/contract, pilot-matter/config, pilot-matter/environment, pilot-matter/elements, pilot-matter/day-night, and pilot-matter/water load no renderer at all, which is the point of the split: a host can check its own options, describe its own world, light its own sky, move its own water, or check the shape of its telemetry on a server, in a worker, or in a test, with nothing rendered.

Stability

The API carries a version of its own, API_VERSION, which moves when the contracts move rather than when the simulator does. A release that changes how an aircraft flies does not change the API; a release that changes what an aircraft has to be does.

import { API_VERSION } from 'pilot-matter';

if (API_VERSION !== 1) console.warn('this host was written against API 1');

What is guaranteed to hold within a major API version:

What is not guaranteed, and is expected to move:

New optional fields may be added to an options object or to a returned object within a version. A host reading a field it knows is safe; a host asserting on the exact set of keys is not.

Pilot API

createPilot(options) -> pilot

Options

OptionTypeDefaultIs
sceneObject3Da fresh SceneWhat the aircraft is added to
cameraCameranoneA camera to place behind the aircraft
aircraftObject3D or loader resultthe bundled modelThe model to fly
anchor{x, y, z}{0, 0, 0}The point in that model the flight model moves
terrain{sampleHeight, bounds}flat groundThe world being flown over
keymapobjectDEFAULT_KEYMAPBindings, merged over the bundled ones
controlsbooleantruefalse takes the keyboard off entirely
wrapbooleantruefalse lets the aircraft fly past the bounds
runwaysarraythe terrain'sStrips a landing can be made on
cameraMode'CHASE', 'COCKPIT', 'ORBIT''CHASE'The view to open in
onResetfunctionnoneCalled whenever the flight resets
onLandingfunctionnoneCalled on an arrival that was a landing
flightobjectthe configured startOverrides for the start and the model

flight takes the start state and the flight model's own numbers, all in world units. Everything it does not name comes from the configured start, so a pilot created with no options at all flies exactly the way the bundled simulator does.

flight fieldIs
speed, throttle, altitude, verticalSpeed, pitch, yawThe condition the flight opens in, and resets to
x, zWhere over the world it opens, which defaults to the middle of it
groundedTrue for a start held on the ground, so the first arrival judged is the one flown back to it
sensitivityHow hard the controls bite, 0.1 to 4
minSpeed, cruiseSpeed, maxSpeedThe stall speed, the speed lift cancels gravity at, and the speed a full throttle asks for
gravityThe pull before lift is subtracted
clearanceHow far above the ground the aircraft sits when it meets it
impactSpeedThe sink rate that turns an arrival into a crash
runwayImpactSpeedThe same, over a strip, where prepared ground takes more

An anchor is the one thing an external model has to declare. The bundled aircraft is built nose-first along +Z with its control anchor at the origin; a model built around some other point is shifted so that point sits where the flight model puts the aircraft, rather than the host having to rebuild it.

What comes back

MemberIs
update(dt)Advances one frame and returns the telemetry
telemetry()The same reading without advancing
pose(){position, rotation, quaternion, attitude}, for an external camera
setTerrain(terrain)Flies a different world without rebuilding the aircraft
setRunways(runways)Names the strips a landing can be made on
setStart(flight)Changes what a reset resets to, without resetting
reset()Back to the start state
dispose()Takes the aircraft out of the scene, keyboard and all
boundsThe square being flown over, as the terrain declares it
runwaysThe strips currently being flown over
aircraft, camera, scene, object3D, input, keymapThe parts, for a host that wants them

input is the control state the flight model reads each frame. A host that created its pilot with controls: false writes it directly, from a gamepad, a touch surface, or a replay:

pilot.input.pitchUp = stick.y > 0.2;
pilot.input.throttleUp = trigger > 0.5;

Telemetry

Every field is in world units, and the object carries these fields and no others. It is a reading rather than a handle: mutating it changes nothing.

FieldIs
airspeedUnits per second
altitudeHeight above sea level
verticalSpeedUnits per second, positive on a climb
headingWhole degrees, 0 to 359, clockwise from north
throttleThe lever setting, 0 to 1
heightAboveTerrainHeight above the ground directly below
crashedTrue while the controls are locked by an impact
landedTrue while the aircraft is down on a strip after an arrival that was a landing
stalledTrue while airspeed is below the stall speed

TELEMETRY_FIELDS is the same list, for a host checking its own.

Worked example: the Pilot API over an external environment

A host page that has its own ground and wants the aircraft that flies over it. Here the ground is a plain sine field, which is enough to show what the terrain contract is: something that answers the height under a point, and says how far its ground goes.

import * as THREE from 'three';
import { createPilot, boundsFromSize } from 'pilot-matter';

const scene    = new THREE.Scene();
const camera   = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 12000);
const renderer = new THREE.WebGLRenderer({ antialias: true });

renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
scene.add(new THREE.DirectionalLight(0xffffff, 1.2), new THREE.AmbientLight(0x8899aa, 0.6));

// The host's own world: any function of x and z will do, and the bounds say
// where it stops being one.
const myTerrain = {
    sampleHeight: (x, z) => 60 * Math.sin(x / 900) * Math.cos(z / 900),
    bounds: boundsFromSize(10000)
};

// The host's own aircraft, nose along +Z, with the point the flight model
// should move declared as the anchor.
const myAircraft = new THREE.Mesh(
    new THREE.ConeGeometry(2, 10, 8).rotateX(Math.PI / 2),
    new THREE.MeshPhongMaterial({ color: 0xdddddd })
);

const pilot = createPilot({
    scene,
    camera,
    terrain: myTerrain,
    aircraft: myAircraft,
    anchor: { x: 0, y: 0, z: 0 },
    keymap: { yawLeft: ['KeyZ'], yawRight: ['KeyX'] },   // remap two, keep the rest
    flight: { sensitivity: 1.5 },
    onReset: () => console.log('back at the start')
});

const clock = new THREE.Clock();

renderer.setAnimationLoop(() => {
    const { airspeed, altitude, verticalSpeed, heading, throttle, stalled } =
        pilot.update(clock.getDelta());

    myHud.textContent =
        `${Math.round(airspeed * 2)} kt  ${Math.round(altitude * 3.28)} ft  ` +
        `${heading.toString().padStart(3, '0')}  ${Math.round(throttle * 100)}%` +
        (stalled ? '  STALL' : '');

    renderer.render(scene, camera);
});

The aircraft is flown with the bundled keys, less the two that were remapped. Nothing in the host page reaches into the flight model: the HUD is written from the telemetry, and the world is read through the sampler.

Matter API

createEnvironment(options) -> environment

Options

OptionTypeDefaultIs
environmentstring'highlands'The assembled environment to build
sizenumber16000The square the world covers
segmentsnumber200How finely that square is sampled
elementsarraythe preset'sElement placements, instead of the preset's
runwayboolean or objectfalseA landable strip in the world, or a configuration for one
tile{x, z}{0, 0}Which square of a larger assembly this world is
seednumberthe preset'sBuilds the same description as different ground
lightsbooleantruefalse to light the world yourself
fogbooleantruefalse to keep your own scene depth

What comes back

MemberIs
groupThe world as one Group any scene can add
sampleHeight(x, z)The ground under a point - the terrain contract itself
boundsThe square the world covers
runwaysThe strips cut into it, empty for a world built without one
fieldThe height and colour field behind the mesh
environmentThe preset being drawn
tile, originThe square of a larger world this is, and where its middle sits
setEnvironment(id)Regenerates the ground as a different world
join(...neighbours)Settles this world's edges against the worlds laid beside it
redraw()Draws the ground again from the field as it now stands
register(object, placement)Adds a caller-supplied mesh as an element of the world
attach(aircraft)Adopts an aircraft the environment did not build
applyDepth(scene, depth)The sky and the fog, applied to any scene
setDaylight(phase)Sets the hour of the day, and returns the light at it
updateWater(dt, light)Moves the water on by a frame
dispose()Releases the mesh, the geometry, and everything registered

register is placement by the generator rather than by the host: given a position the object is set down on the ground there, and given none it is dropped somewhere inside the bounds by the environment's own seeded stream. Anything registered is settled again when the world is regenerated, so an object placed on a hillside that is no longer there is set down on what is.

world.register(myWindsock, { x: 400, z: -900, offset: 2 });

attach checks the aircraft against the contract and throws with every problem at once rather than one per reload. What it returns is the aircraft and a groundHeight() for whatever is flying it.

applyDepth is the world's depth without the world: the sky it fades to and the fog that fades it, applied to any scene. An environment created with fog: false leaves the scene's own depth alone.

The aircraft contract

What an aircraft has to be before the Matter API will fly it:

RequirementWhy
position with numeric x, y, zThe world has to know where to sample under it
rotation with a numeric x, or a getQuaternion()The world has to know which way it points
anchor as a point, if given at allThe point in the model that is being moved

An aircraft that is also an Object3D is added to the group; one that is not is still flown, and stays wherever the host is drawing it.

Worked example: the Matter API under an external aircraft

A host page with an aircraft of its own, driven by a flight model of its own, that wants a world to fly it over.

import * as THREE from 'three';
import { createEnvironment, isAircraftContractSatisfied } from 'pilot-matter';

const scene    = new THREE.Scene();
const camera   = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 12000);
const renderer = new THREE.WebGLRenderer({ antialias: true });

renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

const world = createEnvironment({ environment: 'lakeside' });

scene.add(world.group);
world.applyDepth(scene);                 // the sky and the fog, without the ground

// The host's own aircraft, flown by the host's own model. All the world asks
// of it is a position and an orientation it can read.
const myAircraft = new THREE.Group();
myAircraft.position.set(0, 900, 0);
myAircraft.add(new THREE.Mesh(
    new THREE.BoxGeometry(12, 1, 3),
    new THREE.MeshLambertMaterial({ color: 0x4477aa })
));

console.assert(isAircraftContractSatisfied(myAircraft));

const flown = world.attach(myAircraft);  // throws with every gap in the contract

// A landmark of the host's own, set down on the ground rather than over it.
world.register(new THREE.Mesh(
    new THREE.CylinderGeometry(1, 1, 30, 6),
    new THREE.MeshLambertMaterial({ color: 0xdd4422 })
), { x: 1200, z: -400, offset: 15 });

const clock = new THREE.Clock();

renderer.setAnimationLoop(() => {
    myFlightModel.update(clock.getDelta(), flown.groundHeight());

    camera.position.copy(myAircraft.position).add(new THREE.Vector3(0, 12, -36));
    camera.lookAt(myAircraft.position);

    renderer.render(scene, camera);
});

// A different world under the same aircraft, regenerated in place.
document.querySelector('#world-picker').addEventListener('change', (event) => {
    world.setEnvironment(event.target.value);
});

One tile of a larger world

An environment covers a square, and tile says which square of a larger world that is, counted in squares off the middle of it. A tile is generated in the world's coordinates rather than in its own: its vertices stand where its place in the grid puts them, its sampler answers for that square and nothing outside it, and the noise its ground is shaped from runs on across the join instead of starting again at it.

import { createEnvironment } from 'pilot-matter';

const east = createEnvironment({ environment: 'highlands', size: 8000, tile: { x: -0.5, z: 0 } });
const west = createEnvironment({ environment: 'highlands', size: 8000, tile: { x:  0.5, z: 0 } });

scene.add(east.group, west.group);
east.join(west);                 // settles what their elements drew at the join

The compass runs the other way to the sign. The world's +Z is north and the card counts clockwise from it, which puts east on -X, so the tile at the lower x is the eastern one. bearingToDirection and directionToBearing are the one statement of that frame, and anything laying a world out by compass name should read it off them rather than write it out again.

There is no coordinate to convert. A tile's mesh is drawn where its field says it is, so a host flying across an assembly asks whichever tile the aircraft is over and passes it the world position it already has:

const ground = world.tileAt(position.x, position.z)?.sampleHeight(position.x, position.z) ?? 0;

join is what closes the seam. The ground a tile is shaped on already agrees with its neighbour's, because it is noise read off the world rather than off the tile; what does not is what the elements drew, since a peak that ended at one tile's edge knows nothing about the ground its neighbour laid against it. Every place two or more tiles put a vertex is settled on one height and one colour - the average of what they all had there - and each tile is walked back to what it was over the next few vertices in, so the join is seamless without the country behind it being flattened to make it so.

Give join every neighbour at once rather than one at a time. A vertex four tiles all reach has to be settled against all four of them to close.

middle.join(north, south, east, west);

The eight tiles around one are all of them: four meet it along a side and four only at a corner, and a tile settled against its sides alone leaves the four corners of its own ground unmatched against the tile diagonally across from it.

The simulator settles its own ground the same way, as each tile is laid rather than as a pass over the world afterwards, so a host assembling a world by hand and a flight over the built-in one meet the same seamless ground.

createTiledEnvironment does the whole of it: builds the grid, lights it once rather than once per square, and settles every join in one pass before anything is drawn.

import { createTiledEnvironment } from 'pilot-matter';

const world = createTiledEnvironment({
    environment: 'lakeside',
    tiles: 2,                    // 2 by 2, or { x, z } for a rectangle
    size: 8000,
    runway: true
});

scene.add(world.group);
world.applyDepth(scene);

const flown = world.attach(myAircraft);   // ground reported from whichever square it is over
MemberIs
groupThe whole assembly as one Group
tilesEach square, as the environment it is
acrossHow many squares the grid runs, as {x, z}
boundsThe square the whole assembly covers
seamsHow many shared vertices the joins were settled at
runwaysEvery strip cut into the assembly, whichever square it was cut into
tileAt(x, z)The square a place falls on, or null off the whole assembly
sampleHeight(x, z)The ground under a point, from whichever square it is over
attach(aircraft)Adopts an aircraft, reporting the ground under it across the assembly
register(object, placement)Adds a mesh as an element of the square it falls on
applyDepth(scene, depth)The sky and the fog, applied to any scene
setDaylight(phase)The hour of the day, over the whole assembly
updateWater(dt, light)Moves every square's water on together
dispose()Releases every square

An assembly answers the terrain contract the way a single environment does, so the Pilot API flies over the whole grid rather than over one square of it:

const pilot = createPilot({ scene, camera, terrain: world });

Each tile is a whole environment - its own elements, its own strips, its own water - laid out from its own place in the grid, so neighbouring squares are the same world without being the same ground. Because the ground is generated from a description rather than loaded, a tile costs a few tens of milliseconds and no download.

Contracts

Everything under js/api/contract.js is pure and imports no renderer.

ExportIs
API_VERSIONThe version of the contracts a host is holding
boundsFromSize(size, origin)The square a world of that size covers, about wherever its middle is
tileOrigin(tile, size)Where the middle of a tile sits in the world it is one square of
flatSampler(height)A height sampler for a host with no terrain of its own
isInsideBounds(bounds, x, z)Whether a point is over the ground
resolveTerrain(terrain)A terrain with its gaps filled in
resolvePilotOptions(options)The Pilot API's options, resolved
resolveEnvironmentOptions(options)The Matter API's options, resolved
validateAircraftContract(aircraft)Every problem with an aircraft, as an array
isAircraftContractSatisfied(aircraft)The same question as a yes or no
createTelemetry(values)A telemetry object of the stable shape
TELEMETRY_FIELDSThe fields every telemetry object carries
DEFAULT_KEYMAP, CONTROL_NAMES, RESET_KEYSThe bundled bindings

The resolvers are worth calling on their own: they are how a host finds out what its options actually came to before anything is built.

import { resolvePilotOptions, validateAircraftContract } from 'pilot-matter/contract';

const problems = validateAircraftContract(myAircraft);   // every gap at once, or []
if (problems.length) throw new TypeError(problems.join('; '));

const resolved = resolvePilotOptions({ flight: { sensitivity: 400 } });
resolved.flight.sensitivity;   // 4 - clamped onto the range, not refused

Configuration

The condition a flight opens in is data rather than a set of literals, and a host can read it, offer it, and hand back an edited one.

ExportIs
DEFAULT_CONFIGThe configured start, frozen
START_FIELDSEach field of the start, with what it may hold
START_FIELD_IDSThe field names, in the order they are offered
START_MODES, START_FLYING, START_TAKEOFFThe two conditions a flight can open in
CHOICE_FIELD, TOGGLE_FIELDWhat a field that is chosen or switched calls itself
startField(id)One field's declaration
isStartValue(id, value)Whether a field can be left holding a value
snapStartValue(id, value)The nearest value a field can hold to a worked-out one
startDefaults()A fresh copy of the configured start
resolveStart(values)A start with its gaps filled in, field by field
startsOnRunway(start), runwayForced(start), runwayWanted(start)What the start says about the strip
flightStart(start, world)The same start in the world units flight takes
takeoffStart(start, runway)A flight held at a threshold, ready to roll
createFlightState(start, world)The same start as a position and a rotation

A start is written the way a pilot reads it:

FieldReads inOpens on
startMode'flying' or 'takeoff''flying'
runwayWhether the generated world carries a striptrue
airspeedKnotsKnots80
altitudeFeetFeet above sea level1390
verticalSpeedFpmFeet per minute, signed1260
headingDegreesDegrees on the card, clockwise from north0
throttlePercentPercent of lever travel20
cameraMode'CHASE', 'COCKPIT', 'ORBIT''CHASE'

startMode decides which of the others mean anything. 'flying' opens the flight already up, in the condition the rest of the fields describe. 'takeoff' opens it stopped at a runway threshold with the engine idling, and takes nothing from the airborne fields but the camera - an aircraft held on the ground has no airspeed, no altitude, and no climb of its own to set. A takeoff also turns runway on and holds it there, because a start that asked to roll out of a world with no strip in it is not a start anything could honour.

flightStart is the one place those become world units. A start that opens on a strip is resolved against the strip it opens on, so the world it is being flown over is passed in beside it:

import { startDefaults, flightStart, createPilot, START_TAKEOFF } from 'pilot-matter';

const start = { ...startDefaults(), airspeedKnots: 140, altitudeFeet: 3000 };
const pilot = createPilot({ scene, flight: flightStart(start) });

// Or held at the threshold of the strip the world carries:
const held = { ...startDefaults(), startMode: START_TAKEOFF };
pilot.setStart(flightStart(held, { runway: world.runways[0] }));

The pitch is not part of a start, it is worked out from one: the attitude that holds the configured climb at the configured airspeed, so a flight opens in the climb it was configured for rather than settling out of it over the first second.

Each field declares its own range and step, or the list of settings it may take, which is what lets a host build a control for it without knowing anything about flight:

import { START_FIELDS, CHOICE_FIELD, TOGGLE_FIELD } from 'pilot-matter';

for (const field of START_FIELDS) {
    if (field.kind === CHOICE_FIELD) makeRadioGroup(field.label, field.values);
    else if (field.kind === TOGGLE_FIELD) makeCheckbox(field.label, field.default);
    else if (field.values) makeDropdown(field.label, field.values);
    else makeSlider(field.label, field.min, field.max, field.step, field.unit);
}

isStartValue refuses a reading between two steps rather than snapping it, because a value the configuration never offered is a value from somewhere else. snapStartValue is for the other case - a reading worked out rather than chosen, which wants the nearest setting the field actually has.

Controls without a keyboard

The flight model reads an input state of named controls that are either held or not, and the keyboard is only one thing that writes it. Two others are published, for a host whose page is opened on a phone or a tablet: a set of on-screen pads, and the device's own attitude.

isTouchOnly is the question put the way a browser can answer it. There is no way to ask whether a keyboard is attached, so what is asked instead is whether the machine takes touches and has no pointer that can hover over anything. A device with a mouse or a trackpad has keys beside it in every case that matters, and getting it wrong in the cautious direction leaves a machine flown with the keys it already had.

ExportIs
isTouchOnly(env)Whether this machine has to be flown from the glass
TOUCH_PADSEvery pad there is, with the control each one holds down
TOUCH_LEFT, TOUCH_RIGHTThe two clusters a pad can belong to
touchPads(tilting, pads)The pads a flight wants, given whether tilt has pitch and roll
applyTouchToInput(input, control, down)Holding one down, or letting it up
releaseTouchInput(input, pads)Letting go of everything, for controls being taken away

A pad is {id, control, label, side, cell, tilted}. control is one of CONTROL_NAMES, side is which cluster it belongs to, cell is where in that cluster's cross it sits, and tilted marks the four that tilt takes over.

Tilt is the same input state written from the orientation sensors. It is a default rather than a replacement: a machine that has keys is flown with them, which is why it is offered on what isTouchOnly answers yes to and nowhere else.

ExportIs
createTiltState(enabled)The state, wanted or not, with no reading yet
applyTiltReading(state, beta, gamma, screenAngle)A reading from the sensor, or null for one with no angles in it
tiltAxes(beta, gamma, screenAngle)A device reading as the aircraft's two axes
tiltFlying(state)Whether tilt is both wanted and actually reporting
levelTilt(state)However the device is being held now is level
tiltToInput(input, state, deadzone)The tilt written onto the controls
TILT_DEADZONE, TILT_CONTROLSHow far off level is nothing, and what tilt writes

beta and gamma are reported against the device rather than against the screen, so a phone turned on its side reports the pilot's pitch as roll. screenAngle is what turns one frame into the other, and a flight simulator on a phone is held sideways every time.

import { isTouchOnly, createTiltState, applyTiltReading, tiltToInput } from 'pilot-matter';

const tilt = createTiltState(isTouchOnly());

addEventListener('deviceorientation', (e) => {
    applyTiltReading(tilt, e.beta, e.gamma, screen.orientation?.angle ?? 0);
});

function frame(dt) {
    tiltToInput(pilot.input, tilt);
    pilot.update(dt);
}

tiltFlying is what tells a sensor that was asked for from one that is actually reporting: a browser can refuse it outright, and a device with no gyroscope answers the ask and then never says anything worth reading. A tilt that is not flying writes nothing at all rather than four released controls, so the keys and the pads keep whatever they were holding.

What a device with no sensor actually sends is one event with beta and gamma both null, which is the specification's way of saying it has nothing to report. applyTiltReading refuses it and returns null, leaving state.reading as it was, because a null read as a zero is a device held perfectly level - the one reading that would take the pitch and roll pads off the glass of a machine that has no keys to fall back on. One axis reported and the other not is still a reading, and the silent axis is taken as the neutral.

Runways and landings

A runway is an element of the world like a river or a forest: generated by algorithm rather than placed as an asset, cut into whichever ground the site search found flattest, and levelled with an apron that eases back into the country around it. A world is built without one unless one is asked for.

A strip can be placed instead of searched for: a runway whose configuration carries at, [x, z] in the square's own frame as a footprint's, is laid there, along the low end of its heading span and at the low ends of its length and width, levelled to the ground under it on each square and lifted clear of any water there - the sea's or a drawn body's. band and separation are the search's rules and are not read; it takes nothing from the world's seeded stream. Any strip with centreline: true carries a dashed line down its middle.

import { createEnvironment, createPilot, flightStart, startDefaults, START_TAKEOFF } from 'pilot-matter';

const world = createEnvironment({ environment: 'highlands', runway: true });
const strip = world.runways[0];

const pilot = createPilot({
    scene, terrain: world, camera,
    flight: flightStart({ ...startDefaults(), startMode: START_TAKEOFF }, { runway: strip }),
    onLanding: (runway) => console.log('down on', runway.heading)
});

A strip is a plain description, and everything that reads one is published:

Strip fieldIs
x, zThe middle of the pavement
headingThe bearing it runs on, in degrees clockwise from north
alongX, alongZThe same bearing as a unit vector
length, widthThe paved rectangle
elevationThe height it was levelled to
placedTrue for a strip placed at its at rather than searched for
ExportIs
runwayDirection(heading)A bearing as the vector a strip runs along
runwayPoint(runway, along, across)A place on a strip, as a place in the world
runwayOffsets(runway, x, z)The same reading the other way round
isOnRunway(runway, x, z, margin)Whether a place is over the pavement
runwayThresholds(runway)Both ends, each with the bearing a takeoff from it runs on
nearestRunway(runways, x, z)The strip nearest a place, or null
flightStrip(runways)The strip a takeoff opens on: the last one placed, or the first - the one searched for - where none was

A strip changes what an arrival on the ground means rather than how hard one is allowed to be. Off a runway the rule is the one it has always been: too fast a descent breaks the aircraft, and anything gentler is flown out of. On one, an arrival inside the landing limits and flown in the attitude a landing is flown in is a landing; a firmer arrival still rolls out, because prepared ground takes more than a hillside does; and only past the runway's own threshold is it a crash.

ExportIs
FLYING, LANDED, CRASHEDWhat an arrival amounted to
GROUND_OUTCOMESThe three of them, for a host checking its own
touchdownOutcome(contact, limits)Which one an arrival was
withinLandingAttitude(contact, limits)Whether it was being held the way a landing is flown
headingOffsetTo(heading, runwayHeading)How far off the strip the nose is, to whichever end is nearer
LANDING_LIMITSThe thresholds all of the above default to
GROUND_CLEARANCE, CRASH_IMPACT_SPEED, RUNWAY_IMPACT_SPEEDThe tuning behind them

contact is {verticalSpeed, onRunway, x, z, heading, bank, pitch, headingOffset}, in world units and radians. The rules read the first and the last four of those; the place and the bearing ride along on the same reading because a landing is scored on where down the strip it happened as much as on how. onLanding is handed the strip and that contact, in that order. The telemetry's landed is the same yes or no for a host that would rather not work it out.

A landing is a yes or a no, and then it is four measurements. scoreLanding takes the reading onLanding was handed and says what the approach came to:

ExportIs
scoreLanding(runway, touchdown)The whole reading of a landing, or null off a strip
touchdownPoint(runway, touchdown)How far down the strip it touched, from the threshold flown over
landedForward(runway, heading)Which of the two thresholds that was
LANDING_PARTSThe four parts a landing is marked on
TOUCHDOWN_ZONE, TOUCHDOWN_REACHWhere down the strip to aim, and how far off is nothing
PERFECT_SCOREWhat the four marks come to when every one of them is flown

What comes back is {down, across, sink, heading, marks, score}: how far down the strip from the threshold, how far off the centreline, the rate it came down at, how far off the strip the nose was, the mark each of those four came to from 0 to 1, and the score the four make together. down is negative for a touchdown short of the threshold, which is an undershoot and reads as one.

import { createPilot, scoreLanding, PERFECT_SCORE } from 'pilot-matter';

const pilot = createPilot({
    scene, terrain: world, camera,
    onLanding: (runway, contact) => {
        const landing = scoreLanding(runway, contact);
        say(`${landing.score} out of ${PERFECT_SCORE}`);
    }
});

Game modes

The bundled game is played in modes, and the rules behind them are pure: the stages, the run state, the course a set of loops is laid out as, the test for whether a gate was flown through, the budget a route is flown against, and the marker a search is flown to. A host can play them against its own renderer, or read them as a worked example of a game built on the two APIs.

ExportIs
GAME_MODES, GAME_MODE_IDSThe modes there are
RUNWAY_LANDING, LOOP_COURSE, DEAD_STICK, CARGO_RUN, SEARCH_RESCUE, TRAFFIC_PATTERN, CANYON_RUN, PHOTO_SURVEYThe eight of them by name
LAND_OBJECTIVE, LOOP_OBJECTIVE, CARGO_OBJECTIVE, SEARCH_OBJECTIVE, PATTERN_OBJECTIVE, CORRIDOR_OBJECTIVE, SURVEY_OBJECTIVEWhat a mode is asking for
getGameMode(id), isGameModeId(id)Looking one up
createRunState(modeId), startRun(state, id, stageIndex), endRun(state)A run, started and stopped. The stage is optional and clamped into the ones the mode has
openingRun(query), OPENING_MODE_KEY, OPENING_STAGE_KEYThe run a ?mode=&stage= query asks for, or why it cannot be given
runningMode(state), currentStage(state), advanceStage(state), restartStage(state)Where it is up to
recordLanding(state, runway), recordGate(state, index), recordRescue(state, marker, report), recordCrash(state)Telling it what happened
recordPatternLeg(state, index), recordSection(state, index), recordSectionFault(state, index), recordPhoto(state, landmarks, report)The same, for the three that count something other than a place reached
stageProgress(state), isStageComplete(state), nextGate(state), nextStrip(state)How far through it is
nextLeg(state), nextSection(state), nextLandmark(state)The same, for a circuit, a corridor and a list
progressNoun(state), stripIndex(runway)What it counts in, and which strip a landing was made on
runObjective(state), runStatus(state)What to write on the screen
runBrief(state, world)The same, for the two modes whose objective moves inside a stage
faultNotice(state, fault), shotNotice(landmark, fault), legNotice(flown)What just went wrong, or what a leg came to, as a line
circuitNotice(flown, score)The same for the leg that ends a circuit, which reports the leg and the whole of it
runPointer(state, world, position, heading)Where the thing it is waiting on lies
gatePointer(...), stripPointer(...), searchBriefing(state)Three of the things runPointer dispatches to
chartCourse(state, world), chartNext(state)The same answer as marks on a chart, and which one is next
stageWorld(state)The world the stage is flown over
stageStart(state, world)Where in it the flight opens. world carries runway, rings, corridor and sampleHeight, each read by the objectives that need it
buildCourse(stage, options)The loops a course stage is flown through
stagePattern(state, runway), stageReaches(state)The circuit a pattern stage is flown round, and how closely its legs have to be held
stageSections(state), stageLandmarks(state)The cuts a run is divided into, and the landmarks a survey lists
gatePassed(ring, from, to), gateMissed(ring, from, to), gateOffset(ring, point)Whether a step went through one, or past it
gateCrossing(ring, from, to)The one crossing both of those are read off
gateAspect(ring), gateAxes(ring)The shape of a gate opening, and the two directions across it
flyStep(state, course, from, to)Both of those put to the gate the course is waiting on
flyPattern(state, circuit, from, to), flyCorridor(state, corridor, from, to)The same for the turn a circuit is on and the cut a run is up to
ENGINE_LIVE, ENGINE_DEAD, runEngine(state), engineLive(state)Whether the run still has an engine
stageBudget(state), burnFuel(state, throttle, dt), fuelRemaining(state)The budget a route spends on its throttle
stageStrips(state)How many strips a route stops at
stageMarker(state), RESCUE_RADIUS, RESCUE_STOP_SPEEDThe marker a search is flown to, and what counts as beside it
approachThreshold(runway)The end of a strip a landing stage is flown onto
approachGuidance(state, runway)What is drawn on the ground to help the pilot find it
CENTRELINE_REACH, CENTRELINE_MARKSHow far the lead-in runs back, and in how many marks

The three mechanics the newer modes are built on are published on their own as well, for a host wanting one of them without the run state around it. Each is a module of its own - pilot-matter/pattern, pilot-matter/corridor, pilot-matter/survey - and all three are re-exported from the main entry.

ExportIs
buildPattern(stage, runway)The circuit a stage is flown round, laid off a strip
PATTERN_LEGS, TAKEOFF_LEG, FINAL_LEG, LEG_HEIGHTSThe five legs by name, the two that are special, and the share of the circuit height each ends at
LEFT_HAND, RIGHT_HANDWhich side of the strip the circuit lies on
legOffset(leg, point), legCrossed(leg, from, to)How far past the turn a place is, and whether a step crossed it
legProgress(leg, point), legAltitude(leg, point)How far along a leg a place is, and the height wanted there
createPatternState(legs), resetPattern(state, legs)The reading of how each leg was held, kept beside the run
samplePattern(state, leg, report, dt)One frame of that reading
scoreLeg(state, leg, reaches), completeLeg(state, leg, reaches)What a leg came to, and closing it at the turn
patternScore(state), flownLegs(state)What the circuit came to, and the legs behind it
buildCorridor(stage, options)The cuts a canyon stage is flown through
sectionOffset(section, point)How far past a cut a place lies, along the run
corridorCrossing(section, from, to)Where a step crossed one, and what it was doing there
sectionPassed(...), sectionMissed(...), missedBy(crossing)Through it, past it, and which limit was broken
CORRIDOR_REACH, MIN_HEADROOM, HEADROOM_SAMPLESHow much of the world a corridor is laid inside, the least air a cut may have in it, and how many points across a cut the ground under it is read at
buildSurvey(stage)The landmarks a survey stage lists, placed
shotFor(landmark, report), shotFault(shot, landmark)The reading a shutter takes, and the first thing wrong with it
landmarkBrief(landmark)The line to write for the landmark being flown to
rangeBand(landmark), windowHeight(landmark)The two bands a renderer draws, in the units it draws in

A gate is tested against the step the aircraft flew rather than against where it ended up, because a hoop is thinner than the distance covered in a frame and a point test would fly straight through one without noticing. flyStep is that test and the recording it leads to in one call, so a host drives a course with the two ends of a step and reads back what became of it:

import { createRunState, LOOP_COURSE, buildCourse, currentStage, flyStep } from 'pilot-matter';

const run = createRunState(LOOP_COURSE);
const rings = buildCourse(currentStage(run), { seed: 1, sampleHeight: world.sampleHeight });

let last = pilot.pose().position;
function frame(dt) {
    pilot.update(dt);
    const now = pilot.pose().position;

    const step = flyStep(run, rings, last, now);
    if (step.finished) endOfStage(run);
    else if (step.missed) say('come round again');

    last = now;
}

flyStep returns the gate the step was put to, whether it went passed that gate or missed it, and whether passing it finished the stage. Nothing but the gate a course is waiting on can be flown or missed - a course is an order, and a hoop further down it is not the pilot's business yet - so a host that holds no course state of its own still gets the whole of what a step did.

A gate is {index, x, y, z, radius, aspect, bank, dirX, dirZ}. dirX and dirZ are the way the course runs through it; radius is its half width; aspect is how tall it is against that width; and bank is how far over it was laid, as a turn about the way the course runs. Those last two are what make a gate an attitude to match rather than a place to be: a circle turned about its own axis is the same circle, so a gate that has to be flown at the angle it was laid at has to be narrower one way across than the other. gateAxes gives the span and the rise a crossing is measured on, which is what a host draws the hoop about so that the opening it draws and the opening gateCrossing tests are one opening.

A landing stage may be given help finding its strip, and approachGuidance is what to draw: an extended centreline running back down the approach as a list of marks, and a bar across the threshold, with the heading, width and elevation of the strip they belong to. Which of the two a stage is given is declared on the stage, and by the last of them there is neither. It answers null where there is nothing to draw, which is what leaves the ground clear.

Flying without an engine

runEngine answers whether the run still has one. Two things take it away and both come back through that one call, because the aircraft flies the same way either way: a mode declared without one, and a route that has spent the last of its budget. A host reads it every frame and holds the lever closed when it comes back ENGINE_DEAD; the airspeed then follows the attitude rather than the throttle, which is what makes reaching anywhere a glide to be planned rather than a descent to be flown.

import { createRunState, DEAD_STICK, engineLive } from 'pilot-matter';

const run = createRunState(DEAD_STICK);

function frame(dt) {
    pilot.setEngine(engineLive(run));
    pilot.update(dt);
}

A route, and the budget it is flown on

A route stops at several strips in the order they were laid, so a landing is reported with the strip it was made on. Only the strip the route is up to counts, which is the same rule a course of loops is flown under: nextStrip says which one that is, and recordLanding answers false for any other. It says which one is next, though, and a landing it accepts moves the route on before it returns, so the strip a landing was made on is read off the strip itself with stripIndex rather than off the run afterwards. stageWorld gives a route stage an elements list describing the strips rather than asking for one and being given it, because a world lays a single strip on its own.

burnFuel is the budget, spent a frame at a time. The burn is the lever setting itself, so a wide open throttle costs a second of budget per second and a closed one costs nothing at all - which is what makes the route worth planning rather than merely flying. fuelRemaining is what is left as a share of the whole, for writing on an instrument, and answers null for a run with no budget to read.

import { createRunState, CARGO_RUN, burnFuel, recordLanding, stripIndex } from 'pilot-matter';

const run = createRunState(CARGO_RUN);

function frame(dt) {
    burnFuel(run, pilot.telemetry().throttle, dt);
    pilot.update(dt);
}

function onLanding(runway) {
    if (recordLanding(run, runway)) say(`down at strip ${stripIndex(runway)}`);
}

A search, and the marker it is flown to

stageMarker is where the marker stands, as {x, z, bearing, distance, radius}. The bearing and the distance are measured from where the stage opens rather than from the aircraft, because they are the whole of the briefing the pilot is given: searchBriefing is that briefing as a line to write, and it stays where it was however far the flight has got, which a needle following the marker round would not.

recordRescue is the set-down. It is handed where the aircraft has come to rest and answers true once: on the ground, stopped, inside the circle, and not a wreck.

import { createRunState, SEARCH_RESCUE, stageMarker, recordRescue } from 'pilot-matter';

const run = createRunState(SEARCH_RESCUE);
const marker = stageMarker(run);

function frame(dt) {
    pilot.update(dt);

    const { position, airspeed } = pilot.telemetry();
    const found = recordRescue(run, marker, {
        x: position.x, z: position.z, speed: airspeed, airborne: pilot.isAirborne()
    });

    if (found) say('found');
}

A circuit, and the legs it is held on

A traffic pattern is the one mode that is judged on the flying rather than on the arrival. buildPattern lays the circuit off a strip - five legs, each with a heading to track, a height to be at by the end of it, and the turn it ends at - and the reading of how well each was held is kept beside the run rather than inside it, the way a rollout is.

import {
    createRunState, TRAFFIC_PATTERN, stagePattern, flyPattern, nextLeg
} from 'pilot-matter';
import {
    createPatternState, samplePattern, completeLeg, patternScore
} from 'pilot-matter/pattern';

const run = createRunState(TRAFFIC_PATTERN);
const circuit = stagePattern(run, runway);
const held = createPatternState();

let last = pilot.pose().position;
function frame(dt) {
    pilot.update(dt);
    const now = pilot.pose().position;
    const leg = circuit[nextLeg(run)];

    // Read against the leg being flown, then close it at the turn.
    if (leg) samplePattern(held, leg, { ...now, altitudeFeet, headingDegrees }, dt);
    if (flyPattern(run, circuit, last, now).turned) say(completeLeg(held, leg));

    last = now;
}

A leg is {index, label, fromX, fromZ, fromFeet, x, z, onStrip, altitudeFeet, heading, dirX, dirZ, run}. fromX/fromZ are where it begins and x/z where it ends, which is also the turn onto the next one and the mark a chart draws. fromFeet and altitudeFeet are the heights at those two ends: the target between them is the ramp from one to the other, so a leg whose ends match is a height to hold and a leg whose ends differ is a climb or a descent to fly at a steady rate. One reading serves all five legs without any of them needing a rule of its own.

Both errors are accumulated against time rather than against frames, so what a leg comes to does not depend on how fast the host is drawing. scoreLeg and completeLeg give {index, label, seconds, altitude, heading, marks, score}, where the two errors are means over the time the leg took and the score is their marks averaged out of a hundred. completeLeg closes a leg once: what happens after the turn belongs to the next leg.

The last leg is the one a turn cannot close. Final ends at the threshold, and an aircraft crosses that threshold on every go-around it flies, so recordLanding finishes the circuit and recordPatternLeg refuses the final leg. A landing reported anywhere earlier in the pattern counts for nothing.

So the landing is what closes it. The approach is read against its height and heading every frame it is flown, the same as the other four legs, and a host calls completeLeg on the final leg where it takes the arrival - that is what turns the reading into a mark rather than a tally nothing reports. Because the reading accumulates until the stage is reset, a go-around is part of the approach that was flown rather than a second attempt at it; resetPattern is what starts the approach over, and a crash is where a host calls it.

patternScore is the mean of the legs closed so far, which once the landing is in is the mark for the whole circuit. circuitNotice(flown, score) writes the leg and the circuit as one line for the moment both are finally known.

Each leg also says whether the turn it is named by stands on the strip, as onStrip. Two of the five do - the takeoff ends at the departure threshold and the final back at the approach one - and the other three turn in open air. It is there for whatever draws the circuit: a turn in open air is a place with nothing at it and wants a mark, while a threshold is the end of a drawn strip the approach guidance already picks out.

A circuit is also the one run that opens on the ground: stageStart hands back startMode: 'takeoff' and a null position, leaving the threshold to the takeoff start rather than naming it a second time.

A corridor, and the walls it is flown between

A canyon run is a line through the world with a wall either side of it and a ceiling over it, cut into sections. It is laid rather than found - a generator puts its canyons wherever its noise does, so a run that went looking for one would be a run whose difficulty was whatever the seed handed it.

buildCorridor walks the line across the ground and holds the ceiling a declared height over whatever is underneath, so a stage that brings the ceiling down brings it down everywhere rather than only where the ground was already high. A section is {index, x, z, floor, crest, ceiling, y, halfWidth, dirX, dirZ}, with y the ceiling again under the name a chart reads a mark's height by.

floor is the ground on the centre line and crest is the highest ground the cut is open over, read across the whole span its walls stand either side of. The lid is held at whichever is higher of the stage's own headroom over the floor and MIN_HEADROOM over the crest, so the room a stage asks for is room the aircraft has at the posts as well as in the middle - a cut whose centre falls in a gully has far less air at its edges than at its centre, and a lid measured only on the centre line is a lid the walls reach up into.

stageStart opens a run back down the line of the first cut, at a height held inside a band rather than over a single floor. It wants half the air at that cut over whichever is higher of the cut's floor and the ground at the opening, because the opening is a whole spacing back and the ground between the two is not flat, and it is held no nearer the first lid than half of MIN_HEADROOM. Both bounds are needed: the ground at the opening raises the height wanted without moving the lid, so ground standing well over the cut's floor would otherwise open the run above the ceiling it is flown under. The lid bounds the cut rather than the opening a spacing behind it, so ground at the opening standing inside that clearance leaves the band no width at all, and there the ground takes it: a run opened over its first lid has the whole spacing in which to come down, while a run opened below the ground is a wreck on the frame the pilot is handed. Hand it no sampleHeight and there is no second reading to take, so the cut's own floor stands in for the ground.

import { createRunState, CANYON_RUN, currentStage, flyCorridor } from 'pilot-matter';
import { buildCorridor } from 'pilot-matter/corridor';

const run = createRunState(CANYON_RUN);
const corridor = buildCorridor(currentStage(run), { seed: 1, sampleHeight: world.sampleHeight });

function frame(dt) {
    pilot.update(dt);
    const now = pilot.pose().position;

    const step = flyCorridor(run, corridor, last, now);
    if (step.finished) endOfStage(run);
    else if (step.faulted) say(step.fault);   // OVER THE TOP, WIDE, or OVER AND WIDE

    last = now;
}

flyCorridor is the same shape as flyStep, and tested the same way: a section is thinner than the distance covered in a frame, so the step is put to the plane rather than the place. It is the gate test asked of a rectangle open at the bottom rather than of an ellipse - open at the bottom because the floor is the ground, and the ground is already a thing that ends a flight. Climbing over the lid is the one way the mode could be cheated and the only thing it guards against.

A fault moves nothing. The section stays the one the run is up to, which is what lets it be flown again, and missedBy names which of the two limits was broken - coming back down and coming back in are different corrections.

A survey, and the window a landmark is caught through

A photo survey is met by being somewhere *and saying so*. buildSurvey places the landmarks a stage lists, each by a bearing and a distance from where the stage opens, and each carrying the window it has to be caught from:

import { createRunState, PHOTO_SURVEY, stageLandmarks, recordPhoto } from 'pilot-matter';

const run = createRunState(PHOTO_SURVEY);
const landmarks = stageLandmarks(run);

function onShutter() {
    const shot = recordPhoto(run, landmarks, {
        x: position.x, z: position.z, altitudeFeet, headingDegrees
    });

    if (shot.finished) endOfStage(run);
    else if (!shot.caught) say(shot.fault);   // TOO FAR OUT, TOO HIGH, WRONG SIDE, ...
}

A landmark is {index, name, x, z, bearing, distance, height, window}, and the window is a height band in feet, a range band in world units, and a heading with a reach either side of it. All three have to hold at once: a landmark photographed from directly overhead at four thousand feet is a photograph of a roof, and the brief asked for an elevation.

The shutter belongs to the host. recordPhoto is told that a picture was taken and answers whether it counted - a mode that swallowed a picture for being of the wrong thing would be a mode that broke the camera. A shot with no landmark outstanding is a photograph and nothing else.

shotFor is the reading on its own, as {heightOk, rangeOk, headingOk, inside, range, bearing}, and shotFault turns a reading that missed into the first thing wrong with it rather than all of them. A reading that cannot be taken - a report with no place in it - is outside every part of the window rather than inside it by default: a missing place measures NaN, and NaN fails every comparison including the ones that would have rejected it.

landmarkBrief is the line to write, and it carries the name, the height and the heading but not the range. The range is the one of the three readings that is a place, and a place can be drawn: rangeBand hands the near and far edges over as circles to lay on the ground, which is where the bundled game reads them from.

What a chart draws for a run

runPointer is one line of text, and a host with a map instead of a status row wants the same answer as places. chartCourse gives it: the marks the run is flying to, in the order it flies them, each an {x, z} a chart can plot. A course is its gates and a route is the strips it lands at, and both carry the index the run counts them by; a search is the one marker it is looking for, which the run counts by nothing and which carries no index of its own, so a chart reads that one by its place in the list. A landing and a free flight are an empty array, having no objective standing off somewhere to be drawn. chartNext says which of those marks the run is waiting on, or -1 for none, so the mark a chart lights is the one the pointer row is naming.

The world it is handed is the same shape runPointer takes - {course, runways, circuit, corridor, landmarks}, of which a run uses one - because the two answer one question and should not be able to disagree about it. A circuit is drawn as its turns, a canyon run as its cuts, and a survey as its landmarks.

import { createRunState, CARGO_RUN, chartCourse, chartNext } from 'pilot-matter';

const run = createRunState(CARGO_RUN);
const marks = chartCourse(run, { runways });   // the strips, in landing order
const lit = chartNext(run);                    // 0 - the leg it is flying now

Worlds and elements

ExportIs
ENVIRONMENTSThe five assembled environments
MODE_ENVIRONMENTSThe thin worlds the game modes are played over
DEFAULT_ENVIRONMENT_IDThe one a fresh install opens on
getEnvironment(id), environmentIds(), isEnvironmentId(id)Looking one up
environmentElements(environment, runway)A description's placements, with a strip added if asked
buildEnvironment(environment, options)The field a description becomes
ELEMENTS, ELEMENT_ORDERThe element registry, and the order it applies in
getElement(id), isElementId(id)Looking one up
resolveConfig(element, overrides)An element's configuration, clamped to its ranges
createField(options)An empty height and colour field
sampleHeight(field, x, z)The ground under a point of a field
fieldBounds(field)The square a field covers, in the world rather than in its own coordinates
tileSeed(seed, x, z)The seed one square of an assembly is laid out from; the middle square is the seed itself, and no two places in a grid share one
matchEdges(fields, options)Settles an assembly wherever its fields meet
matchWater(surfaces)Settles an assembly's water wherever its tiles share a wet vertex
SEAM_BLENDHow far a join is eased back into the ground, in vertices
waterSurface(field)The water a world settled at, as the vertices at or under their own water line

The five worlds a host can ask for by name:

IdIs
'highlands'fBm ground under scattered peaks, with snow above 300
'river-basin'A meandering river the width of the world, through low forested country
'canyon-country'A branching canyon system cut into a high plateau
'dune-sea'Wind-blown dunes and rock outcrops, cut by one desert river
'lakeside'A town on the shore of a wide lake, under forested hills

Five more are built for the game modes rather than to be chosen between. isEnvironmentId answers no for all of them, so a stored choice can never leave a free flight parked in one; getEnvironment still finds them by name.

IdIs
'open-country'Low rolling ground under a wide sky, with one strip cut into it
'loop-valley'A shallow valley with clear air over it, for a course of loops
'back-country'Rough, wooded ground with no strip in it, for a search flown by the compass
'canyon-reach'A deep plateau cut end to end by canyons, for a run flown low
'survey-country'Town, forest, water and rock together, for a survey flown with a camera

The first three are deliberately thin - what a mode asks the pilot to read is the objective, not the scenery around it. The last two are not, and for the same reason read the other way round: a canyon run wants real walls standing beside the corridor it is judged on, and a survey wants something on the ground worth photographing.

An environment is a description rather than geometry: a name, a seed, the base ground, and the elements placed over it. A host can pass its own list of placements instead of a preset's, and every value in one is clamped into the range the element declares, so a placement cannot configure an element outside what it says it supports.

A placement may also carry a footprint, and is then drawn only where the footprint reaches rather than across the whole square:

{
    type: 'grass',
    config: { band: [0, 400] },
    // A disc 900 units across the middle of it, east and south of the centre.
    footprint: { kind: 'disc', x: 2000, z: -1500, radius: 900 }
}

The kinds are disc (x, z, radius), ring (points, a closed outline of [x, z] pairs), stroke (points with a stamp of width, depth and corner swept along them: as wide as the stamp all along the way, joined round at every bend, and the stamp itself at each end, reaching half its depth past the first and last points), rect (x, z, width, depth, angle, corner), ellipse (x, z, radiusX, radiusZ, angle), polygon (x, z, radius, sides, angle, and an inner between 0 and 1 that pulls every other corner in to that share of the radius, making a star) and path (points, run through by a smooth curve, round the loop unless closed is false, when it ends on its last point and closes straight back), each with an optional feather: the width in world units of the band the element fades out across at the edge, two cells of the sampling where none is given. Whichever it comes to, it is held to half the way in from the region's edge to its deepest point, so the inner half of a region is always covered whole: a feather wider than a small region has room for would leave it with no point fully covered at all, and everything that reads one - an extrusion's top among them - would read nothing. A region with room to spare keeps the feather as given. A combine (op, a, b) makes one region of two: union covers either, intersect only both and subtract the first without the second, each part keeping its own feather, and either part may itself be a combine. A placed footprint (of, x, z, angle, scaleX, scaleZ) sets another down somewhere: of is drawn about the middle of its own frame, and the frame puts that middle at x, z, turned by angle and stretched along its own two axes, so a region with no one middle - several strokes unioned - can be moved, turned and stretched whole. The coordinates are the square's own, measured from its middle, so the same footprint lands in the same place on every tile of a tiled world, and one drawn over an edge of the square carries on across the opposite edge - which is what lets a built square repeat as a world without a cut at its seams. The element is drawn on each square's own ground, so anything it works out from the ground is worked out where it lands. A placement without a footprint is drawn exactly as it always was.

A ground cover drawn with a footprint of its own - grass or sand - is not held to its band. The band is worked out on each square from the ground the footprint covers there, its lowest point to its highest, so the cover lies on all of that ground and its colour runs from the light end on the low ground to the dark end on the high. Every ground cover - grass, sand, water and snow - also takes a density between 0 and 1: how much of the ground it reaches it covers, in clumps that stay where they are each time the ground is drawn, from none at 0 to all of it at 1, which is the default and how every preset is drawn:

{
    type: 'grass',
    // Half the ground under the stroke, in clumps; the band is the stroke's.
    config: { density: 0.5 },
    footprint: { kind: 'stroke', points: [[-1000, 0], [1000, 200]], stamp: { width: 600, depth: 400, corner: 50 } }
}

water drawn with a footprint of its own is not the sea: it rests at a line of its own, worked out on each square from the ground under it - at each point of a stroke, a little under the lowest bank of the stroke's cross-section there, so it lies level across a stroke and follows the ground down along one - and the ground under it is cut down to that line, never raised. Its level and flatten are not read, and the sea's level is not moved. A footprint with no stroke in it, a disc or a box, rests at its lowest ground. It is coloured pale at its banks and dark across its middle, and a strip laid over it is lifted clear of it. snow drawn with a footprint of its own settles on any ground the footprint covers, its line put just under the lowest of it, and lies deeper the further in from the footprint's edge:

{
    type: 'water',
    // A stream down whatever ground the stroke crosses: no level to set.
    config: {},
    footprint: { kind: 'stroke', points: [[-2000, -1500], [0, 0], [1500, 2500]], stamp: { width: 700, depth: 400, corner: 100 } }
}

paint lays a colour and no height. Its color is one colour, [r, g, b] from 0 to 1; its opacity, from 0 to 1, is how much of the colour covers the ground; and its blend is how the two meet: 'tint', the default, takes the colour's hue at the ground's own lightness, so a painted slope keeps its light and shade and a black or white is the ground's own grey, and 'normal' lays the colour as it is. It is applied after snow, as the last colour laid. Without a footprint it paints the whole square; through one, only where the footprint reaches, fading in across its feather:

{
    type: 'paint',
    config: { color: [0.2, 0.4, 0.9], opacity: 0.8 },
    footprint: { kind: 'stroke', points: [[-1200, 300], [900, -400]], stamp: { width: 500, depth: 500, corner: 250 }, feather: 250 }
}

extrude levels and raises: every vertex is moved toward config.level by config.flatten - all the way at 1, which is a flat top - and raised by config.height, from -400 to 1200, below nothing for a pit. With a footprint of its own the level is the mean height of the ground the footprint covers on each square, worked out there, and the band at the footprint's edge is the wall: it is painted config.wallColor, and what the footprint wholly covers config.topColor. Its blend is how the two meet the ground, as paint's does: 'tint', the default, takes each colour's hue at the lightness of the ground it stands on, so a wall shades down a slope the way the slope does, and the wall, blended by the band's weight, fades to the ground's own colour at its foot; 'normal' lays the two colours as they are:

{
    type: 'extrude',
    // A block 120 over the ground's mean, roofed in red at the ground's own lightness.
    config: { height: 120, topColor: [0.6, 0.2, 0.2] },
    footprint: { kind: 'rect', x: 0, z: 0, width: 800, depth: 500, angle: 0, corner: 0, feather: 40 }
}

A placement of type erase lays nothing: it is cut out of the placements listed before it. config.target names what it takes - 'all', the default, for every element but runway, or any one element id - and config.strength, from 0 to 1, how much of it; its footprint, and its clip where it carries one, say where. Where a placement is taken the ground is as it stood before that element ran, and anything the placement's own footprint settled is worked out as drawn, before the cut. config.restore: true makes one that puts back instead: it is cut out of the regions the erase placements before it took, from the placements it names. A runway is taken whole or not at all, and only when it was placed with at and 'runway' is the target named; water taken out of the sea is dry ground, its line gone where it was taken:

[
    { type: 'mountain', config: { count: 4 } },
    // Half of whatever is under the stroke, from everything before it.
    { type: 'erase', config: { target: 'all', strength: 0.5 }, footprint: { kind: 'stroke', points: [[-2000, 0], [2000, 0]], stamp: { width: 800, depth: 800, corner: 400 } } }
]

A placement may carry a seed, any finite number, and then draws from a random stream of its own rather than the world's. A world's placements otherwise share one stream in the order they are applied, which is what makes a description the same world every time - and also means a placement added among them moves every draw after it. Seeded, it takes nothing the others were going to draw, so they stay where they were, and it draws the same values on every tile.

A placement of type group is a runway template placed whole: config holds where it stands, at, which way it runs, heading, and how long and wide its strip is, and members holds the placements it stands for, laid out in its own frame - world units from its middle, a heading of 0 running north. The members are copied in when it is placed rather than looked up by the template's name, so a template changed later leaves a world built with it as it was. When the world is built the group is expanded: each member turned through its heading and moved to where it stands, its strip given its length and width, and kept wholly on the square, so no copy of it crosses an edge.

{
    type: 'group',
    template: 'basic-runway',
    config: { at: [2000, -1500], heading: 120, length: 2400, width: 240 },
    members: [{ type: 'runway', role: 'strip', config: { at: [0, 0], heading: [0, 0] } }]
}

A mountain's configuration may carry at, [x, z] in the same square's frame as a footprint, and is then one peak standing on that point whatever its count says - a mountain dropped somewhere rather than scattered. A peak near an edge carries on across it into the next square. at is a point rather than a range: it has no bounds and no row on a panel, and no preset uses it.

{
    type: 'mountain',
    // One peak 600 tall, east and north of the middle.
    config: { at: [2500, -1800], height: [600, 600] },
    seed: 20260101
}

A placement may also carry a clip: a second footprint, of any of the kinds above, that it is drawn inside as well. Its weight multiplies the placement's own - the footprint's where it has one, everything where it has none - so the element is drawn where both reach and eased out across whichever edge is softer. It is what the builder's lasso writes onto a tool that minds the selection, and a placement without one is drawn as it always was.

{
    type: 'mountain',
    config: { at: [0, 0], height: [800, 800], radius: [3000, 3000] },
    seed: 7,
    // A wide peak cut to a square of ground around its top.
    clip: { kind: 'rect', x: 0, z: 0, width: 4000, depth: 4000, angle: 0, corner: 0 }
}

Every range an element declares says which of six kinds it is, which is what a host reading ELEMENTS has to branch on and what a value handed to resolveConfig is read as:

KindAsked for asResolves to
span[low, high]A pair of bounds, each clamped, the lower first
scalarA numberOne number, clamped
gradient{light, dark}A base hue as its two ends, each a colour triple
gated{on, range}, or a bare [low, high]{on, range} - a pair of bounds and whether they apply
toggletrueA boolean - a mode the element is either in or not
choiceOne of the range's valuesThat value, or the range's default for anything not on the list

toggle and choice are named for the TOGGLE_FIELD and CHOICE_FIELD kinds the start-state fields in js/config.js already use. A choice lists strings or numbers, never objects, so a placement holding one stays JSON-shaped.

Any range may also carry a when: { field: value } or { field: [values] }, naming another field of the same element and the values that open it, or { 'ctx.<name>': value } for a fact a panel is handed about its session. It says when a panel offers the range, and nothing more - a panel draws a range gated shut disabled and keeps its value, and resolveConfig resolves and keeps it the same either way, so a gate never changes the ground.

A gated range is a pair of bounds that only sometimes applies, for a footprint an element otherwise works out for itself. A span always applies, so the only way to say "leave this alone" with one is to find a value that happens to mean nothing - and then the generator cannot be asked for that value. The switch says it outright instead.

It is off unless something turns it on, so an element can declare one without moving any world already described: a placement written before the range existed resolves to the ground it always did. A bare pair turns it on, because a caller that troubled to name bounds is asking for them to be used; an on with no bounds beside it takes the declared ones. Anything other than true is read as off, so what comes back is always an answer rather than a value to test for truthiness.

The mountain declares three. length and width are the two axes of the ellipse a peak is drawn on; off, each gives way to what was always there - width to radius, and length to radius stretched by girth - so a peak is round, stretched by a random amount, or exactly the footprint it was asked for. girthRange is the third, and it is the girth said as a pair of bounds rather than as the one value every peak shares: on, each peak draws a girth of its own out of it, and the girth scalar is what it gives way to when it is off.

Where two of them describe the same axis, the one asked for outright wins. A length is the long axis whatever the girth says, and a girthRange is the stretch whatever the girth scalar says.

import { createEnvironment } from 'pilot-matter';

createEnvironment({
    elements: [
        // A ridge: the long axis said outright, the short one left to `radius`
        { type: 'mountain', config: { count: 8, length: [2600, 3200] } },
        // Both axes given, so `girth` has nothing left to say about the peak
        { type: 'mountain', config: { count: 4, length: { on: true, range: [1800, 1800] },
                                               width:  { on: true, range: [600, 600] } } },
        // Peaks stretched by a girth each, drawn between the bounds given
        { type: 'mountain', config: { count: 6, girthRange: [0.2, 0.9] } }
    ]
});

A toggle is a switch with no bounds behind it: a mode an element is either in or not, for something that reaches across several ranges at once and so has no pair of its own to carry. It is off unless something asks for it, and only true asks - anything else is read as off, the way a gated switch is.

The mountain declares one, bulk: the footprint randomized at once, inside the bounds each range was configured with. It reads length and width as on without writing to them, so a world put into the mode and taken out of it is back on whatever each switch was left saying.

Two of the three footprint ranges, and girthRange is the one it leaves alone. Under the mode length holds the long axis, so a girth - the scalar or the range - has nothing left to decide either way, and a host sending { bulk: true, girthRange: [0.2, 0.9] } gets the length's own spread rather than a girth's. That is the precedence length already has over girth outside the mode, not a rule the mode invents.

import { createEnvironment } from 'pilot-matter';

createEnvironment({
    elements: [
        // The two footprint ranges randomized together, inside the bounds
        // given - and `girth` is spent, because `length` holds the long axis
        { type: 'mountain', config: {
            count: 10,
            bulk:  true,
            width:  { range: [300, 900] },
            length: { range: [1200, 3000] }
        } }
    ]
});
import { createEnvironment } from 'pilot-matter';

const world = createEnvironment({
    size: 8000,
    elements: [
        { type: 'mountain', config: { count: 12, height: [200, 700], radius: [300, 900] } },
        { type: 'grass',    config: { band: [10, 200] } },
        { type: 'water',    config: { level: 6 } },
        { type: 'snow',     config: { line: 400, coverage: 0.8 } }
    ]
});

The field itself is a plain object of typed arrays, sampled row by row from the low z edge to the high one, which is the order a renderer walks a plane grid in. A host that wants the ground without a mesh can build one and read it:

import { buildEnvironment, getEnvironment, sampleHeight } from 'pilot-matter';

const field = buildEnvironment(getEnvironment('canyon-country'), { size: 8000, segments: 128 });
const ground = sampleHeight(field, 120, -400);
Field memberIs
size, segments, stride, count, stepThe grid the world is sampled on
originX, originZWhere the middle of that grid sits in the world
heightA Float32Array of one height per vertex
colorA Float32Array of three components per vertex
runwaysThe strips cut into it, which a flight model has to be able to ask about by name
waterThe water laid on it, or null for a dry world: level, the sea's one height, or null where there is no sea, and line, a Float32Array of one height per vertex - the line the water at that vertex rests at, NaN where it is dry

The day

A world lit one way all flight is a world with one hour in it. The cycle turns that hour into a day: the light warms and cools, the sky and the fog it fades to go with it rather than after it, and the sun walks across the sky instead of hanging in one corner of it. The whole of it is arithmetic - no renderer, no scene - so a host can drive its own sky from it, or read the hour without drawing one.

ExportIs
createDayNight(options)A day: how long it runs, and the hour it opens on
advanceDayNight(state, dt)Moves the day on by a frame, and returns the hour
daylightAt(phase)The light at an hour: the sky, the sun, the fill, and how much day it amounts to
sunPositionAt(phase, distance)Where the sun is at that hour
wrapPhase(phase)An hour outside the day, as the hour inside it that it amounts to
clockAt(phase)The hour as a clock reading, for anything that shows it
CYCLE_LENGTH, CYCLE_STARTHow long a day takes, and the hour a flight opens on
DAY_STOPSThe day as the moments it is read between
SUN_DISTANCEHow far the sun is thrown from the middle of the world

Phase runs 0 to 1 over a whole day: midnight at 0, dawn around a quarter, noon at a half, dusk around three quarters. Midday is the light the world was drawn in before it had a day, so a flight at noon is lit exactly as it always was.

import { createDayNight, advanceDayNight } from 'pilot-matter';

const day = createDayNight({ length: 600, phase: 0.3 });   // ten minutes, opening mid-morning

renderer.setAnimationLoop(() => {
    const dt = clock.getDelta();
    advanceDayNight(day, dt);

    const light = world.setDaylight(day.phase);   // the sky, the fog, and the sun
    world.updateWater(dt, light.daylight);        // and what there is to glint with
});

Time a flight did not spend flying is time the day does not spend passing: hand the cycle a dt of 0 and the sun stays where it was left, which is what keeps a paused world paused.

daylightAt is the whole reading, for a host lighting its own scene:

FieldIs
phaseThe hour it was read at
labelWhat that hour is called - NOON, DUSK, NIGHT
skyThe colour the world fades to, as [r, g, b] in 0 to 1
sun{color, intensity} for the directional light
ambient{color, intensity} for the fill
daylightHow much day there is at all, 0 to 1

The water

Water is the one part of the ground that moves and the one part that shines. The surface a world settled at is read off the finished field - the vertices lying at or under their own water line: the sea's level wherever the sea reaches, and a drawn body's own line where one was drawn - and moved from there, so ground the water no longer has, a strip graded over a shallow or a town levelled onto a shore, is not still being moved as though it were, and a valley beside a pond drawn on a hill is not water for lying under the pond.

ExportIs
waterSurface(field)The surface a world settled at, or null for a dry world
matchWater(surfaces)Settles an assembly's water wherever two of its tiles share a wet vertex
waveHeight(x, z, time, wave)How far the surface stands off its resting level at a point
waveSpecular(x, z, time, wave)How much of the light that point is throwing back
waterColor(base, specular, options)The colour water is showing, with the light laid over it
animateWater(surface, time, options)Moves a whole surface on to a moment in time
WAVEThe swell every world's water is drawn with
WATER_SHEEN, SHEEN_STRENGTHThe colour the sun leaves on water, and how much of it a crest shows

The wave is a function of where a point is in the world and what time it is, which is what lets two tiles of an assembly work out the same surface at the place they meet without agreeing on anything. animateWater writes into the arrays a renderer is already drawing from, three numbers a vertex, and touches nothing but the water:

import { waterSurface, animateWater } from 'pilot-matter';

const surface = waterSurface(myField);
const position = myMesh.geometry.attributes.position;
const color = myMesh.geometry.attributes.color;

animateWater(surface, elapsed, { positions: position.array, colors: color.array, light: 0.8 });
position.needsUpdate = true;
color.needsUpdate = true;
Surface memberIs
levelThe height the water was laid at
count, verticesHow many vertices it covers, and which they are
x, zWhere each of them is in the world
restThe height each of them rests at
colorThe colour each of them was painted
openHow far from the bank each of them is, 0 to 1
edgeWhich of them lie on the outside of the field, as indices into the surface
aloneWhat this field alone makes of open, recorded once and left alone

open is what holds the swell down to nothing at the shoreline, so the water meets the bank it was poured against rather than lapping over it.

It is also the one thing an assembly has to settle about its water. The ground either side of a join is matched by matchEdges, and the surface is read off that settled ground - so the height the water rests at and the colour it is painted already agree at a join. open does not: it counts a vertex's four neighbours and reads the one off the field as water, which is what keeps each tile from drawing a shoreline down the join, but the neighbour that is *on* the field is a different vertex on each side of the line. Left alone, the same place rides a different wave and throws back a different amount of light depending on which tile drew it.

matchWater closes that, over the edge vertices rather than over every vertex under the level. It is called for an assembly after its surfaces have been read, which on join and on createTiledEnvironment is after the redraws - a surface read off the field again comes back with the openness one tile alone makes of the join:

import { matchEdges, matchWater, waterSurface } from 'pilot-matter';

matchEdges(fields);
const surfaces = fields.map(field => waterSurface(field));
matchWater(surfaces);   // how many shared vertices were settled

A vertex only one tile calls water is left exactly as it is, so a bank never moves: averaging it against a tile that calls the same place land is how a shoreline walks.

What it settles *from* is alone, which is why a place comes out at the same value however the world around it was assembled. A world laid a tile at a time settles a place more than once - a tile laid later moves a neighbour's ground, that neighbour's surface is read off the moved ground again, and the join is settled a second time - so averaging open would be averaging a fresh reading against one an earlier pass had already settled, and would weight whichever tile was laid first. A host that writes its own open can leave alone as it is; a host that builds a surface by hand rather than through waterSurface gives it an alone, or matchWater passes it over along with the dry fields.

The edge of the world

A terrain declares bounds and answers nothing outside them, so an aircraft flown far enough would find itself over a flat nothing the fog had been promising was more world. By default the Pilot API carries the aircraft round instead: crossing an edge puts it back in over the opposite one, at the same distance past it, at the same altitude, and on the same heading. Only the horizontal position moves.

A host whose own world continues past the bounds it declared, or which would rather handle the edge itself, creates its pilot with wrap: false. The rule is published either way, so a host handling its own edge can match it:

ExportIs
wrapValue(value, low, high)A value carried back inside a span
wrapPosition(bounds, x, z){x, z, wrapped} - a position carried back inside a world
isOutsideBounds(bounds, x, z)Whether a point is past one of the edges
import { wrapPosition } from 'pilot-matter';

const carried = wrapPosition(world.bounds, myAircraft.position.x, myAircraft.position.z);
if (carried.wrapped) myAircraft.position.set(carried.x, myAircraft.position.y, carried.z);

Sitting exactly on a boundary is still inside the world: a position that has only reached the edge has not crossed it.

The bundled simulator answers the same question a different way. Rather than carry the aircraft round one square, it lays the square down as one tile of an endless grid and draws the tiles around the aircraft out past the far plane, so the ground it is over is always ground that continues. A host can build the same thing out of what is published here - tileSeed for the seed a place is laid out from, and createEnvironment({ tile }) for the square it is laid at - or use createTiledEnvironment for a grid of a fixed size with its joins settled.