UX Design: Main Views & Detail Panels
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:
- Main Canvas — Godot 2D rendering of the solar system (one node layer per concern, drawn in child order)
- Top Panel — Speed selector, display toggles, the Missions editor launcher, and a body search box with clickable suggestions
- Side Panel — Collapsible, context-sensitive detail view based on selection
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:
ViewModel —
SolarApp.Presentation.Mvvm.ViewModel: bindable properties + presentation formatting, engine-freeView — 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:
- Main frame (
SolarSystemView) — heliocentric: Sun at the origin, planets, the asteroid belt and free-flying craft. Scene clicks hit-test bodies, then course elements (burn nodes, trace legs, gravity-assist markers), then clear. - Planet frame (
BodyDetailView) — planetocentric: the focal planet at the origin with its moons and the craft in its sphere of influence, plus the compiled courses of anything arriving.
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?
- A Planet or Star shows bound header fields (diameter, mass, sun distance), its satellite list, and a click-to-detail 3D sphere preview
- A Spacecraft shows telemetry, the upcoming-maneuvers grid, and an Engineering drill-in pane
- A Station shows station-specific fields (crew, docking) plus Engineering
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:
- Open/Closed Principle — Adding a panel = one VM + content pair, one entry in the dispatch array, one ComponentDebug scenario
- Testability — VMs are asserted directly in scenario expectation tests; dispatch order is itself under test
- Modularity — Each content type is self-contained
SOLID Principles Applied
Single Responsibility
Each class has one reason to change:
StarshipPanelViewModel— Format craft data for displayStarshipPanel— the scene; refreshes the VM from the domain and applies it to the controlsISelectionManager— Track which body is selected
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:
- SettingsWindow — a data-driven modal: each tab is a list of field specs (label, hint, kind, min/max/step, getter, setter) over the settings models. Control events write through the setters; any
Changedevent re-seeds every control. - UxSettings / RenderMainSettings — the two settings models;
UxSettings.Changedalso drives live font re-resolution and display defaults. - JsonSettingsStore + SettingsBootstrapper — load at startup, persist on change, stored under the OS-appropriate application-data folder.
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:
- Drag pan, cursor-anchored wheel zoom, arrow keys, +/- keys
LeftClickedge event with 4 px drag-vs-click slopTogglePause(Space),Reset(R) edges consumed by the conductor
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:
- Syncing Guard: Domain → VM pushes only fire property change events for values that actually changed
- Fixed-Slot Collections: Bound lists that tick every frame (maneuver countdowns) never churn the collection
- Viewport Culling: Don't render bodies outside the camera
- Visibility Sync: Only the matched side-panel content is visible; inactive panels skip their update
Future Directions
- Mission Planner v2: v1 ships today as a side panel rather than a modal, so a plan is authored beside the map it crosses (top-bar Missions button, a craft panel's Plan / Replace buttons, or the craft's right-click / long-press menu: craft picker, step list, per-kind parameter forms, pick-on-map targets, calendar picker, live estimate). A craft holds a list of missions: a new one can be queued behind the current one — compiled from its flown end state, with the tanks as it left them — or replace it from where the craft is at that instant, and everything queued behind a replaced mission is re-planned in order. A transfer can be compared before it is committed: four candidate shapes of the same crossing, each compiled for real, showing flight time, arrival orbit, cost and whether the ship can afford it. v2 adds persistence across restarts, craft targets (Rendezvous/Dock), and the remaining phase kinds
- Interactive Maneuver Planner: Absorb the retired sandbox's porkchop UI and KSP-style node dragging into the main game (the maneuver debugger already previews node editing)
- Comets End-to-End: Load, render, select, and panel the 1,700+ comet catalog in the production UI
- Jump-to-Date: Direct date entry in the top panel (the date-edit logic is already salvaged and tested)