Building and flying
The flight program
The update(fc) contract: what runs when, what you can read, what you can command, and how to keep state.
A flight program is one file of plain JavaScript. The simulator evaluates it
once, at launch, and from then on calls one function in it fifty times per
simulated second. That function reads the vehicle's state from a flight
computer object, fc, and gives it commands. There is no other channel: no
event handlers, no callbacks, no way to reach the page or the simulator. What
the vehicle does is what that function told it, the last time it was called.
The contract is small, and most of this page is about the consequences of it.
The contract
function update(fc) {
// the main vehicle: called once per simulation step, 50 times per simulated second
}
function booster(fc) {
// optional: called the same way for every stage you separate, each with its own fc
}
- The file is evaluated once, in strict mode. Top-level code runs once;
top-level variables persist for the whole flight and are shared by
updateandbooster. update(fc)must exist. It is called for the main vehicle: the whole stack on the pad, and after each separation the part above the stage that fell away.booster(fc)is optional. Every stage you drop withfc.separate()gets its own flight computer and is flown bybooster, starting in the very step it separates. Without it, a separated stage flies with no program at all.- Once a vehicle has landed, its function is no longer called for it.
- Commands work only while
updateorboosteris running. Code that runs later — a resolved promise, a callback — gets an error, and anupdatethat returns a promise is flagged in the console. Keep flight code synchronous. - If the function throws, the error and its line go to the console, and the
vehicle keeps its last commands and flies on without software. A crash in
boosteraffects only that stage.
When it runs
The simulation advances in fixed steps of 20 ms of simulated time. In each step, for each vehicle, it does the same three things in the same order:
- It calls the program with the vehicle's state at the start of the step,
the time in
fc.t. - It applies the commands the program gave, from this step on.
- It integrates the vehicle over the next 20 ms — six degrees of freedom, fourth-order Runge–Kutta, four sub-steps within 2 m of the ground — with those commands held.
So the program sees the world fifty times a second, and every command it gives
lasts at least 20 ms. fc.dt is the step, 0.02 s in the playground; a
headless Simulation({ dt }) may use another, which is the reason to integrate
with fc.dt rather than a constant.
Time warp changes none of this. It is display only: at a warp of 5,000 the simulator runs more steps per animation frame, and the program is called exactly as often per simulated second as at 1. A flight is identical at every warp, and identical with or without a browser watching it.
Twenty milliseconds is short, but it is not nothing. A booster falling at 225 m/s moves 4.5 m in one step; an orbit at 7.8 km/s moves 156 m. A decision taken one step late is taken that far late.
Sleeping
fc.sleep(seconds) stops the calls for that vehicle for that much mission
time. Its commands hold — a burn keeps burning — and fc.sleeping reads true
until it runs out. A vehicle coasting above 150 km with its engine, RCS and
ullage thrusters off is then propagated on rails: the same step, a much
cheaper model of the motion. An hour of orbit costs almost nothing.
Rails have one condition that catches programs out: a steered vehicle goes on
them only once its attitude has settled on an unchanged command, within 1° and
turning less than 0.1°/s. A turn commanded just before fc.sleep() is flown
awake, with its time and its RCS gas, and only then does the vehicle go on
rails. There is no way to skip a turn by sleeping.
What you can read
Everything is a property of fc. Units are SI unless a name says otherwise;
angles are in degrees. The fields a first program uses most:
| Field | Unit | What it is |
|---|---|---|
fc.t, fc.dt | s | Mission time, and the step |
fc.altitude | m | The vehicle's base above the WGS84 ellipsoid |
fc.radarAltitude | m | Its lowest point — feet or nozzles — above whatever is below: ground, deck or sea |
fc.verticalSpeed | m/s | Relative to the surface, positive up |
fc.horizontalSpeed | m/s | Relative to the surface, along the guidance plane, positive forward |
fc.surfaceSpeed, fc.airspeed, fc.orbitalSpeed | m/s | Speed relative to the ground, the air and the stars |
fc.pitch, fc.yaw | ° | Where the nose points, in the guidance plane |
fc.prograde, fc.airPrograde, fc.orbitalPrograde | ° | The direction of travel as a pitch, relative to ground, air and space; each has a retrograde and a yaw companion |
fc.dynamicPressure, fc.aoa, fc.qAlpha | Pa, °, Pa·° | The air's push, the angle of attack, and the bending load the structure is judged on |
fc.mass, fc.propellant, fc.rcsGas | kg | The whole vehicle; the usable propellant of the bottom stage; attitude-thruster gas |
fc.thrust, fc.maxThrust, fc.minThrust | N | Thrust now, and at full and minimum throttle at the current air pressure |
fc.engineState | 'off', 'starting', 'running' or 'stopping'; fc.engineRunning is true for the middle two | |
fc.ignitionsLeft, fc.propellantSettled | Starts left, and whether the next one can work | |
fc.twr, fc.deltaV, fc.isp | —, m/s, s | For the engines lit, or those that would light |
fc.gravity, fc.effectiveGravity | m/s² | Local gravity, and gravity less the relief of horizontal speed: about 0 in orbit |
fc.orbit | object | Apoapsis, periapsis (above the equatorial radius), period, inclination and the rest |
fc.target, fc.impact | object | The landing platform, and where an engine-off descent would come down relative to it |
fc.stageNumber, fc.stageCount, fc.hasLegs, fc.payloadAttached | What the vehicle still is | |
fc.contact, fc.landed | Touching something; landed and settled | |
fc.mission | object | The mission, frozen: profile, targets, site, azimuth, sensors, fidelity |
fc.limits | object | What the simulator enforces: 250,000 Pa·° of bending, 15 g, the skin temperatures |
The flight computer reference lists every field. Three of
these are easy to confuse. fc.altitude is measured from the ellipsoid, and
fc.radarAltitude from whatever is under the vehicle, so over a pad 147 m up
at Vandenberg they differ by 147 m. And fc.orbit.apoapsis and
fc.orbit.periapsis are measured from the equatorial radius, 6,378 km, so at
high latitudes they differ from altitudes by up to 21 km.
Reading is free, with a few exceptions that do real work: fc.impact
integrates a descent, and is refreshed at most every 0.1 s of simulated time.
The queries below do the same on request and cost more.
What it cannot see
The program sees fc, a small set of frozen helpers — clamp, lerp, deg,
rad, wrap180, PID, and the constants G0, MU, EARTH_RADIUS and
OMEGA — and a Math whose random() is seeded from the mission's seed.
The page and the simulator are out of reach: window, document, Date,
performance, setTimeout, fetch, Function and the rest are undefined
inside a program. There is no wall clock and no randomness the seed does not
decide, which is why the same seed flies the same flight to the step.
Every loop is guarded. A loop that spins for ten million iterations, or for 500 ms of real time, in one call stops the program with an error. The fence is against accidents and casual cheating; it is not a security boundary.
Sensors
By default the program reads the true state. With realistic sensors, chosen in
the Mission panel, it reads a navigation estimate instead: a GPS-aided position
and velocity that wander slowly (about 2 m and 1 cm/s, over 30 to 60 s), an
inertial attitude with a small bias, a radar altimeter with bias and noise,
noisy air data, accelerometers and a propellant gauge. Hard sensors triple
every error. Everything the flight computer derives — prograde, the orbit, the
impact point, every prediction — runs on the estimate; the physics never sees
the errors. fc.mission.sensors tells the program which it has.
A few centimetres per second of velocity error is a kilometre of predicted range after a de-orbit burn. A program written for ideal sensors that acts on one prediction will be a kilometre off; one written for real sensors averages, filters, and corrects late.
What you can command
| Command | What it does |
|---|---|
fc.throttle(x) | 0 to 1, each engine held within its own range. Below the minimum the engine holds its minimum |
fc.ignite() | Starts the selected engines. Each group lit uses one ignition, even if the start fails |
fc.shutdown() | Cuts the engines, with a 0.25 s tail-off |
fc.setEngines(n) | Which engines light: a count, a count per group, or 'sea-level', 'vacuum' or 'all' |
fc.steer(pitch, yaw) | The autopilot holds that direction in the guidance plane; fc.steer(null) turns it off |
fc.setPlane(plane) | What pitch and yaw are measured against: 'launch', 'orbit', a target or an azimuth |
fc.control(actuators) | Raw gimbal, RCS and fin commands, in place of the autopilot until the next steer |
fc.separate() | Drops the bottom stage; refused while clamped to the pad |
fc.jettisonFairing(), fc.deployPayload() | Drops the fairing; releases the payload, dropping the fairing first |
fc.deployLegs(), fc.deployFins() | About 3 s and 2 s, and irreversible; legs add drag |
fc.ullage(on) | Fires the aft thrusters to settle the propellant, while the gas lasts |
fc.sleep(s) | No calls for that much mission time; commands hold |
fc.setTarget(id), fc.expend(reason) | Divert to the other platform; give up on this stage on purpose |
fc.log(...), fc.warp(n) | A line in the console with the mission time; ask the display for a warp |
Commands hold until they are changed. fc.steer(90) given once holds the nose
up until something else is asked for, and the throttle set before an ignition
is the throttle the engine lights at. Giving the same command every tick is
harmless: fc.ignite() while the engines are already burning, for instance,
lights nothing new and spends nothing.
A command given a bad number throws a TypeError rather than guessing:
fc.throttle('full') and fc.steer(NaN) both stop the program. A command the
vehicle cannot carry out — separating a single-stage vehicle, releasing a
payload twice — is refused with a warning in the console, and the program
carries on.
Queries
Four calls answer questions about the future without changing it. They are covered properly in steering and guidance; what matters here is that they are real computations with real costs.
| Query | What it answers | Cost |
|---|---|---|
fc.predict(burn) | Where a descent comes down after a hypothetical burn; or, with a landing burn, when to light it | about 1.3 ms with a landing burn |
fc.stopPoint(burn) | Where the landing burn lit now, or already running, brings the vehicle to rest | about 0.7 ms |
fc.passes(options) | When the orbit's ground track next passes the target, and how far to the side | hours of orbit per call: plan once |
fc.burnTime(dv), fc.timeToAltitude(h) | How long a burn takes; when a coast reaches a height | small |
The costs are wall-clock time, and the playground gives the simulation 11 ms
of each animation frame. A program that calls fc.predict() with a landing
burn on every tick spends most of that budget on predictions, and the warp it
can reach falls to something like ten times real time on a 60 Hz display. Call the
expensive queries a few times a second and keep the answer.
Keeping state
There are two places to keep anything between calls.
Top-level variables persist for the whole flight. They are shared by
update, booster, and every stage booster flies. That makes them right for
the main vehicle's phase and wrong for anything a booster needs.
fc.mem is a plain object that belongs to one vehicle. Each separated stage
gets its own, empty to begin with. Anything booster(fc) needs to remember
belongs there.
Time within a phase should come from fc.t, never from counting calls. A
vehicle that slept was not called, and a headless run may use another step; the
clock is right in both cases and a counter is wrong in both.
Expensive answers are state too. The reference programs keep a plan and the time it was made, and refresh it when it is old enough to matter:
// the planned landing burn, refreshed when it is older than maxAge seconds
function landingPlan(fc, maxAge) {
const m = fc.mem;
if (m.planT == null || fc.t - m.planT >= maxAge) {
m.plan = fc.predict({ landingBurn: { engines: 1, throttle: 0.9 } });
m.planT = fc.t;
}
return m.plan;
}
Phases
Almost every program worth flying is a state machine: a name for what the
vehicle is doing now, a branch per name, and a rule for when to move on. The
reference Full mission program has fifteen phases for the main vehicle —
twelve on the way there and back, from launch through ascent, meco,
upper, orbit, align, deorbit, trim, coast, entry and landing to
landed, and three for when there is no way back — and seven for the booster.
The pattern that holds them together is a transition helper:
let phase = 'liftoff', since = 0;
function go(fc, next) {
phase = next;
since = fc.t;
fc.phase = next; // the label in the top bar
fc.log('→', next, '| alt', (fc.altitude / 1000).toFixed(1), 'km');
}
function update(fc) {
const inPhase = fc.t - since; // seconds since this phase began
switch (phase) {
case 'liftoff':
fc.throttle(1);
fc.steer(90);
fc.ignite();
go(fc, 'ascent');
break;
case 'ascent':
if (fc.surfaceSpeed > 60) fc.steer(Math.min(82, fc.prograde));
if (fc.propellant <= 0) go(fc, 'staging');
break;
case 'staging':
if (inPhase > 1 && fc.stageNumber === 1) fc.separate();
// …
break;
}
}
function booster(fc) {
const m = fc.mem; // this stage's own memory
if (!m.phase) { m.phase = 'flip'; m.since = fc.t; }
fc.phase = m.phase;
// …
}
A few rules make state machines like this behave.
- Move on conditions you can see, not times you guessed. "Until the propellant is gone" survives a heavier payload; "until T+144 s" does not.
- Let each phase command everything it depends on, every tick. Steering and throttle are cheap to repeat, and a phase that sets its own attitude does not care which phase came before it.
- Guard the one-shot actions. An ignition, a separation or a deployment
should happen once. Change phase in the same call, as
liftoffdoes, or keep a flag infc.mem. - Give every phase a way out. A relight can fail and the reference program retries while ignitions are left; a turn that has not finished after half a minute is not going to. A phase with no exit is a vehicle waiting forever.
- Log the transitions. One
fc.log()ingo()gives a timeline of the flight in the console, which is most of what debugging a flight needs.
fc.phase itself is only a label, up to 40 characters, shown in the top bar.
Nothing in the simulator reads it.
Common mistakes
Each of these was reproduced in the simulator while writing this page.
A variable that was never declared. Programs are strict. phaze = 'coast'
throws a ReferenceError the first time it runs, the program stops, and the
vehicle flies on with whatever it was last told.
A NaN reaching a command. Math.asin of anything outside −1 to 1, or a
division by a speed that has reached zero, produces NaN, and
fc.steer(NaN) throws "fc.steer(pitch) needs a number in degrees". Clamp
before the trigonometry and guard the divisions.
fc.throttle(0) to stop the engine. It does not stop anything. Below its
minimum an engine holds its minimum, so on the pad nine Merlins asked for 0
still push 2.6 MN. fc.shutdown() stops engines. The reference programs use
fc.throttle(0) deliberately, to mean the gentlest burn available.
Relighting in free fall. In free fall the propellant floats off the tank
outlets and counts as unsettled after about twelve seconds. fc.ignite() then
fails — and the ignition is spent anyway. Called every tick, it spends them
all: after a 30 s coast in orbit, an Aster upper stage used its four remaining
ignitions in four ticks, 0.08 s, and never lit. Fire fc.ullage(true), wait for
fc.propellantSettled, and ignite once.
One phase for two vehicles. A top-level phase read in booster(fc) is
the main vehicle's phase. The booster then does whatever the upper stage is
doing. Keep a booster's state in fc.mem.
Counting ticks. fc.sleep() skips calls; the clock does not. Store fc.t
when something happens and compare against it.
Letting the wind choose the launch azimuth. Steering along
fc.airProgradeYaw from the pad turns the rocket towards whatever the surface
wind suggests, and the gravity turn locks that heading in. In
the first flight it took the orbit's inclination from 26°
to as much as 46°.
Forgetting the ignition dead time. On real launch sites an engine gives
nothing for 0.2 to 0.5 s after fc.ignite(). A hand-made landing trigger must
add that time's worth of fall, 75 m at 250 m/s. fc.predict({ landingBurn })
already plans the command with it.
A plan too old, or too new. A landing plan 0.2 s old lights a burn 30 m late at 150 m/s. A plan refreshed on every tick costs 1.3 ms each time. The reference programs refresh every 0.5 s high up, every 0.2 s in the last five or six kilometres, and every tick in the last second before ignition.
Assuming fc.horizontalSpeed is all of it. It is the component along the
guidance plane. Sideways drift shows in fc.progradeYaw and fc.heading, and a
landing that nulls only fc.horizontalSpeed arrives drifting.
When it goes wrong
A syntax error is caught before launch: the launch-readiness card checks the program as you type, and a launch with an error stops at the line. An error at run time goes to the console with its line, and the vehicle carries on with its last commands; if that ends badly, the loss card says the flight software crashed and links to the line.
Every loss gets a card on the flight view: the cause — aerodynamic break-up, overheating, the g limit, an impact, a tip-over, collapsed legs, a splashdown, running dry — the numbers that mattered against their limits, and one thing to try. The console holds the events, the warnings, and everything your program logged. Between them they are usually enough to find the tick where it went wrong.