Contents

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 update and booster.
  • 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 with fc.separate() gets its own flight computer and is flown by booster, 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 update or booster is running. Code that runs later — a resolved promise, a callback — gets an error, and an update that 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 booster affects 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:

  1. It calls the program with the vehicle's state at the start of the step, the time in fc.t.
  2. It applies the commands the program gave, from this step on.
  3. 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:

FieldUnitWhat it is
fc.t, fc.dtsMission time, and the step
fc.altitudemThe vehicle's base above the WGS84 ellipsoid
fc.radarAltitudemIts lowest point — feet or nozzles — above whatever is below: ground, deck or sea
fc.verticalSpeedm/sRelative to the surface, positive up
fc.horizontalSpeedm/sRelative to the surface, along the guidance plane, positive forward
fc.surfaceSpeed, fc.airspeed, fc.orbitalSpeedm/sSpeed 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.qAlphaPa, °, Pa·°The air's push, the angle of attack, and the bending load the structure is judged on
fc.mass, fc.propellant, fc.rcsGaskgThe whole vehicle; the usable propellant of the bottom stage; attitude-thruster gas
fc.thrust, fc.maxThrust, fc.minThrustNThrust 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.propellantSettledStarts left, and whether the next one can work
fc.twr, fc.deltaV, fc.isp—, m/s, sFor the engines lit, or those that would light
fc.gravity, fc.effectiveGravitym/s²Local gravity, and gravity less the relief of horizontal speed: about 0 in orbit
fc.orbitobjectApoapsis, periapsis (above the equatorial radius), period, inclination and the rest
fc.target, fc.impactobjectThe landing platform, and where an engine-off descent would come down relative to it
fc.stageNumber, fc.stageCount, fc.hasLegs, fc.payloadAttachedWhat the vehicle still is
fc.contact, fc.landedTouching something; landed and settled
fc.missionobjectThe mission, frozen: profile, targets, site, azimuth, sensors, fidelity
fc.limitsobjectWhat 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

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

QueryWhat it answersCost
fc.predict(burn)Where a descent comes down after a hypothetical burn; or, with a landing burn, when to light itabout 1.3 ms with a landing burn
fc.stopPoint(burn)Where the landing burn lit now, or already running, brings the vehicle to restabout 0.7 ms
fc.passes(options)When the orbit's ground track next passes the target, and how far to the sidehours of orbit per call: plan once
fc.burnTime(dv), fc.timeToAltitude(h)How long a burn takes; when a coast reaches a heightsmall

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 liftoff does, or keep a flag in fc.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() in go() 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.