SolarApp UX Architecture

True MVVM with engine-free view models, SOLID principles, and Godot in a maintainable, testable UI layer

UX Design: Main Views & Detail Panels

Design Philosophy: SolarApp uses genuine MVVM — engine-free view models with INotifyPropertyChanged, applied to retained-mode Godot Control trees — while the scene itself stays immediate-mode drawing for real-time performance.

Architecture Overview

The UI is organized into three spatial regions:

This structure balances information density with visual focus on the simulation itself. Every interactive surface — top bar, side panel, maneuver window, settings modal — is a retained-mode Godot Control tree, styled by a code-built "glass" theme (GlassTheme). Only the scene layers and the HUD text are immediate-mode draws.

The Glass Design Language

The map is the application. Chrome takes a column, never the screen — the ranking that follows from that settles most of the arguments. Mission planning was a 720×560 modal that dimmed everything behind it; it is a side panel now, because planning a trajectory while unable to see the trajectory is the wrong trade whatever the modal gains in room.

Tokens, not stylesheets

There is no theme resource file. GlassTheme builds one Theme in code and every root Control sets it, so children inherit: translucent slate surfaces, an azure accent (80,160,220) the interface is built on, and a type ramp that is a single function — FontPx(scale) = round(scale × 16), running from 21 px for a panel's name down to 10 px for a column header.

Two deliberate departures from the blue. Anything that opens over other chrome is fully opaque — a translucent list drawn over a moving scene is unreadable, which is the one place the glass look costs more than it buys. And a firing engine goes hot (amber 255,196,96): it is the one thing in the interface that is happening, and exhaust reads hot or it does not read as exhaust.

Reflow, then scroll, then never clip

A narrow panel answers in that order — the field grid drops a column, button rows wrap, list columns compress (they are fractions of the row, so lists spread as the panel is dragged wider), and only then does a scroll bar appear. There are no breakpoints anywhere: a grid picks its own column count from the width it is handed and a minimum cell width, because a breakpoint is a number that must be re-tuned every time the content changes and a minimum cell width is a property of the content itself.

One language everywhere

The same fact wears the same colour and shape in every window. A burn is azure while it is ahead, orange while it is burning and gray once it is flown — in the map's trace, in the side panel's step row, in the debugger's list, and in the swatch on the menu that filters them. Selection is opacity and only opacity: a picked trajectory leg brightens rather than changing colour, because a leg cannot be both cyan and orange, and recolouring it destroyed the very phase the reader selected it to check.

Say the true thing

A figure the model declined to compute shows a dash, never a zero — a zero reads as free. A best-effort capture is labelled best-effort. A transfer the ship cannot afford says so in words rather than being hidden. And where the compiler explains itself, the interface quotes it rather than paraphrasing: when two transfer options come out identical because the solver declined to locate a launch window for a craft with no departure planet, the panel repeats that sentence — which is what makes the match read as an answer instead of a bug.

MVVM in a Game Context

Real Data Binding

The view models are the part that outlived two UI toolkits. SolarApp.Presentation.Mvvm.ViewModel provides INotifyPropertyChanged with Get/Set property storage; panel contents push domain state into the VM once per frame inside a re-entrancy guard, and the Godot scene applies it. Because the VMs carry no engine types, the same classes drive the game, the panel harness, and the scenario expectation tests — the migration from MonoGame reused them byte for byte:

Model — Domain objects (Planet, Starship, MissionCompilation)
ViewModel — SolarApp.Presentation.Mvvm.ViewModel: bindable properties + presentation formatting, engine-free
View — Godot Control trees (glass-themed) for panels/modals; scene layers draw the system
Binding — INotifyPropertyChanged plus a pull-based applier: the panel scene reads the VM once per frame inside a re-entrancy guard
// Every side-panel VM derives from the engine-free bindable base
public abstract class PanelViewModelBase : ViewModel   // SolarApp.Presentation.Mvvm
{
    public string Title { get => Get<string>() ?? ""; set => Set(value); }
    public int ActivePane { get => Get<int>(); set => Set(value); }
}

// The content binds controls to VM properties once, at tree build time
Root.BindingContext = Vm;
label.SetBinding(nameof(Label.Text), nameof(Vm.SunDistance));
list.SetBinding(nameof(ListBox.Items), nameof(Vm.Satellites));

Where the Game Loop Still Matters

What remains per-frame is the domain → ViewModel push: contents refresh their VM from live simulation state once per frame inside a Syncing guard (so two-way bindings never echo a refresh back). The View is never written directly — if a value doesn't change, no property fires, and the bound controls don't churn:

public override void Update()
{
    Sync(() =>                       // Syncing guard
    {
        Vm.Title = body.Name;
        Vm.SunDistance = PanelFormat.Au(body.Position.Length());
        Vm.OrbitalDuration = PanelFormat.Days(body.OrbitalPeriodDays);
    });
}

Because the view-models are GraphicsDevice-free, the exact same VMs drive three hosts: the game, the ComponentDebug side-panel harness, and the scenario expectation tests — which construct VMs directly and assert the formatted display values without any UI at all.

A Binding Subtlety Worth Knowing

The craft panels' upcoming-maneuvers grid keeps Vm.Steps as a fixed-length slot list, blanked rather than trimmed, with ActiveSteps exposing the populated prefix. That shape was originally forced by a binding-engine constraint in the old toolkit; it survived the port because a ticking countdown that never churns the collection is simply the better design.

Frames, Not Windows

The conductor owns exactly one active frame. Say frame rather than window — the vocabulary matches the domain's own MissionCompilation.IsPlanetocentric:

