Skip to content
Back to all projects
In progress 2026 — present · 10 min read

MSFS VFR Coach

Virtual flight instructor for Microsoft Flight Simulator 2024 — objective maneuver scoring against EASA tolerances, deterministic replay and an exam mode for German PPL students

Training add-on for Microsoft Flight Simulator 2024 that gives German PPL students a virtual flight instructor: objective maneuver scoring against EASA skill-test tolerances, German voice coaching, debriefing with replay, plus training and exam modes. An out-of-process .NET 10 app reads 30 Hz telemetry via SimConnect, detects flight phases and maneuvers through state machines, scores them against JSON-defined tolerance rules and records every session to SQLite — deterministically replayable, so the entire scoring pipeline can be tested without a running simulator. A thin in-sim panel (TypeScript, WebSocket) shows only what the server allows it to see.

01 Problem

PPL flight lessons cost €200–300 per hour, and for many students the practice between lessons happens in a home simulator — without feedback. MSFS 2024 won't tell you whether your steep turn stayed within checkride tolerances (±150 ft altitude, ±5° bank, ±10 kt airspeed), whether your slow flight was cleanly stabilised, or why the landing came down hard. Existing tools are English-only, focused on airliner IFR, or pure landing raters with no methodology. What was missing: a tool that evaluates like a flight instructor against EASA standards, coaches in German — and whose scoring is fair, i.e. doesn't punish commanded transitions (roll-in and roll-out) as deviations.

02 Solution

Out-of-process architecture following Asobo's official recommendation: an external .NET 10 desktop app connects via SimConnect, runs the phase detector, maneuver evaluator and scoring, and hosts a local WebSocket server. The in-sim panel (Coherent GT, i.e. Chrome 49) stays a thin renderer. Maneuvers are pure JSON data — entry/exit conditions, tolerance rules, German callouts — the engine is generic. Every session is recorded to SQLite at 10 Hz and deterministically drives the same pipeline again: regression tests and debriefing run without the simulator.

03 Outcome

The M0/M1 slice is in place: monorepo with CI, telemetry pipeline, phase detector, data-driven scoring engine (steep turn, slow flight, stall), SQLite recorder with deterministic replay, and the panel protocol including server-side exam mode — 47 tests green, CI with no MSFS SDK dependency. Next up: live verification against the simulator, voice output (Piper TTS), the WPF debriefing UI and the traffic-pattern/landing evaluator.

A flight instructor as a pipeline

The VFR Coach consists of an out-of-process desktop app (.NET 10) and a thin in-sim panel. The app reads telemetry via SimConnect, feeds it through a phase detector (Cold → Taxi → Takeoff Roll → Climb → Airwork/Pattern → Approach → Flare → Touchdown → Rollout — every transition debounced through a SustainedCondition) and through maneuver evaluators instantiated from JSON definitions. The panel in the simulator receives finished frames over WebSocket and renders them — nothing more.

What a maneuver is lives not in code but in packages/content/maneuvers/:

packages/content/maneuvers/steep_turn_45.json json
{
  "id": "steep_turn_45",
  "name": "Steilkurve 45°",
  "easaRef": "Skill test section 2 / Exercise 15 (Advanced Turning)",
  "entry":  { "bankAbsMinDeg": 30, "sustainSec": 2 },
  "target": { "bankDeg": 45, "turnDeg": 360 },
  "exit":   { "bankAbsMaxDeg": 10, "minTurnedDeg": 330, "timeoutSec": 240 },
  "rules": [
    { "metric": "altitude", "mode": "devFromEntry",   "tol": 150, "unit": "ft",  "weight": 0.35 },
    { "metric": "bankAbs",  "mode": "devFromTarget",  "tol": 5,   "unit": "deg", "weight": 0.25 },
    { "metric": "ias",      "mode": "devFromEntry",   "tol": 10,  "unit": "kt",  "weight": 0.20 },
    { "metric": "heading",  "mode": "rolloutVsEntry", "tol": 10,  "unit": "deg", "weight": 0.20 }
  ],
  "callouts": {
    "altitude.low":  "Du verlierst Höhe — etwas ziehen und Leistung nachführen.",
    "bankAbs.high":  "Querneigung über 50 Grad — etwas aufrichten."
  }
}

The tolerances are those of the EASA skill test, and the scoring follows the same logic: time within tolerance, peak deviation and RMS feed into a weighted score — but a sustained excursion (> 5 s outside) or a gross one (> 2× tolerance) means “failed”, no matter how good the rest was. Exactly like the examiner.

Deep dives

Capture semantics: transitions are not deviations

The hard domain problem in scoring: anyone flying a clean steep turn inevitably violates the ±5° bank tolerance while rolling in (0° → 45°) and rolling out (45° → 0°). Naive tracking would fail every perfect maneuver. The solution: target-relative rules only start counting once the target band is first reached (“capture”); after that, out-of-band time goes into a pending buffer — committed only if the value returns into the band (a genuine mid-maneuver excursion). The final roll-out never returns and is discarded: an intentional exit transition. That is the EASA philosophy in code — what’s evaluated is holding the target, not the way there.

