A browser-based 3D simulator for teaching aircraft control system design. Pick a mission, read the aircraft model and its requirements, tune a controller with sliders, run the simulation, and watch the aircraft fly the manoeuvre in 3D while the game scores what your design actually achieved.
Everything runs client-side. No server, no API, no language model, no network access at runtime. The mission content is bundled JSON; the numerics are plain TypeScript, and every aircraft model traces back to real flight-dynamics derivatives rather than a hand-tuned transfer function (see Where the aircraft models come from).
Built for two audiences at once: students and engineers who want to feel what a phase margin or an LQR weight actually does to an aeroplane, and developers who want a from-scratch, dependency-light implementation of eigenvalue solvers, Riccati equations and pole placement to read or extend.
- Node.js 18+ (developed against 22)
- A browser with WebGL2 for the 3D viewport
npm install
npm run dev # http://localhost:5173
npm test # numerics + headless render checks
npm run test:solvability # confirm every mission is actually beatable (slow)
npm run build # static bundle in dist/This project's original design spec draws a hard line: a content generator defines missions, and the game computes results. That line is enforced here.
A mission file may declare the plant, the flight condition, the command, the
limits, the requirements, the hints, the feedback strings and the scoring
weights. It may not contain an achieved settling time, an overshoot figure,
pole locations, a stability verdict or a score. validateMission fails any file
carrying a key that looks like a pre-computed result (achieved_*, actual_*,
player_score, closed_loop_poles, …), and every number the UI shows in an
"achieved" column comes from src/engine.
Feedback strings are likewise conditional by construction: they are keyed by the
condition that triggers them (unstable, excessive_overshoot, …) and are
selected at runtime from measured telemetry, never asserted by the file.
src/
types/mission.ts Mission JSON schema
missions/*.json Six missions; index.ts bundles them
engine/
linalg.ts Matrices, eigenvalues (balance → Hessenberg → shifted QR),
complex solve, Kronecker, Lyapunov
poly.ts Polynomial algebra, ss2tf (Faddeev–LeVerrier), PID/lead-lag
systems.ts State-space type, tf2ss, controllability, observability
riccati.ts CARE via matrix sign function + Kleinman polish; LQR
placement.ts Ackermann pole placement, observer placement by duality
freqresp.ts Bode sweep, gain and phase margins
controllers.ts Design (sliders → gains) and runtime (gains → commands)
simulate.ts Fixed-step RK4 closed loop, actuator/sensor/disturbance models
metrics.ts Rise, settling, overshoot, steady-state error, envelope
evaluate.ts Test battery, requirement checks, scoring, feedback selection
mission.ts Validation + compilation to SI runtime form
units.ts SI internally; degrees only at the display boundary
visualState.ts Telemetry → 3D attitude/position track
ui/ React panels, R3F viewport, procedural aircraft, HUD
tests/
run.ts Numerics against analytic results + mission validation
smoke.tsx Headless React render of every panel
solvability.ts Searches each mission's design space for a compliant design
The six missions:
| Mission | Difficulty | Model | Controllers offered |
|---|---|---|---|
| Pitch Attitude Hold: Basic Trainer | beginner | short period → transfer function | P, PD, PID |
| Roll Attitude Capture: Survey UAV | beginner | roll subsidence → transfer function | P, PD, PID |
| Pitch Attitude Command in Turbulent Air | intermediate | 4-state longitudinal | PID, pole placement, state feedback, LQR |
| Bank Angle Hold in Turbulence | intermediate | 4-state lateral-directional | state feedback, LQR |
| Pitch Command with a Single Sensor | advanced | 4-state longitudinal, attitude-only measurement | LQR |
| Coordinated Bank with Rudder Degradation | advanced | 4-state, 2-input, with in-flight failure | LQR |
Every mission's model is derived from airframe data. None contain a hand-written A matrix. The two beginner missions are still transfer functions, as the spec requires for that level, but their coefficients are computed by reducing the derived state-space model rather than being written out by hand: the trainer keeps the short-period approximation, the UAV keeps roll subsidence only.
No mission contains an A matrix. Each contains an aeroplane with geometry, mass,
inertia tensor, and non-dimensional stability derivatives, and
src/engine/aero.ts derives the state-space model from it at load time.
Missions that want a reduced model ask for one by name (short_period, roll)
and those that want a transfer function get it by collapsing the derived model,
so even a beginner SISO plant traces back to the same physics.
wing area, span, chord ISA density at altitude
mass, Ixx/Iyy/Izz/Ixz + true airspeed -> q̄ = ½ρV²
CLα, Cmα, Cmq, Cmδe, … -> dimensional derivatives
-> A, B
Longitudinal models carry the α̇ downwash lag properly (Z_ẇ on the left-hand
side of the w equation, substituted into the pitching moment), and lateral
models fold the Ixz roll–yaw coupling into primed derivatives. Gust columns
are physics too: a vertical gust of w m/s is an incidence change of w/u₀, so
E is the α column of A divided by u₀ rather than a hand-written vector that can
drift out of step with A.
This matters because it makes the models checkable. npm run test:aero
verifies the ISA against published values and every assembled model against the
classical reduced-order approximations, which are derived from the same
derivatives by a completely different route:
| Check | Agreement |
|---|---|
| Short period vs two-state reduction | within 5% |
| Roll subsidence vs primed roll damping | within 5% |
| Dutch roll vs two-state reduction | within 15–20% |
| Phugoid vs Lanchester estimate | within 30% |
| Spiral vs its approximation, and against the stability criterion | within 35%, sign must agree |
Tolerances differ by mode deliberately. The short-period and roll reductions are near-exact for a conventional aeroplane, the Lanchester phugoid is known to be loose, so demanding tight agreement there would be testing the approximation rather than the model.
It also tests the scaling laws: short-period frequency rising linearly with
airspeed, M_α halving when pitch inertia doubles, and the exact two-term
density mixture. That last one is worth spelling out, because the tempting
guess is wrong: ωₙ² = (Zα M_q/u₀) − Mα, and those terms scale as ρ² and ρ
respectively, so frequency falls faster than √ρ with altitude.
The validator additionally enforces physical consistency: CLα > 0, Cmα < 0 for static stability, Cmq < 0, Cnβ > 0 for weathercock stability, Clβ < 0 for dihedral effect, a realisable inertia tensor, geometry where span × chord is consistent with wing area, and, for longitudinal models, that the declared trim CL₀ actually equals W/(q̄S) at the stated weight, altitude and speed.
These remain teaching models: representative of their class and internally consistent, not manufacturer data.
Each run integrates the augmented [plant; actuator] state with fixed-step RK4.
Actuator position and rate limits are enforced inside the integration, not
clipped afterwards for display, so a saturated surface genuinely starves the loop
of authority. Sensor noise, bias, transport delay and sample-and-hold are applied
to the feedback signal before the controller sees it.
Pressing Run executes the test battery the mission declares:
| Test | What it does |
|---|---|
| Nominal | Commanded manoeuvre, ideal sensors, still air, actuator limits active |
| Disturbance | Same command with the mission's gusts, turbulence or failures |
| Sensor noise | Noise, bias and delay applied to the feedback signal |
| Parameter uncertainty | Worst-corner sweep over the declared ±% on A and B, gains unchanged |
Noise and turbulence are driven by a generator seeded from the mission id, so the realisation is identical between runs. A score that moves did so because the gains changed, not because the dice did.
Following stepinfo and most first-course textbooks:
- Rise time is 10% to 90% of the achieved change.
- Settling time and overshoot are measured against the response's own final value, so a slow-but-offset response is penalised through steady-state error rather than twice.
- Steady-state error is measured against the command.
- Margins are properties of the linear loop and therefore exclude actuator saturation, which is stated in the UI wherever they appear.
- Disturbance response is isolated by differencing the disturbed run against
the nominal one, not by measuring
|reference - output|. Otherwise continuous turbulence starting at t = 0 would report the commanded step as its "peak deviation". - Rate saturation is only penalised once sustained. Every step command produces a brief slew at maximum surface rate; marking that down would teach the wrong lesson. Position saturation is penalised in proportion to duration.
The 3D view is presentational. The linearised scenario models say nothing about where the aircraft is in space, so the flight path is integrated from the attitude they do produce. Three deliberate distortions, all labelled in the UI:
- Control-surface deflection is drawn 3× actual. A good design moves the elevator a few degrees, which is invisible at model scale. The HUD always prints the true deflection and the viewport footer states the exaggeration.
- Altitude is log-compressed for the ground plane's depth, because the missions span 400 m to 10 km and no linear mapping keeps both readable. Departures from the trim altitude are then amplified so a climb or descent is visible. The HUD altitude is always the true value.
- The attitude indicator is the primary instrument. A pitch ladder, bank scale and a yellow command bug make a 5° tracking error something you can see; judging that from the model silhouette alone is not realistic.
When the state leaves the range where a small-perturbation model means anything (roughly 25° of pitch, 60° of bank or 15° of angle of attack), the viewport says so explicitly rather than presenting a nose-down dive as a real prediction.
| Method | Sliders exposed |
|---|---|
| P / PI / PD / PID | Kp, Ki, Kd, derivative filter corner; anti-windup and derivative-on-measurement toggles |
| Lead-lag | Gain, zero and pole location (implemented, but no mission enables it; see below) |
| Pole placement | Damping ratio, natural frequency, non-dominant pole spread, observer speed |
| State feedback | One gain per state, plus an integral-of-error gain and observer speed |
| LQR | One Q weight per state, control weight R, integral weight, observer speed |
When a mission does not measure the full state, the observer-speed slider becomes active and a Luenberger estimator is built from the available measurement and fed to the control law.
Raw gain vectors are not exposed for LQR or pole placement, per the technical report: you set the intent (weights, or where the poles should go) and the solver returns the gains, which are displayed so you can see what your intent cost.
Three deliberate choices worth knowing about:
- Gains are synthesised against the plant alone, the way a student would work on paper. The reported closed-loop poles then include the actuator lag. That gap between what you designed for and what you got is part of the lesson.
- Slider ranges scale to the aircraft. Pole-placement frequency spans a multiple of the plant's own fastest mode, and PID gain ranges derive from its low-frequency gain. A fixed range would put almost all of the travel in the region where the surface is saturated and nothing useful happens.
- Not every method is offered on every mission. A mission only lists a
controller that can actually meet its requirements, which the solvability
sweep verifies. Two missions are restricted as a result:
advanced_observer_pitchoffers LQR alone because Ackermann's formula is numerically unusable on a plant that stiff, andintermediate_lateral_turbulencedrops pole placement because three sliders (ζ, ωₙ, spread) have to fix all five closed-loop poles, and no point in that space meets the mission's speed and actuator-authority constraints together; 0 of 560 grid points satisfied everything. Offering a method that cannot pass would be worse than omitting it.
Not currently exposed, and worth knowing before you go looking for them:
- Gain scheduling / multiple operating points. Listed as an optional extension in the original design spec. Not implemented; every mission is a single operating point.
- Lead-lag is implemented and unit-tested in the engine, but no shipped mission enables it. A grid search over the intermediate pitch mission found no first-order compensator meeting that mission's requirements; the damping floor is the binding constraint and it fails at the large majority of grid points. Adding an option that cannot pass would be worse than leaving it off, so it needs a mission written around it.
- The anti-windup limit slider suggested in the original spec is a toggle here; the tracking time constant is derived from Kp/Ki rather than set by hand.
npm test runs two suites, and npm run test:solvability runs a third:
tests/run.ts checks the numerics against values that can be derived by hand:
inverses and linear solves; eigenvalues of diagonal, complex-pair and
block-diagonal matrices; polyRoots on a factored cubic; a Lyapunov solution;
tf2ss/ss2tf round trips; LQR against the analytic scalar solution and the
double integrator's K = [1, √3]; a Riccati residual on a stable spiral;
Ackermann on a double integrator (K = [2, 2]) and pole-landing accuracy on a
3×3; phase margin of 1/(s(s+1)) and the 15.56 dB gain margin of
1/(s(s+1)(s+2)); a second-order overshoot against exp(-πζ/√(1-ζ²)). It then
validates every mission file and confirms each mission runs with each of its own
allowed controllers.
tests/reference-solutions.ts is the one that matters most day to day. For
every mission/controller pair the game offers it stores a design known to satisfy
every requirement, and asserts that it still does and that it sits inside the
slider ranges a student can actually reach. All 14 offered pairs currently pass,
scoring between 95.5 and 100. If an engine change quietly makes a mission
unbeatable, this fails in seconds rather than going unnoticed.
tests/smoke.tsx renders every panel headlessly against a real evaluated run.
tests/solvability.ts answers the question static validation cannot: are the
requirements actually achievable? It runs random-restart coordinate descent over
each mission's slider space for each of its allowed controllers, and reports both
the best reachable score and whether any design meeting every requirement
exists. A mission with no compliant design anywhere in its slider space is
unsolvable and fails the check. This is the slow, exhaustive search (tens of
minutes) used when authoring or changing a mission; the reference-solutions test
below is the fast check on the answers it finds.
This sweep earned its place. It caught, among other things:
- A phantom pole at the origin:
pidTfalways emitted thes(s+N)denominator, so a P controller carried uncancelled pole/zero pairs and every stable proportional design reported as unstable. - Ackermann silently returning an unstable design. On the advanced
four-state model the phugoid and short-period modes are two decades apart, and
augmenting for integral action pushes the controllability matrix to a
condition number near 10⁵. Ackermann returned gains of ±1180 that placed a
pole at +29.9 in the right half plane while reporting success.
ackermannnow verifies that the poles actually landed where they were asked and reports failure with an explanation when they did not, and that mission offers LQR only. - Pole-placement sliders spanning 0.2–20 rad/s regardless of aircraft, putting nearly the whole travel in the region where Ackermann returns enormous gains and the surface is pinned against its stop.
- State-feedback sliders that could not reach the answer. Their range was derived from the plant's low-frequency gain, the right reference for a PID gain acting on error, the wrong one for a gain acting on a state. On the lateral mission it collapsed the range to ±1 while a design scoring 100 needed −2, so no amount of careful tuning could reach it. The range now derives from a unit-weight LQR solution, and a test asserts the sliders can always express it.
- A disturbance metric that measured the commanded step rather than the gust,
because continuous turbulence starts at t = 0 and
|reference - output|is dominated by the manoeuvre itself. - Four missions declaring a rise-time limit while awarding no points for it, so a design could fail a stated requirement and still score full marks.
- A near-miss rounding up: category points were rounded to the nearest tenth, so a design at 99.7% of a category's maximum displayed as full marks and could total a perfect 100 while a requirement was failing. Points now floor.
- Requirement comparisons failing on floating-point noise, because degree limits round-trip through radians and a value the simulator clamped to exactly the limit came back a few ULP above it.
The sweep is a lower bound, not a proof: a "no compliant design" report means investigate, since local search can miss a compliant region. It did exactly that once, reporting the beginner roll mission unsolvable by PID when the compliant point simply sat outside its basin. This is why the search now seeds from the all-minimum corner (zeroing a controller's optional terms) before trying random restarts.
Two, both deliberate:
-
mathjsis not used, though the original technical spec recommended it. It has no continuous-time Riccati solver, no gain/phase margin routine, and unreliable eigenvalues for non-symmetric matrices. All three are core to this app. The dedicatedlinalgmodule replaces it and is unit-tested against analytic results, which the recommendation's intent (don't scatter ad-hoc matrix code) is better served by. -
Riccati is solved by the matrix sign function, not by integrating the differential Riccati equation. The DRE approach is simpler to write but needs tens of thousands of steps on a stiff model. The advanced mission has a phugoid and a short-period mode two decades apart, and LQR design took seconds, which makes a live slider preview impossible. The sign function converges in roughly a dozen iterations regardless of scaling; design synthesis now takes about 1 ms.
The remaining open questions from the original spec (3D asset style, live vs. scrubbable playback, session persistence) are resolved with that document's own stated defaults: procedural low-poly aircraft, precompute-then-scrub playback, and ephemeral sessions with no persistence.
Drop a JSON file in src/missions/ and add it to index.ts. validateMission
enforces, among other things: matrix dimensions agree with the declared state,
input and output vectors; state names carry SI unit suffixes and never degrees;
the flight condition is physically plausible; rise-time and settling-time limits
are not mutually contradictory; the settling limit fits inside the run; actuator
travel and rate limits are sane; scoring categories sum to exactly 100; three
hints exist; a disclaimer exists; and nothing in the file references a URL.
Errors block the mission from loading and are shown on its card. Warnings (for instance, a beginner mission using a state-space model) are surfaced but do not block.
The aircraft models here are simplified for education. This is not a flight-certification tool, the results are estimated rather than exact, and no controller designed here should be used on a real aircraft. Real flight control systems require verification, validation, testing, redundancy and certification.
Bug reports, mission proposals and engine improvements are welcome. If you're
adding or changing a mission, run npm run test:solvability for it before
opening a PR — an unsolvable mission is treated as a bug. If you're touching
src/engine, npm test should stay green; it checks the numerics against
values derivable by hand, not just against the app's own output.
Built by two students at Gebze Technical University — one from the software side, one from the aircraft side, which is why the engine insists on deriving every model from real stability derivatives instead of a hand-picked transfer function.
![]() Tuğberk Akbulut Computer Engineering, GTU |
![]() Mehmet Muaz Tunç Aeronautical Engineering, GTU |