Which frame a craft appears in is geometry, not a string. CraftFrameResolver rebuilds the answer every frame from orbital energy relative to the body whose SOI the craft is inside: heliocentric craft draw in the main frame, a craft passing through an SOI draws in both, and a captured craft leaves the main frame behind a count chip on its host planet. Hysteresis at the SOI boundary stops a skimming craft flickering between the two.

The earlier MonoGame build expressed this as an IMainPanelView interface with a host and a factory, because a view had to be handed a render context and asked to fill a target. In Godot a frame is a scene: the conductor shows one and hides the other, and the plumbing that used to be an abstraction is now the engine's.

Layers, not a renderer list

Inside the main frame each rendering concern is one Node2D layer, added in draw order — asteroid belt, orbit paths, velocity trails, bodies, minor bodies, course traces, craft, decorations. Child order is z-order, so a new visual concern is a new layer rather than an edit to the view. Every layer reads a per-frame ViewState struct (sim time, camera, scale, toggles, selection, screen positions, icon picks) and never writes to it.

Labels are the exception that proves the rule: layers post their label requests to a shared bus, and a single LabelsLayer added last lays the whole set out at once — so a moon's name, a ship's name and a rock's name compete for space in one pass instead of three layers each believing they are alone.

Side Panel & Detail Resolution

The Problem: What to Show?

When the user clicks on something, what detail should appear in the side panel?

Content Resolver Pattern

Rather than a large if/switch statement, each content type is a pluggable MVVM pair (ViewModel + panel scene) implementing one contract:

public interface ISidePanelContent
{
    bool Matches(object? selection);   // does this content handle the selection?
    void SetActive(bool visible);      // sync visibility to the panel state
    // ... refresh the VM from the domain, then apply it to the scene
}

// Ordered registration — first match wins (order pinned by PanelDispatchPinTest)
MissionPlanner → Missions → SystemOverview → Station → Shuttle → Starship → Sol → Comet
        → Asteroid → MinorPlanet → Planet (catch-all)

Nine panels ship today. Benefits:

SOLID Principles Applied

Single Responsibility

Each class has one reason to change:

Open/Closed

Classes are open for extension (new selectable kinds, new panels, new renderers), closed for modification — the conductor and existing panels never change when one is added.

Liskov Substitution

Any PanelContentBase can stand in for any other. The host asks each in turn whether it matches, shows the winner and refreshes it — it knows nothing about what is inside:

foreach (var content in _contents)
    if (winner is null && content.Matches(selection))
        winner = content;

_active.Visible = true;
_active.Refresh();          // domain → VM → controls

The substitution is real rather than decorative: two of the eleven contents ignore the selection entirely and match on a navigation route instead, and one of those — the mission planner — stays up while the selection changes underneath it, which is what lets a player pick a transfer target off the map mid-edit.

Interface Segregation

Contracts are narrow, so an implementation depends only on what it uses. The panels reach the game through a bundle of small delegates rather than a reference to the conductor:

// PanelServices — what a side panel is allowed to know about the game.
Func<IStarship, string?>?   CraftHostBody;   // which planet's frame a craft is in
Func<IStarship, double?>?   SurfaceGravity;  // for local thrust-to-weight
Action<ISelectableObject?>? Highlight;       // ring it, don't select it
Action<ISelectableObject>?  CenterOn;        // bring it to the middle

Each one is nullable, and every absence is a real host: the panel harness supplies no compile stack, so the mission planner is inspectable there but cannot commit — and says so, rather than failing when pressed.

Dependency Inversion

High-level modules (conductor, ViewModels) depend on abstractions (ISelectionManager, ISimulationManager), not concrete implementations:

// Bad:
public sealed class MissionExecutor
{
    private readonly JsonSettingsStore _settings = new();
}

// Good:
public sealed class MissionExecutor
{
    private readonly ISettingsStore _settings;
    public MissionExecutor(ISettingsStore settings) => _settings = settings;
}

The composition root is a Godot autoload (AppHost) — the only place the container is built, from two registration calls: AddSolarApp() for the domain stack and AddSolarAppClient() for the client services. Scenes acquire it once and cache what they need, through typed accessors rather than a service locator: when a service is used by more than one scene, it earns an accessor instead of a Resolve<T>() call site.

Settings & Persistence

Settings are edited in-game through a modal (F2) and persisted as JSON:

Adding a setting is one field spec — no new controls, no new event wiring.

Input Handling

InputHandler is the single owner of mouse/keyboard state and exposes edge events, not raw state:

Input gating rides the engine's own mouse filters: chrome controls stop their own clicks before the scene's unhandled-input handler ever sees them, so interactions never bleed into the map, and typing in the search box or in a side panel's own text field stands the keyboard shortcuts down. Scene clicks dispatch through the active view, so each view owns what a click means.

Testing Strategy

The MVVM split enables testing at three levels (xunit throughout):

ViewModel Unit Tests

ViewModels are GraphicsDevice-free, so they're constructed directly:

[Fact]
public void PlanetPanel_FormatsSunDistance()
{
    var vm = new PlanetPanelViewModel();
    vm.SunDistance = PanelFormat.Au(1.0);

    Assert.Equal("1.00 AU", vm.SunDistance);
}

Scenario Expectation Tests

The ComponentDebug harness drives each panel from scenario JSON with an expect block — the test builds the same domain objects, runs the same VM factory, and asserts every expected display value. The panel dispatch order itself is pinned by PanelDispatchTests.

Integration Tests

Every mission scenario in the catalog compiles through the real solver stack in SolarApp.Scenarios.Tests, with numeric assertions on Δv totals, pass distances, and final orbits.

Performance Considerations

Real-time rendering demands efficiency:

Future Directions