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);

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.

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
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

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_RESCUEThe five of them by name
LAND_OBJECTIVE, LOOP_OBJECTIVE, CARGO_OBJECTIVE, SEARCH_OBJECTIVEWhat a mode is asking for
getGameMode(id), isGameModeId(id)Looking one up
createRunState(modeId), startRun(state, id), endRun(state)A run, started and stopped
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
stageProgress(state), isStageComplete(state), nextGate(state), nextStrip(state)How far through it is
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
runPointer(state, world, position, heading)Where the thing it is waiting on lies
gatePointer(...), stripPointer(...), searchBriefing(state)The three runPointer dispatches to, one per objective
stageWorld(state)The world the stage is flown over
stageStart(state, world)Where in it the flight opens
buildCourse(stage, options)The loops a course stage is flown through
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
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

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');
}

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
SEAM_BLENDHow far a join is eased back into the ground, in vertices
waterSurface(field)The water a world settled at, as the vertices under its level

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

Two more are built for the game modes rather than to be chosen between, and are deliberately thin - what a mode asks the pilot to read is the objective, not the scenery around it. isEnvironmentId answers no for both, 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

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.

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 level whatever water was laid settled at, or null for a dry world

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 the level the water was laid at - 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.

ExportIs
waterSurface(field)The surface a world settled at, or null for a dry world
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

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.

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.