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, mission editor, 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.
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.
Main View System
The main canvas supports multiple view types behind one contract:
IMainPanelView Interface
public interface IMainPanelView
{
void Draw(RenderContext ctx); // fill the scene render target
void HandleClick(Point screenPx, Camera2D camera); // what a scene click means
}
View Types
- SolarSystemMainView — The default: orbit traces, body sprites, selection halos, and compiled spacecraft trajectories; scene clicks hit-test bodies, then course elements (burn nodes, trace legs, gravity-assist markers)
- BodyDetailMainView — Full-overlay close-up of a selected body with a large spinning sprite strip and close button
New top-level views (galactic map, scenario picker) plug in by implementing IMainPanelView — the conductor renders whatever IMainPanelHost.Current is.
Host + Factory Pattern
IMainPanelHost owns the current view and the default (system) view; per-body detail views are created on demand by a factory:
public interface IMainPanelHost
{
IMainPanelView Current { get; }
void Switch(IMainPanelView view);
void SwitchToDefault(); // back to the system view
}
public interface IBodyDetailViewFactory
{
IMainPanelView Create(ICelestialBody body);
}
This allows:
- Decoupling view creation from the Game class (the conductor never news up a view)
- Injecting shared services (camera, render context, selection manager) via DI
- Runtime view switching — the detail view's close button just calls
SwitchToDefault()
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 PanelDispatchTests)
Missions → 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 IMainPanelView can be swapped at runtime without breaking the game loop:
// Side panel sphere click → detail view
_host.Switch(_detailFactory.Create(selectedBody));
// GameBoardGame.Draw continues to work unchanged:
_mainHost.Current.Draw(ctx);
Interface Segregation
Rendering contracts are narrow and per-pass, so implementations depend only on what they use:
public interface IBodyRenderer { /* alpha-pass body draw */ }
public interface IAdditiveLightSource { /* additive glow pass */ }
public interface ISelectionDecorationRenderer { /* halo / target-lock pass */ }
// The system view iterates each DI collection per pass;
// a halo renderer never sees the additive pass contract.
Dependency Inversion
High-level modules (conductor, ViewModels) depend on abstractions (ISelectionManager, ISimulationManager), not concrete implementations:
// Bad:
public class Game
{
private JsonSettingsStore _settings = new();
}
// Good:
public class Game
{
private readonly ISettingsStore _settings;
public Game(ISettingsStore settings) => _settings = settings;
}
The composition root (Program.cs) is the only place the container is built — three registration calls (AddSolarApp, AddSolarAppRendering, AddSolarAppGameUi) wire the whole application, and GameBoardGame receives everything by constructor injection.
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 the mission editor 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 Editor v2: v1 ships today (top-bar Missions button: craft picker, step list, per-kind parameter forms, pick-on-map targets, calendar picker, runtime recompile). v2 adds persistence, 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)