Adaptive MPC as guidance and predictive shield for a Laya decision model on an arcade lunar lander; VanillaJS landing lab with live and recorded flights
Python
0
10 commits
updated Sep 22, 2026
The game. A small computer game of landing a spacecraft on the Moon. Three landing pads on bumpy ground. The lander has a tilt control and an engine with three settings: off, half, full. Every fifth of a second the pilot picks one tilt and one engine setting. Land gently, upright, on a pad, before the fuel runs out. To make it hard, partway through the flight the engine secretly loses more than half its power, and the pilot is not told.
Two very different pilots.
Three ways they play together.
Why bother, if the engineer alone can land? The engineer can only do what its rules say. It cannot be taught new preferences from examples, cannot take instructions in plain language, and cannot tell you how confident it is. The trainee can do all three. The goal is a pilot with the trainee's flexibility and the engineer's guarantee that physics gets the last word. Today that pilot exists and lands; the next step is more training so the engineer has to step in less often.
The animation below shows this exact flight: the AI proposing each move, the engineer vetoing about one in three, the engine failing at ten seconds, and a soft landing at seventy.

An actual recorded flight: distilled Laya deciding from telemetry, adaptive MPC shielding it, engine thrust cut to 40% at 10 s. The shield replaced 109 of 348 proposals. See docs/media.md.
Every 0.2 s decision, adaptive MPC (recursive-least-squares thrust estimate + NumPy beam search over the nine discrete commands, ported from lunar-mpc to the arcade physics of lunar-laya) does three jobs:
A browser page runs the MPC live (click anywhere in the sky, pick a pad, add a fault, launch). With the local server it also flies both Laya checkpoints and replays every recorded decision with the model's probabilities.
| Document | Contents |
|---|---|
| How MPC and Laya combine | What each side contributes, the three couplings, why the combination is stronger than either, what a swappable proposer would change, and what comes next |
| Measured results | Landings, request following, shield activity and latency for every pilot and fault scenario, with reproduction commands |
| Distillation | Retraining Laya on MPC labels with a telemetry-only prompt: raw 1/90, shielded 90/90 with Laya's choice kept more than four fifths of the time |
| Landing lab | The browser page: live MPC, live Laya pilots through the local server, recorded flights |
| Animated flight | The GIF above: what it shows and how to regenerate it |
HF_HUB_OFFLINE=1 uv run lunar-mpc-laya-serve # http://127.0.0.1:8767/
Pilots on the page: Browser MPC (JavaScript port of the controller, no
server needed), Live Laya with MPC requests, raw or shielded, and Live
distilled Laya, telemetry only, raw or shielded. The recorded tab replays the
evaluation flights after python3 web/build.py dist/eval/*laya*.json dist/eval/mpc-assisted*.json dist/distilled-evaluation-*.json.
Without the server, python3 -m http.server 8767 --bind 127.0.0.1 --directory web
gives Browser MPC only. Details in web/README.md.
Requires the sibling ../lunar-laya checkout (path dependency, like lunar-laya
depends on laya-mlx) and uv:
git clone https://github.com/mraad/lunar-laya.git
git clone https://github.com/mraad/lunar-mpc-laya.git
cd lunar-mpc-laya
The MPC modes need
only NumPy; the Laya modes need an Apple Silicon Mac and the mlx extra.
uv sync # MPC-only environment
uv run python -m unittest discover -s tests -v
uv run lunar-mpc-laya --pilot mpc --episodes 10 --seed 3000 --thrust-scale 0.4 \
--out dist/mpc-fault.json --replay dist/mpc-fault.html
open dist/mpc-fault.html # macOS; upstream's standalone replay
uv sync --extra mlx # adds laya-mlx and MLX; never resync mid-batch
HF_HUB_OFFLINE=1 uv run lunar-mpc-laya --pilot mpc-assisted \
--model ../lunar-laya/models/lunar-laya-supervised-mlx \
--episodes 3 --seed 3000 --thrust-scale 0.4 \
--out dist/fusion.json --replay dist/fusion.html
Pilots: pd-baseline and pd-laya are upstream's PD guidance for comparison;
mpc is MPC alone; mpc-laya executes Laya's choices directly with MPC
requests in the prompt; mpc-assisted adds the predictive shield. --fixed-model
disables learning (ablation), --thrust-scale and --fault-at set the fault
(default: no fault), --switch sets the command-switching cost (0 disables the
smoothing), --margin sets the shield tolerance, --prompt-estimate
appends the engine estimate to the prompt (changes the trained template; measured
separately). uv run lunar-mpc-laya --help lists the rest.
lunar_mpc_laya/mpc.py Dynamics (scalar RLS on thrust gain) and AdaptiveMPC (beam search)
lunar_mpc_laya/pilot.py MPCPilot: upstream Pilot with MPC reference and predictive shield
lunar_mpc_laya/cli.py Session (episode loop with fault injection), upstream JSON schema and replay
lunar_mpc_laya/distilled.py telemetry prompt with the flown command, and the distilled pilot (Laya decides, MPC shields)
lunar_mpc_laya/serve.py local server: page + live /reset and /step decisions from the Python pilots
lunar_mpc_laya/gif.py documentation GIF renderer (Pillow, media extra)
scripts/evaluate.py records every pilot/scenario on seeds 3000-3009 and writes docs/results.json
scripts/parity.py records Python flights that the JavaScript port must reproduce
training/ telemetry-only prompt, MPC-labelled data, CUDA trainer, distilled pilot, MLX verification
tests/test_fusion.py dependency-free checks (fake agents; no MLX)
web/ landing lab: lander.js (physics + MPC port + shared lander art), app.js, build.py, Node test
Every 0.2 s decision the controller reads Game.snapshot() and the public map
geometry, never the fault timing or true engine multiplier. Dynamics.predict
integrates a constant command with the same ten substeps as Game.step, using
the estimated thrust gain. AdaptiveMPC.act expands every retained candidate
by the nine commands for 15 stages (3 s of foresight), keeps the best 32 by
accumulated cost, and returns the plan, the predicted path and the cost. Safe
touchdown inside the target pad is an absorbing zero-cost state; any other
contact or leaving the world is an absorbing 1000-unit penalty. The cost tracks
pad offset, a lateral-velocity reference, an angle reference, a descent-speed
reference capped by the braking distance the estimated thrust allows (0.7
design margin, as in lunar-mpc), a hold at 120 m above the pad while off it,
plus terrain clearance while off the pad and a small throttle cost.
One more term keeps the flight smooth. A beam search that re-picks freely every
0.2 s will take a different command on most decisions even when the difference
barely matters, and the tank then chatters between throttle settings while the
attitude jets flip sign. MPCConfig.switch (default 8) charges each candidate
for changing command, normalized so one knob covers both axes: half the change
in turn plus the change in throttle, each in [0, 1]. The command actually flown
last stage is what the first stage is charged against, so it is per-flight
online state and MPCPilot.reset clears it with the thrust estimate. Measured
over seeds 3000-3009 on all three pads, it cuts the command-change rate from
77.7% to 20.6% of decisions and the stage-to-stage tilt jerk from 5.37° to
1.69° RMS, with all 30 flights still landing under every fault level and fuel
use unchanged.
After each executed command Dynamics.observe compares the measured velocity
change with the prediction and updates the thrust gain by scalar recursive
least squares (forgetting 0.97, clipped to [1, 10]). Gravity and turn rate are
treated as known constants: learning gravity too made thrust and gravity
collinear during a sustained upright burn, and the estimates drifted along the
unobservable direction. Coasting, terminal and fuel-starved transitions are
excluded.
A decision covers 0.2 s, so a page that redraws only when a decision lands animates at 5 fps however smooth the trajectory is. Both canvas tabs keep the state the stage started from, mix the state it ended in back into it by the fraction of the stage the playback clock has covered, and redraw every animation frame. The drawn lander is at most one stage behind the telemetry panel; the panel itself still reports exact decision states, and the trail and predicted path are unchanged.
Lander.drawLander is one chamfered-box lander shared byte for byte with
lunar-mpc and lunar-laya, so the same vehicle appears in all three projects and
in the documentation GIF. Its coordinates are in units of RADIUS / 8 with y
pointing down, which keeps the footpads exactly one hull radius below the
centre: the game treats the hull as a circle of RADIUS and lands when
y - RADIUS reaches the surface, so the feet touch down without sinking at any
canvas size. Passing flip: -1 draws it into a y-up frame.
The replay is upstream's replay.html; its banner reads upstream's mode
(baseline/laya/assisted), so the JSON provenance keeps that field and adds
pilot (mpc, mpc-laya, mpc-assisted) and a controller block. Each
decision record keeps upstream's keys and adds plan, prediction,
plan_cost, proposal_cost, model, prediction_error and solve_ms.
uv run python -m unittest discover -s tests -v # controller, shield, fault harness, server
node --check web/app.js && node web/app.test.cjs # JS parity with Python, page behaviour
uv run python scripts/parity.py # after a controller change; a stale fixture proves the old controller
Tune a cost weight against every scenario, not the convenient one:
scripts/evaluate.py runs nominal, ×0.7 and ×0.4 together for that reason, and
results.md records both what the switching weight cost in the
sibling lunar-mpc and which seeds it was
tuned on.
This is an exploration, not a flight-certified controller. The beam search is incomplete, the cost weights are hand-picked on development seeds 0–9, the landing model checks contact at stage ends only, fuel starvation is not predicted, and the shield margin is a design choice, not a certified safe set. Some starts are unrecoverable for every pilot: for example x = 300 m heading for the right-hand pad with a ×0.4 fault crashes under MPC alone, so it crashes shielded too. The shield can only be as good as MPC's own plan. The original checkpoint receives requested labels in every prompt, so those Laya results demonstrate request following, not independent piloting, and the shield never fired. The distilled checkpoint (docs/distillation.md) decides from telemetry alone: unshielded it lands 1 of 90 flights and lands every flight shielded (90/90), with 12-17% of its proposals overridden. Appending the engine estimate to the prompt broke request following completely, so that flag is a negative result, not a feature. The fault is a single step change in main-engine thrust; sensor noise, delays and other disturbances are untested.
lunar-mpc (adaptive MPC on Box2D), lunar-laya (Laya on the arcade lander), Laya, Laya-MLX.
Apache-2.0. Copyright 2026 mraad. NOTICE records the attribution; Laya and Laya-MLX retain their upstream terms. The trained checkpoint lives in lunar-laya and is not redistributed here.
Adaptive MPC as guidance and predictive shield for a Laya decision model on an arcade lunar lander; VanillaJS landing lab with live and recorded flights
Python
0
10 commits
updated Sep 22, 2026
The game. A small computer game of landing a spacecraft on the Moon. Three landing pads on bumpy ground. The lander has a tilt control and an engine with three settings: off, half, full. Every fifth of a second the pilot picks one tilt and one engine setting. Land gently, upright, on a pad, before the fuel runs out. To make it hard, partway through the flight the engine secretly loses more than half its power, and the pilot is not told.
Two very different pilots.
Three ways they play together.
Why bother, if the engineer alone can land? The engineer can only do what its rules say. It cannot be taught new preferences from examples, cannot take instructions in plain language, and cannot tell you how confident it is. The trainee can do all three. The goal is a pilot with the trainee's flexibility and the engineer's guarantee that physics gets the last word. Today that pilot exists and lands; the next step is more training so the engineer has to step in less often.
The animation below shows this exact flight: the AI proposing each move, the engineer vetoing about one in three, the engine failing at ten seconds, and a soft landing at seventy.

An actual recorded flight: distilled Laya deciding from telemetry, adaptive MPC shielding it, engine thrust cut to 40% at 10 s. The shield replaced 109 of 348 proposals. See docs/media.md.
Every 0.2 s decision, adaptive MPC (recursive-least-squares thrust estimate + NumPy beam search over the nine discrete commands, ported from lunar-mpc to the arcade physics of lunar-laya) does three jobs:
A browser page runs the MPC live (click anywhere in the sky, pick a pad, add a fault, launch). With the local server it also flies both Laya checkpoints and replays every recorded decision with the model's probabilities.
| Document | Contents |
|---|---|
| How MPC and Laya combine | What each side contributes, the three couplings, why the combination is stronger than either, what a swappable proposer would change, and what comes next |
| Measured results | Landings, request following, shield activity and latency for every pilot and fault scenario, with reproduction commands |
| Distillation | Retraining Laya on MPC labels with a telemetry-only prompt: raw 1/90, shielded 90/90 with Laya's choice kept more than four fifths of the time |
| Landing lab | The browser page: live MPC, live Laya pilots through the local server, recorded flights |
| Animated flight | The GIF above: what it shows and how to regenerate it |
HF_HUB_OFFLINE=1 uv run lunar-mpc-laya-serve # http://127.0.0.1:8767/
Pilots on the page: Browser MPC (JavaScript port of the controller, no
server needed), Live Laya with MPC requests, raw or shielded, and Live
distilled Laya, telemetry only, raw or shielded. The recorded tab replays the
evaluation flights after python3 web/build.py dist/eval/*laya*.json dist/eval/mpc-assisted*.json dist/distilled-evaluation-*.json.
Without the server, python3 -m http.server 8767 --bind 127.0.0.1 --directory web
gives Browser MPC only. Details in web/README.md.
Requires the sibling ../lunar-laya checkout (path dependency, like lunar-laya
depends on laya-mlx) and uv:
git clone https://github.com/mraad/lunar-laya.git
git clone https://github.com/mraad/lunar-mpc-laya.git
cd lunar-mpc-laya
The MPC modes need
only NumPy; the Laya modes need an Apple Silicon Mac and the mlx extra.
uv sync # MPC-only environment
uv run python -m unittest discover -s tests -v
uv run lunar-mpc-laya --pilot mpc --episodes 10 --seed 3000 --thrust-scale 0.4 \
--out dist/mpc-fault.json --replay dist/mpc-fault.html
open dist/mpc-fault.html # macOS; upstream's standalone replay
uv sync --extra mlx # adds laya-mlx and MLX; never resync mid-batch
HF_HUB_OFFLINE=1 uv run lunar-mpc-laya --pilot mpc-assisted \
--model ../lunar-laya/models/lunar-laya-supervised-mlx \
--episodes 3 --seed 3000 --thrust-scale 0.4 \
--out dist/fusion.json --replay dist/fusion.html
Pilots: pd-baseline and pd-laya are upstream's PD guidance for comparison;
mpc is MPC alone; mpc-laya executes Laya's choices directly with MPC
requests in the prompt; mpc-assisted adds the predictive shield. --fixed-model
disables learning (ablation), --thrust-scale and --fault-at set the fault
(default: no fault), --switch sets the command-switching cost (0 disables the
smoothing), --margin sets the shield tolerance, --prompt-estimate
appends the engine estimate to the prompt (changes the trained template; measured
separately). uv run lunar-mpc-laya --help lists the rest.
lunar_mpc_laya/mpc.py Dynamics (scalar RLS on thrust gain) and AdaptiveMPC (beam search)
lunar_mpc_laya/pilot.py MPCPilot: upstream Pilot with MPC reference and predictive shield
lunar_mpc_laya/cli.py Session (episode loop with fault injection), upstream JSON schema and replay
lunar_mpc_laya/distilled.py telemetry prompt with the flown command, and the distilled pilot (Laya decides, MPC shields)
lunar_mpc_laya/serve.py local server: page + live /reset and /step decisions from the Python pilots
lunar_mpc_laya/gif.py documentation GIF renderer (Pillow, media extra)
scripts/evaluate.py records every pilot/scenario on seeds 3000-3009 and writes docs/results.json
scripts/parity.py records Python flights that the JavaScript port must reproduce
training/ telemetry-only prompt, MPC-labelled data, CUDA trainer, distilled pilot, MLX verification
tests/test_fusion.py dependency-free checks (fake agents; no MLX)
web/ landing lab: lander.js (physics + MPC port + shared lander art), app.js, build.py, Node test
Every 0.2 s decision the controller reads Game.snapshot() and the public map
geometry, never the fault timing or true engine multiplier. Dynamics.predict
integrates a constant command with the same ten substeps as Game.step, using
the estimated thrust gain. AdaptiveMPC.act expands every retained candidate
by the nine commands for 15 stages (3 s of foresight), keeps the best 32 by
accumulated cost, and returns the plan, the predicted path and the cost. Safe
touchdown inside the target pad is an absorbing zero-cost state; any other
contact or leaving the world is an absorbing 1000-unit penalty. The cost tracks
pad offset, a lateral-velocity reference, an angle reference, a descent-speed
reference capped by the braking distance the estimated thrust allows (0.7
design margin, as in lunar-mpc), a hold at 120 m above the pad while off it,
plus terrain clearance while off the pad and a small throttle cost.
One more term keeps the flight smooth. A beam search that re-picks freely every
0.2 s will take a different command on most decisions even when the difference
barely matters, and the tank then chatters between throttle settings while the
attitude jets flip sign. MPCConfig.switch (default 8) charges each candidate
for changing command, normalized so one knob covers both axes: half the change
in turn plus the change in throttle, each in [0, 1]. The command actually flown
last stage is what the first stage is charged against, so it is per-flight
online state and MPCPilot.reset clears it with the thrust estimate. Measured
over seeds 3000-3009 on all three pads, it cuts the command-change rate from
77.7% to 20.6% of decisions and the stage-to-stage tilt jerk from 5.37° to
1.69° RMS, with all 30 flights still landing under every fault level and fuel
use unchanged.
After each executed command Dynamics.observe compares the measured velocity
change with the prediction and updates the thrust gain by scalar recursive
least squares (forgetting 0.97, clipped to [1, 10]). Gravity and turn rate are
treated as known constants: learning gravity too made thrust and gravity
collinear during a sustained upright burn, and the estimates drifted along the
unobservable direction. Coasting, terminal and fuel-starved transitions are
excluded.
A decision covers 0.2 s, so a page that redraws only when a decision lands animates at 5 fps however smooth the trajectory is. Both canvas tabs keep the state the stage started from, mix the state it ended in back into it by the fraction of the stage the playback clock has covered, and redraw every animation frame. The drawn lander is at most one stage behind the telemetry panel; the panel itself still reports exact decision states, and the trail and predicted path are unchanged.
Lander.drawLander is one chamfered-box lander shared byte for byte with
lunar-mpc and lunar-laya, so the same vehicle appears in all three projects and
in the documentation GIF. Its coordinates are in units of RADIUS / 8 with y
pointing down, which keeps the footpads exactly one hull radius below the
centre: the game treats the hull as a circle of RADIUS and lands when
y - RADIUS reaches the surface, so the feet touch down without sinking at any
canvas size. Passing flip: -1 draws it into a y-up frame.
The replay is upstream's replay.html; its banner reads upstream's mode
(baseline/laya/assisted), so the JSON provenance keeps that field and adds
pilot (mpc, mpc-laya, mpc-assisted) and a controller block. Each
decision record keeps upstream's keys and adds plan, prediction,
plan_cost, proposal_cost, model, prediction_error and solve_ms.
uv run python -m unittest discover -s tests -v # controller, shield, fault harness, server
node --check web/app.js && node web/app.test.cjs # JS parity with Python, page behaviour
uv run python scripts/parity.py # after a controller change; a stale fixture proves the old controller
Tune a cost weight against every scenario, not the convenient one:
scripts/evaluate.py runs nominal, ×0.7 and ×0.4 together for that reason, and
results.md records both what the switching weight cost in the
sibling lunar-mpc and which seeds it was
tuned on.
This is an exploration, not a flight-certified controller. The beam search is incomplete, the cost weights are hand-picked on development seeds 0–9, the landing model checks contact at stage ends only, fuel starvation is not predicted, and the shield margin is a design choice, not a certified safe set. Some starts are unrecoverable for every pilot: for example x = 300 m heading for the right-hand pad with a ×0.4 fault crashes under MPC alone, so it crashes shielded too. The shield can only be as good as MPC's own plan. The original checkpoint receives requested labels in every prompt, so those Laya results demonstrate request following, not independent piloting, and the shield never fired. The distilled checkpoint (docs/distillation.md) decides from telemetry alone: unshielded it lands 1 of 90 flights and lands every flight shielded (90/90), with 12-17% of its proposals overridden. Appending the engine estimate to the prompt broke request following completely, so that flag is a negative result, not a feature. The fault is a single step change in main-engine thrust; sensor noise, delays and other disturbances are untested.
lunar-mpc (adaptive MPC on Box2D), lunar-laya (Laya on the arcade lander), Laya, Laya-MLX.
Apache-2.0. Copyright 2026 mraad. NOTICE records the attribution; Laya and Laya-MLX retain their upstream terms. The trained checkpoint lives in lunar-laya and is not redistributed here.