apps/desktop/src/VfrCoach.Scoring/DeviationTracker.cs csharp
public void TrackTarget(string metric, double deviation, double tol, double dt)
{
    var acc = GetOrAdd(metric);
    acc.LastDeviation = deviation;
    var inBand = Math.Abs(deviation) <= tol;

    if (!acc.Captured)
    {
        if (!inBand) return; // still rolling in / decelerating toward the target
        acc.Captured = true;
    }

    if (inBand)
    {
        if (acc.PendingSec > 0) CommitPending(acc);
        Commit(acc, deviation, tol, dt);
    }
    else
    {
        acc.PendingSec += dt;
        acc.PendingSumSqDt += deviation * deviation * dt;
        if (Math.Abs(deviation) > acc.PendingMaxAbs) acc.PendingMaxAbs = Math.Abs(deviation);
    }
}

Entry reference via backfill

The entry condition “bank ≥ 30° for 2 seconds” only fires once the aircraft has already turned ~50° further. If the evaluator set its reference there, all devFromEntry rules (altitude, airspeed) and the rollout heading would compare against a state mid-maneuver — every score would be wrong. Instead, the Armed state buffers the last 45 seconds, and on activation the evaluator walks backwards to the last wings-level sample before the roll-in. From there, the buffered transition is replayed through the Active tracking, so turn integration and deviations start at the aerodynamically correct reference point.

apps/desktop/src/VfrCoach.Scoring/ManeuverEvaluator.cs csharp
private void Activate()
{
    var refIndex = FindEntryReferenceIndex();
    _entrySample = _armedBuffer[refIndex];
    _turnedDeg = 0;
    _activeSince = _entrySample.SimTimeSec;
    Current = State.Active;
    Started?.Invoke(new ManeuverStarted(def.Id, _entrySample.SimTimeSec));

    // Replay the buffered transition (roll-in, deceleration) through the Active
    // tracking so deviations and turn integration start at the true entry.
    _backfilling = true;
    _prev = _entrySample;
    for (var i = refIndex + 1; i < _armedBuffer.Count && Current == State.Active; i++)
    {
        var sample = _armedBuffer[i];
        var dt = sample.SimTimeSec - _prev.SimTimeSec;
        if (dt <= 0 || dt > MaxPlausibleDtSec)
        {
            _prev = sample;
            continue;
        }
        StepActive(sample);
    }
    _backfilling = false;
    _armedBuffer.Clear();
}

Deterministic replay: tests without the simulator

The replay source reads recorded samples from SQLite in sim-time order and publishes them onto the same telemetry bus that SimConnect feeds, with no wall-clock pacing. The phase detector and evaluator compute their dt from SimTimeSec — they cannot tell whether the data is live or from the database, only that the replay runs as fast as SQLite delivers rows. That eliminates the simulator as a test dependency: 47 regression tests drive real and synthetic flights through the identical scoring pipeline in milliseconds, and CI doesn’t even need the MSFS SDK installed.

apps/desktop/src/VfrCoach.Persistence/SqliteReplaySource.cs csharp
/// <summary>
/// Replays a recorded session through the telemetry bus — deterministically and as
/// fast as consumers accept (no wall-clock pacing; all downstream timing derives
/// from SimTimeSec). This is how scoring regression tests and the debriefing
/// replay work without the sim.
/// </summary>
public sealed class SqliteReplaySource(FlightDatabase db, long sessionId) : ITelemetrySource
{
    public Task RunAsync(TelemetryBus bus, CancellationToken ct)
    {
        foreach (var sample in ReadSamples())
        {
            ct.ThrowIfCancellationRequested();
            bus.Publish(sample);
        }
        bus.Complete();
        return Task.CompletedTask;
    }
}

Exam mode: stripping in the server, not the client

The in-sim panel is JavaScript inside Coherent GT — from an integrity standpoint an untrusted client that any student can edit locally. So exam mode isn’t hidden via CSS; it is enforced at exactly one point in the server: the frame builder never writes gauges, tolerances or hints into the frame in the first place, and maneuver results come back as null in exam mode — feedback only appears in the debriefing, like in the real skill test. A tampered panel cannot reveal what it never received.

