SolarApp UX Architecture

Blending MVVM patterns, SOLID principles, and MonoGame into a maintainable, testable UI layer

UX Design: Main Views & Detail Panels

Design Philosophy: SolarApp uses a hybrid MVVM/MVP pattern with MonoGame, separating business logic from rendering while maintaining 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.

MVVM in a Game Context

The Challenge

Traditional MVVM (Xamarin.Forms, WPF) uses data binding to synchronize View and ViewModel. MonoGame doesn't have a binding engine, so we adapt the pattern:

Model — Domain objects (Planet, Asteroid, ManeuverPlan)
ViewModel — Presentation logic (formatting, filtering, computed values)
View — MonoGame drawing calls and input handling
Binding — Manual update loop (call ViewModel methods each frame)

ViewModels as Presentation Controllers

SolarApp ViewModels act as presentation controllers, not just data containers:

public class BodyDetailViewModel
{
    private readonly ICelestialBodyService _bodyService;
    private readonly ISelectionManager _selection;

    public string DisplayName { get; set; }
    public string OrbitClassification { get; set; }
    public Vector3 Position { get; set; }
    public double OrbitalPeriod { get; set; }

    public void Update(DateTime currentTime)
    {
        var selected = _selection.SelectedBody;
        if (selected == null) return;

        DisplayName = selected.Name;
        Position = selected.DeterminePosition(currentTime);
        OrbitClassification = ClassifyOrbit(selected);
        OrbitalPeriod = CalculatePeriod(selected);
    }

    private string ClassifyOrbit(CelestialBody body) { /* ... */ }
}

The View simply reads these public properties and renders them:

public class BodyDetailView : IMainPanelView
{
    private BodyDetailViewModel _viewModel;

    public void Update(GameTime gameTime)
    {
        _viewModel.Update(gameTime.TotalGameTime);
    }

    public void Draw(SpriteBatch spriteBatch)
    {
        spriteBatch.DrawString(
            _font,
            $"Name: {_viewModel.DisplayName}",
            new Vector2(10, 10),
            Color.White);
        // ... more drawing
    }
}

Main View System

The main canvas supports multiple view types:

IMainPanelView Interface

public interface IMainPanelView
{
    void Update(GameTime gameTime);
    void Draw(SpriteBatch spriteBatch);
    void HandleInput(InputState input);
}

View Types

View Factory Pattern

Views are created by a factory to manage composition and dependency injection:

public interface IMainPanelViewFactory
{
    IMainPanelView CreateSolarSystemView();
    IMainPanelView CreateBodyDetailView(CelestialBody body);
}

// In Game.Initialize()
_mainView = _viewFactory.CreateSolarSystemView();

This allows:

Side Panel & Detail Resolution

The Problem: What to Show?

When the user clicks on a celestial body, what detail should appear in the side panel?

Content Resolver Pattern

Rather than a large if/switch statement, use a pluggable resolver:

public interface ISidePanelContentProvider
{
    bool CanHandle(CelestialBody body);
    Control CreateContent(CelestialBody body);
}

// Register implementations by type
_providers.Register<Planet>(new PlanetSidePanelProvider());
_providers.Register<Moon>(new MoonSidePanelProvider());
_providers.Register<Asteroid>(new AsteroidSidePanelProvider());

// In selection handler
var provider = _providers.Resolve(selectedBody.GetType());
var control = provider.CreateContent(selectedBody);

Benefits:

SOLID Principles Applied

Single Responsibility

Each class has one reason to change:

Open/Closed

Classes are open for extension (new body types), closed for modification (no changing ISidePanelContentResolver).

Liskov Substitution

Any IMainPanelView can be swapped at runtime without breaking the Game loop:

// User presses 'D' to detail view
_currentView = _viewFactory.CreateBodyDetailView(_selection.SelectedBody);
// Game.Update/Draw continues to work unchanged

Interface Segregation

Classes depend on narrow interfaces, not large base classes:

// Not: public class View : GameComponent { ... }

// Instead:
public interface IUpdatable { void Update(GameTime gt); }
public interface IDrawable { void Draw(SpriteBatch sb); }
public interface IInputHandler { void HandleInput(InputState i); }

public class BodyDetailView : IUpdatable, IDrawable, IInputHandler { ... }

Dependency Inversion

High-level modules (Game, ViewModels) depend on abstractions (ISelectionManager, IBodyService), 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;
}

Settings & Persistence

SolarApp uses a layered settings architecture:

ISettingsStore Interface

public interface ISettingsStore
{
    void SaveRenderSettings(RenderMainSettings settings);
    RenderMainSettings LoadRenderSettings();

    void SaveUxSettings(UxSettings settings);
    UxSettings LoadUxSettings();
}

public class JsonSettingsStore : ISettingsStore
{
    // Implementation using System.Text.Json
}

Settings Types

Settings are loaded at startup and persisted when changed. Each setting change triggers a UI update:

public class UxSettingsTab : ISettingsTab
{
    private ISettingsStore _store;

    public void OnQualityChanged(QualityLevel newLevel)
    {
        var settings = _store.LoadRenderSettings();
        settings.QualityLevel = newLevel;
        _store.SaveRenderSettings(settings);

        // Notify Game to regenerate assets
        _onSettingsChanged?.Invoke();
    }
}

Input Handling

Input flows through a centralized InputState object:

public class InputState
{
    public Vector2 MousePosition { get; set; }
    public bool LeftMousePressed { get; set; }
    public KeyboardState KeyboardState { get; set; }
    // ...
}

// In Game.Update()
var inputState = new InputState
{
    MousePosition = Mouse.GetState().Position.ToVector2(),
    LeftMousePressed = Mouse.GetState().LeftButton == ButtonState.Pressed,
    KeyboardState = Keyboard.GetState()
};

_mainView.HandleInput(inputState);
_topPanel.HandleInput(inputState);
_sidePanel.HandleInput(inputState);

Each view can then respond to input independently, avoiding a central input dispatcher.

Testing Strategy

The MVVM/factory pattern enables comprehensive testing:

Unit Tests

ViewModels can be tested without a Game instance:

[Test]
public void BodyDetailViewModel_WhenBodyIsSelected_DisplaysName()
{
    var mockBody = Substitute.For<CelestialBody>();
    mockBody.Name.Returns("Earth");

    var mockSelection = Substitute.For<ISelectionManager>();
    mockSelection.SelectedBody.Returns(mockBody);

    var vm = new BodyDetailViewModel(mockSelection);
    vm.Update(DateTime.Now);

    Assert.That(vm.DisplayName, Is.EqualTo("Earth"));
}

Integration Tests

View composition can be tested with mock rendering:

[Test]
public void SolarSystemView_WithAsteroid_RendersWithoutError()
{
    var view = _viewFactory.CreateSolarSystemView();
    var inputState = InputState.Empty;

    Assert.DoesNotThrow(() =>
    {
        view.Update(GameTime.Zero);
        view.Draw(null); // Or a mock SpriteBatch
    });
}

Performance Considerations

Real-time rendering demands efficiency:

Future Directions