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, 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:

Model — Domain objects (Planet, Starship, MissionCompilation)
ViewModelSolarApp.Presentation.Mvvm.ViewModel: bindable properties + presentation formatting, engine-free
View — Godot Control trees (glass-themed) for panels/modals; scene layers draw the system
BindingINotifyPropertyChanged 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

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:

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 PanelDispatchTests)
Missions → 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 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:

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 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:

Future Directions