apps/desktop/src/VfrCoach.Server/PanelFrameBuilder.cs csharp
/// <summary>
/// Builds the frames pushed to the panel. THE exam-mode enforcement point (4.2):
/// gauges, tolerances and hints are stripped here, server-side — a tampered panel
/// cannot show live values in exam mode because it never receives them.
/// </summary>
public static LiveFrame BuildLive(
    string? exerciseId,
    FlightPhase phase,
    IModePolicy mode,
    IReadOnlyList<LiveDeviation> liveDeviations,
    string? hint)
{
    var showLive = mode.ShowLiveGauges;
    return new LiveFrame
    {
        Exercise = exerciseId,
        Phase = phase.ToString().ToUpperInvariant(),
        Mode = mode.Mode.ToString().ToUpperInvariant(),
        AidsLevel = mode.AidsLevel.ToString().ToUpperInvariant(),
        Gauges = showLive && liveDeviations.Count > 0
            ? liveDeviations.ToDictionary(d => d.Metric, d => new GaugeDto(Round(d.Deviation), d.Tol, d.Unit))
            : null,
        Hint = showLive ? hint : null,
    };
}

Where it stands and what’s next

The M0/M1 slice is complete: monorepo with CI, telemetry pipeline, phase detector, scoring engine with three maneuvers (steep turn, slow flight, stall), SQLite recorder with deterministic replay, and the WebSocket panel protocol including exam mode — 47 tests green. The CLI host’s demo and replay commands drive synthetic and recorded flights through the entire pipeline, entirely without the simulator.

Next up are the spikes against the real sim (WebSocket behaviour in Coherent GT, object-spawning limits for the 3D aids), German voice output via Piper TTS, the WPF debriefing UI as a second head over the same modules — and the traffic-pattern/landing evaluator, the centrepiece for the target audience.

05 Key decisions

Architecture choices that paid off

01

Out-of-process instead of in-sim WASM

All the logic runs in an external .NET app, not as a WASM module inside the simulator. That is Asobo's officially recommended pattern — and it decouples the pipeline from the limits of the in-sim environment: full .NET libraries, SQLite, TTS, real debugging. The panel in the sim stays a thin WebSocket client that renders but never computes.

02

Maneuvers as data, not code

A steep turn is a JSON file: entry condition (bank ≥ 30° for 2 s), target (45°, 360° of turn), exit, weighted tolerance rules and German callouts. The evaluator is a generic state machine (Idle → Armed → Active → Done). New exercises — slow flight, stall, later the traffic pattern — are content, not an engine rebuild.

03

Deterministic replay as the testing foundation

Every session is recorded to SQLite at 10 Hz. The replay source publishes the samples onto the same telemetry bus as SimConnect, with no wall-clock pacing — all downstream timing derives from sim time. Recorded and synthetic flights run through the real scoring pipeline in milliseconds: 47 tests, no simulator, no SDK in CI.

04

Exam mode enforced server-side

In exam mode, live gauges, tolerances and hints aren't hidden in the panel — they are never written into the frame on the server in the first place. A tampered panel cannot display what it never received. Results only appear in the debriefing — just like the real checkride.

05

Capture semantics and entry backfill for fair scoring

Naive deviation tracking would fail every clean steep turn: rolling in from 0° to 45° violates the ±5° bank tolerance for seconds. Target-relative rules therefore only start counting once the target band is first reached, the roll-out is discarded as an intentional exit transition — and the entry reference is backfilled to the last wings-level state before the roll-in, because otherwise the rollout comparison measures against a heading that is already ~50° off.

06

Plain ADO instead of EF Core in the recorder hot path

The recorder buffers 64 samples and writes them as parameterised bulk inserts in a single transaction, straight through Microsoft.Data.Sqlite. EF Core with change tracking has no business in a 10 Hz hot path — it can be added later for app queries, the schema stays unchanged.

07 Lessons learned

  • 01 Sim time instead of wall clock as the single time base pays off three ways: pause, time acceleration and replay speed are automatically correct — and every consumer validates dt against a plausibility window, because sim reloads and slew otherwise produce absurd deltas.
  • 02 MSFS 2024 is an unstable runtime environment, and it's best to plan for that: SIMCONNECT_RECV_ID_QUIT arrives inconsistently, SimConnect crashes were only fixed in point releases, and object spawning reproducibly causes CTDs from ~45–60 injections. Defensive dt checks and empirically documented limits aren't overcaution — they're a necessity.
  • 03 The in-sim panel renders in Coherent GT — effectively Chrome 49 from 2016, no WebGL, with known WebSocket leaks. The right response is architectural: put as little logic in there as possible and build the panel as a thin, reconnect-capable client.
  • 04 Fairness in scoring is the real domain problem, not the tolerances themselves. EASA evaluates holding the target, not the transition to it — mapping that cleanly (capture semantics, entry backfill, a pending buffer for excursions) took more iterations than the entire rest of the pipeline.
  • 05 When borrowing code from other repos, a hard licensing line from day one pays off: MIT/Apache projects (SkyDolly, fs2ff, FsConnect) may serve as code sources, GPL/LGPL projects strictly as learning references. Untangling that after the fact would cost more than writing everything yourself.
Contact

Sound familiar?

Send me a short note about what you are dealing with. I will respond within 24 hours with an honest assessment — even if I am not the right partner.