Presentationcom.zoa.frontend · v0.1.0

ZOA Frontend Shell

Everything the player sees outside gameplay: main menu, pause menu, options, save and load, and the scene flow between them.

The frontend shell owns the surfaces a game has to have and nobody wants to write twice. A main menu with resume, new game, save, load, options and quit. The same controller in pause mode, where new game disappears and quit means quit to menu. An options screen with graphics, post-processing, audio, input, gameplay and save settings. A save and load grid backed by real slot enumeration with screenshot thumbnails. A loading screen driven by real scene-load progress. And an application lifecycle that opens the menu on first load and routes an OS-initiated quit through the same confirm the in-menu button uses.

The options screen has no fixed content. IOptionsRegistrar is a contributor interface: a registrar publishes a category and enumerates its entries, the service materialises those into a runtime tree backed by a value store, and the form generator turns the tree into UI Toolkit rows. Six registrars ship in this package. A gameplay package adding its own tab implements the same interface, and the shell never learns about it.

The package splits along a Core and Unity boundary, and the Core assembly takes no engine reference at all. Option colours are therefore an OptionsColour of four floats rather than a UnityEngine.Color, and persistence sits behind IOptionsValueStore. The whole options model can be tested in plain NUnit without a play-mode harness, and the frontend can hold contracts for save slots, scene flow, thumbnails and new-game catalogs without dragging the packages that implement them into its dependencies.

Depended on by (1)

How it works

Concepts

Options arrive from contributors

A registrar is stateless metadata: a category id used in the persistence key, a display name for the tab, a tooltip, a sort order, and a Describe method returning descriptors. Values live in the service, so Describe can be called twice and must produce equivalent descriptors, and a registrar can be reconstructed freely.

RegisterRegistrar is idempotent by category id, replacing a prior registration rather than duplicating it, and the caller then invokes RebuildFromRegistrars to materialise the tree. A rebuild preserves persisted values while picking up descriptor changes, so adding an entry, removing one, or widening a slider's range takes effect without wiping what the player already set.

Descriptors are a small closed hierarchy, one sealed subclass per OptionEntryKind: slider with a range, an optional quantisation step and a display format; toggle with a default; dropdown with a label list and a default index; keybind with an action id and a default binding path; colour with a default and an allow-alpha flag. Each maps to exactly one typed accessor pair on the service, and the Kind discriminator on the change event is how a subscriber knows which one to call.

The Core assembly has no engine reference

That constraint drives several shapes in the API. OptionsColour exists because Core cannot name UnityEngine.Color, and the Unity layer converts at the rendering edge. IOptionsValueStore exists because Core cannot call PlayerPrefs, and the production adapter, an in-memory fake, and any future backend all satisfy the same interface.

Keys are composed once, by the service, in the shape ZOA.Options.{categoryId}.{entryId}, and handed to the store already complete so no backend re-derives them. The store contract requires typed slots to be independent, so a value written as a float must not be readable as a bool; the PlayerPrefs adapter achieves that by suffixing the stored key with a one-character type marker at the storage edge, while the in-memory store simply uses typed dictionaries.

The same discipline applies to the other Core contracts. ISceneFlowService, ISaveSlotService, IThumbnailService, IHudStyleService and INewGameContextProvider all describe behaviour the Unity layer implements, so the shell depends on the idea of a save backend or a level catalog without depending on the package that provides one.

One controller, two modes

ZOAMainMenuController drives both the title-screen menu and the in-game pause menu, switched by a single serialized pauseMode flag. In pause mode the new-game button is hidden, the quit button reads quit to main menu and routes through ISceneFlowService to the menu scene rather than terminating the application, and opening the menu optionally sets Time.timeScale to a configurable paused value, where zero freezes simulation and anything above it gives a slow-motion pause.

The menu's contract with its UXML is a set of element names: MenuOverlay, RootPopover, SubPopoverLayer, and the buttons BtnResume, BtnNew, BtnSave, BtnLoad, BtnSettings, BtnMultiplayer and BtnQuit. A fork can restyle the entire template freely as long as those names survive, which is how the visual polish pass added backdrop, scanline, corner-bracket and status-line chrome without touching the controller.

Opening the menu does more than change what is on screen. The controller drives both the cursor-lock bus and cursor capture, because a per-frame cursor-lock module will otherwise re-lock the cursor in the same frame it was unlocked and clicks fall straight through. It can also take exclusive ownership of the EventSystem while open, disabling others and restoring them on close, so a scene with more than one does not split UI input.

ZOAPauseMenuController is the trivial half: watch Escape, toggle the bound controller. It declines to open on top of a surface that already owns input suppression, while still allowing an open pause menu to close itself.

Scene flow is a service, the loading screen is a subscriber

ISceneFlowService wraps the async scene load and reports it as events: started, progress with a fraction, then loaded or failed, plus unloaded for the additive path. It exposes IsLoading and the current level id, and re-entrant load calls while one is in flight are ignored with a warning.

The implementation is a POCO with no Update of its own. Its host, normally LoadingScreenController, ticks it from its own Update loop so the underlying operation's progress turns into events. A driverless service keeps the test seam clean.

LoadingScreenController is then just a subscriber: it shows its document while a load is in flight, drives the progress fill and percentage, rotates a configurable tip list on an interval, and hides again on completion. The level-art slot collapses gracefully when no texture is assigned.

Soft boundaries where a hard one would be wrong

The shell needs to offer a level picker, a profile picker and a multiplayer screen, and in a project that has not installed the gameplay or networking packages those need to degrade rather than fail to compile. Both are handled by reflection against type names rather than assembly references.

ServiceBackedNewGameContextProvider reads the authored game-mode and level services out of the registry by type name and reads controller profiles from the Nucleon catalog the same way, so a release menu lists real playable content without the frontend referencing com.zoa.gameplay. StubNewGameContextProvider is the explicit test and development catalog, and release flows do not fall back to it, so a missing gameplay service surfaces as an empty picker rather than as plausible-looking sample data.

ReflectionMultiplayerMenuService does the same for networking, translating a MultiplayerMenuSessionRequest into the networking package's own session config when that package is present and reporting unavailable when it is not. GameplaySessionFlowCoordinator sits in the same category: it loads a scene through the scene-flow service and then, if a player session service happens to be registered, starts a session through that public contract.

Save slots, thumbnails, and the migration you inherit

ISaveSlotService wraps one save backend per slot behind a single contract, enumerating manual slots first and the auto-save last. The slot id format is the implementation's business and UI code must use the ids returned by Enumerate rather than guessing them. Writes are atomic in the underlying backend's sense, either fully succeeding or leaving no trace.

Thumbnails are a separate service. Keeping IThumbnailService out of the slot contract means the slot service stays thumbnail-agnostic and a future cloud-save backend does not inherit the screen-capture path. The file-backed implementation captures the screen, downscales to the card size, and writes a PNG under the persistent data path keyed by slot id.

Two forward migrations ship with the package and both are idempotent, guarded by a sentinel key so they run once. The audio migration moves the legacy per-bus volume keys onto the generic options key shape; the post-processing migration moves the motion-blur toggle from the graphics category into the post-processing category that was split out of it.

In the editor

Screens

Screenshot pending

/screenshots/main-menu.png

The title-screen menu open over the backdrop chrome, showing the root popover with the full button column and the decorative corner brackets and status line.

The main menu

Screenshot pending

/screenshots/options-screen.png

The options form on the Graphics or Audio tab, showing the tab strip with the built-in categories and several row types at once: a slider with its value readout, a dropdown, and a toggle.

The options screen

Screenshot pending

/screenshots/save-load-grid.png

The save and load view showing several slot cards, at least one populated with a screenshot thumbnail and timestamp and one empty, so both card states are visible.

The save and load grid

Screenshot pending

/screenshots/new-game-picker.png

The three-step picker on its level step, with the mode already chosen and the level list filtered to that mode's supported set, one row selected.

The New Game picker

Setup

Workflow

  1. 01

    Install the shell in the persistent scene

    Put a ZOAFrontendSceneInstaller on the persistent frontend root, alongside a ZOAMainMenuController and an ApplicationLifecycle. Leave persistSettings on for the PlayerPrefs store, leave registerBuiltInCategories on for the six shipped tabs, and set the manual slot count.

  2. 02

    Bind the menu to its template

    Assign the UIDocument, the save-card visual tree and the options and picker stylesheets on the controller. If you fork the UXML, keep the element names the controller queries: MenuOverlay, RootPopover, SubPopoverLayer and the seven Btn buttons.

  3. 03

    Add the pause path

    For an in-game pause menu, set pauseMode on a second controller instance, point mainMenuSceneId at the menu scene, and add a ZOAPauseMenuController bound to it. Decide whether opening the menu should stop time through pauseTimeWhenOpen and pausedTimeScale.

  4. 04

    Contribute your own options tab

    Implement IOptionsRegistrar for a code-driven tab, or author an OptionsCategoryDefinitionAsset and add it to the installer's additionalCategories for one that needs no code. Pick an order of 60 or above to sit after the built-ins, which occupy 10 through 50.

  5. 05

    Apply values where they matter

    Registrars that own an apply path subscribe to ValueChanged and push the new value onto the system that cares, the way the audio registrar pushes volumes onto the audio bus service. Registrars for values other systems read, such as save-slot count or the post-processing toggles, publish metadata only and let those systems read the option themselves.

  6. 06

    Wire the loading screen

    Add a LoadingScreenController next to the installer and assign its UIDocument. It ticks the scene-flow service, so a level load started from the New Game picker or from gameplay code drives real progress with no further wiring.

Surface

Key types

IOptionsService

interface

Owns the registrar set, the runtime category tree, and persistence. Typed accessors, one pair per entry kind, plus reset at three scopes and a change event the form and gameplay both subscribe to. Unity main thread only.

  • IReadOnlyList<OptionsCategory> Categories { get; }
  • OptionsCategory FindCategory(string categoryId) OptionEntry FindEntry(string categoryId, string entryId)
  • float GetFloat / bool GetBool / int GetInt / string GetString / OptionsColour GetColour / string GetKeybind
  • void SetFloat / SetBool / SetInt / SetColour / SetKeybind
  • void ResetEntry(...) void ResetCategory(...) void ResetAll()
  • void RegisterRegistrar(IOptionsRegistrar registrar) void UnregisterRegistrar(string categoryId)
  • void RebuildFromRegistrars()
  • event Action<OptionsValueChange> ValueChanged

IOptionsRegistrar

interface

The contributor interface. Publishes one category and enumerates its entries. Stateless: the service stores values, the registrar only describes them.

  • string CategoryId { get; } string CategoryDisplayName { get; }
  • string CategoryTooltip { get; } int CategoryOrder { get; }
  • IEnumerable<OptionEntryDescriptor> Describe()

IOptionsValueStore

interface

The persistence seam that keeps the Core assembly engine-free. Receives the fully composed key and must keep each typed slot independent of the others.

  • bool HasValue(string fullKey)
  • bool TryGetFloat / TryGetBool / TryGetInt / TryGetString
  • void SetFloat / SetBool / SetInt / SetString
  • void Delete(string fullKey) void Save()

OptionEntryDescriptor

class

Abstract base for the five authored entry shapes. Carries id, display name, tooltip, order and the Kind discriminator; each sealed subclass adds its own constraints.

  • SliderOptionDescriptor: MinValue, MaxValue, Step, DefaultValue, ValueFormat
  • ToggleOptionDescriptor: DefaultValue
  • DropdownOptionDescriptor: Options, DefaultIndex
  • KeybindOptionDescriptor: ActionId, DefaultBinding
  • ColourOptionDescriptor: DefaultValue, AllowAlpha

OptionEntryKind

enum

The five entry types the shell renders. Each maps to one descriptor subclass and one typed accessor pair.

  • Slider, Toggle, Dropdown, Keybind, Colour

OptionsValueChange

struct

The change event payload: which category, which entry, and which kind. Subscribers switch on Kind to pick the accessor that reads the new value.

  • string CategoryId { get; } string EntryId { get; } OptionEntryKind Kind { get; }

OptionsColour

struct

RGBA in normalised floats. Exists because the Core assembly declares no engine reference and therefore cannot name UnityEngine.Color; the Unity layer converts at the rendering edge.

  • float R, G, B, A { get; }
  • static OptionsColour White / Black / Transparent

OptionsService

service

The concrete options service, constructed against a value store and registered by the scene installer. Not a singleton: one instance per installer.

  • OptionsService(IOptionsValueStore store)

PlayerPrefsOptionsValueStore

service

The production store. Persists across launches, appending a one-character type suffix to each key so the typed slots stay independent as the contract requires.

ISaveSlotService

interface

Multi-slot save access behind one contract. Enumerates in display order with manual slots first and the auto-save last, and raises SlotChanged whenever a slot's metadata moves.

  • IReadOnlyList<SaveSlotMetadata> Enumerate() SaveSlotMetadata GetSlot(string slotId)
  • bool TryRead(string slotId, out SaveRoot root, out string reason)
  • bool TryWrite(string slotId, SaveRoot root, out string reason)
  • bool TryDelete(string slotId, out string reason)
  • void Refresh() event Action<string> SlotChanged

SaveSlotMetadata

class

What a save card shows: id, display name, whether it is the auto-save, whether it is empty, when it was written, the save version, and an optional thumbnail key.

  • string SlotId, DisplayName bool IsAutoSave, IsEmpty
  • DateTime? CreatedUtc int Version string ThumbnailKey
  • string FormatCreatedTime()

ISceneFlowService

interface

Level loading as events. Accepts a scene name, build index or path, and the Unity implementation also accepts a level id when a gameplay level service is registered, resolving it to that asset's scene.

  • bool IsLoading { get; } string CurrentLevelId { get; }
  • void LoadLevel(string sceneId) void LoadLevelAdditive(string sceneId)
  • void UnloadLevel(string sceneId) void LoadGameplay(string gameplaySceneId)
  • event Action<string> LevelLoadStarted, LevelLoaded, LevelUnloaded
  • event Action<string, float> LevelLoadProgress
  • event Action<string, string> LevelLoadFailed

IThumbnailService

interface

Capture and lookup for slot thumbnails, keyed by the same slot id the save service uses. Kept separate so the slot contract stays thumbnail-agnostic.

  • bool TryCapture(string slotId, out string path, out string reason)
  • bool TryGetPath(string slotId, out string path)
  • bool TryDelete(string slotId, out string reason)

IHudStyleService

interface

Bridges the gameplay HUD-style dropdown to consumers that repaint on it. A player's option pick overrides any genre-bundle default, and consumers map the id onto their own visual choices.

  • string CurrentStyleId { get; }
  • event Action<string> StyleChanged

INewGameContextProvider

interface

Source of the three lists the New Game picker walks. Resolved from the registry so a host can supply project-authored catalogs while the shell stays decoupled from gameplay assemblies.

  • IReadOnlyList<NewGameModeOption> GetModes()
  • IReadOnlyList<NewGameLevelOption> GetLevels()
  • IReadOnlyList<NewGameProfileOption> GetProfiles()
  • NewGameLevelOption.SupportsMode(string modeId) filters step two by step one

IMultiplayerMenuService

interface

Lets menu surfaces offer host, client, dedicated-server and local-only topologies without a compile-time dependency on a networking package, with a status read model so the UI can render truthful state.

  • enum MultiplayerMenuSessionMode { LocalOnly, Host, Client, DedicatedServer }
  • struct MultiplayerMenuSessionRequest { Mode, Address, Port, MaxPlayers, SessionName, BackendId }
  • struct MultiplayerMenuSessionStatus { IsAvailable, IsRunning, Mode, BackendId, ... }

ZOAFrontendSceneInstaller

component

The bootstrap. Registers the options service and its store, the built-in registrars, scene flow, save slots, thumbnails, HUD style, the new-game catalog and the multiplayer bridge, and disposes the registrars it owns on destroy. Added under ZOA/Frontend/Frontend Scene Installer.

  • IOptionsService Options { get; } ISceneFlowService SceneFlow { get; }
  • ISaveSlotService SaveSlots { get; } IThumbnailService Thumbnails { get; }
  • IHudStyleService HudStyle { get; } INewGameContextProvider NewGameContext { get; }
  • IMultiplayerMenuService Multiplayer { get; } void EnsureRegistered()
  • serialized: dontDestroyOnLoad, persistSettings, registerBuiltInCategories, manualSaveSlots, additionalCategories

ZOAMainMenuController

component

The menu itself, in title or pause mode. Owns the overlay, the popover stack, cursor handling and optional exclusive EventSystem ownership, and hosts the options form, the New Game picker and the save and load grid.

  • void OpenMenu() void CloseAll() bool IsOpen { get; }
  • serialized: uiDoc, saveCardAsset, optionsFormStyleSheet, newGamePickerStyleSheet
  • serialized: pauseMode, mainMenuSceneId, pauseTimeWhenOpen, pausedTimeScale
  • serialized: enforceExclusiveEventSystemOwnership, logInteractionDiagnostics

OptionsFormBuilder

class

Turns service data into UI Toolkit trees. Each row subscribes to ValueChanged so an external mutation refreshes the control, and writes back through the service on user change. Keybind rows drive Unity's interactive rebinding and store the captured path.

  • static VisualElement BuildCategoryView(IOptionsService service, string categoryId)
  • static VisualElement BuildFullForm(IOptionsService service)
  • USS contract: zoa-options-form, __scroll, __footer, zoa-options-row and its element classes

ApplicationLifecycle

component

Opens the menu on first scene load unless the scene carries a NoMainMenuOnStart marker, and intercepts an OS-initiated quit so the window close button routes through the same confirm popover the in-menu quit uses. Added under ZOA/Frontend/Application Lifecycle.

  • void NotifyConfirmedQuit()
  • serialized: menu, interceptOsQuit, altF4QuitsImmediately

LoadingScreenController

component

Renders the loading screen and ticks the scene-flow service from its own Update, turning the async operation's progress into the service's events. Added under ZOA/Frontend/Loading Screen Controller.

  • serialized: uiDoc, defaultArt, tips, tipIntervalSeconds
  • UXML element contract: LoadingProgressFill, LoadingProgressText, LoadingTip, LoadingLevelName

NotificationHudController

component

Toast feed for published game notifications, rendered as a stack of auto-expiring cards that never capture pointer input. Auto-ensures itself in play mode when a notification service is registered, so a demo scene needs no authoring. Added under ZOA/Frontend/Notification HUD.

Surface

Authoring assets

OptionsCategoryDefinitionAsset

asset

The authoring path for a category that does not need code. Carries category metadata and a list of serialized entry rows, which SODefinitionRegistrar materialises into descriptors. Reference it from the installer's additionalCategories list. Created via ZOA/Frontend/Options Category Definition.

  • string CategoryId { get; } string DisplayName { get; } string Tooltip { get; } int Order { get; }
  • IReadOnlyList<SerializedOptionEntry> Entries { get; }
  • IEnumerable<OptionEntryDescriptor> BuildDescriptors() // skips and warns on invalid rows

SerializedOptionEntry

class

One authored row. A flat discriminated shape where the kind field selects which per-kind fields are read: uglier in the Inspector than a polymorphic reference, but easier to author by hand and to round-trip in tests.

  • id, displayName, tooltip, order, kind
  • sliderMin, sliderMax, sliderDefault, sliderStep, sliderFormat
  • toggleDefault dropdownOptions, dropdownDefaultIndex
  • keybindActionId, keybindDefault colourDefault, colourAllowAlpha
  • OptionEntryDescriptor ToDescriptor()

Usage

Examples

Contributing an options category from another packagecsharp
using System.Collections.Generic;
using ZOA.Frontend.Options;

public sealed class AccessibilityOptionsRegistrar : IOptionsRegistrar
{
    public const string CategoryIdentifier = "accessibility";

    public string CategoryId => CategoryIdentifier;
    public string CategoryDisplayName => "Accessibility";
    public string CategoryTooltip => "Subtitles, colour handling, and motion comfort.";

    // Built-ins occupy 10 through 50. Pick 60 or above to sit after them.
    public int CategoryOrder => 60;

    public IEnumerable<OptionEntryDescriptor> Describe()
    {
        yield return new ToggleOptionDescriptor(
            id: "subtitles",
            displayName: "Subtitles",
            tooltip: "Show spoken dialogue as on-screen text.",
            order: 10,
            defaultValue: true);

        yield return new SliderOptionDescriptor(
            id: "subtitleScale",
            displayName: "Subtitle Size",
            tooltip: "Scale factor applied to subtitle text.",
            order: 20,
            minValue: 0.75f,
            maxValue: 2f,
            defaultValue: 1f,
            step: 0.25f,
            valueFormat: "F2");

        yield return new DropdownOptionDescriptor(
            id: "colourFilter",
            displayName: "Colour Filter",
            tooltip: "Palette adjustment for colour vision deficiency.",
            order: 30,
            options: new[] { "Off", "Protanopia", "Deuteranopia", "Tritanopia" },
            defaultIndex: 0);
    }
}

// At bootstrap, once the service is registered:
var options = FoundryServiceRegistry.Get<IOptionsService>();
options.RegisterRegistrar(new AccessibilityOptionsRegistrar());
options.RebuildFromRegistrars();  // persisted values survive the rebuild
Register is idempotent by category id. The rebuild is the caller's job, so several registrars can be added before one materialisation pass.
Reading a setting and reacting to changescsharp
using System;
using UnityEngine;
using ZOA.Frontend.Options;
using ZOA.Messaging;

public sealed class CameraFovBinding : MonoBehaviour
{
    private const string Category = "graphics";
    private const string Entry = "fieldOfView";

    private IOptionsService _options;

    private void OnEnable()
    {
        if (!FoundryServiceRegistry.TryResolve<IOptionsService>(out _options))
            return;

        _options.ValueChanged += OnValueChanged;

        // Reads return the descriptor's default when nothing is stored,
        // so there is no separate "not set yet" branch to write.
        Apply(_options.GetFloat(Category, Entry));
    }

    private void OnDisable()
    {
        if (_options != null) _options.ValueChanged -= OnValueChanged;
    }

    private void OnValueChanged(OptionsValueChange change)
    {
        if (change.CategoryId != Category || change.EntryId != Entry) return;

        // Kind tells you which typed accessor services this entry.
        if (change.Kind == OptionEntryKind.Slider)
            Apply(_options.GetFloat(Category, Entry));
    }

    private void Apply(float fov) { /* ... */ }
}
The event carries identity and kind, never a value. Subscribers read through the accessor that matches, so the payload stays a small struct.
Loading a level with progresscsharp
using UnityEngine;
using ZOA.Frontend.SceneFlow;
using ZOA.Messaging;

public sealed class LevelLauncher : MonoBehaviour
{
    private ISceneFlowService _sceneFlow;

    private void OnEnable()
    {
        if (!FoundryServiceRegistry.TryResolve<ISceneFlowService>(out _sceneFlow))
            return;

        _sceneFlow.LevelLoadStarted += id => Debug.Log("Loading " + id);
        _sceneFlow.LevelLoadProgress += (id, fraction) => Debug.Log(fraction);
        _sceneFlow.LevelLoaded += id => Debug.Log("Ready: " + id);
        _sceneFlow.LevelLoadFailed += (id, reason) => Debug.LogError(reason);
    }

    public void Launch(string sceneId)
    {
        // A second call while a load is in flight is ignored and warns,
        // so a double-clicked menu button cannot start two loads.
        if (_sceneFlow.IsLoading) return;
        _sceneFlow.LoadLevel(sceneId);
    }
}
The service does not tick itself; LoadingScreenController drives it. That keeps it a POCO with a clean test seam.
Writing a save slot with a thumbnailcsharp
using UnityEngine;
using ZOA.Frontend.SaveSlots;
using ZOA.Frontend.Thumbnails;
using ZOA.Messaging;
using ZOA.Persistence.Core.SaveData;

public sealed class SaveController : MonoBehaviour
{
    public bool SaveTo(string slotId, SaveRoot root)
    {
        var slots = FoundryServiceRegistry.Get<ISaveSlotService>();

        if (!slots.TryWrite(slotId, root, out var reason))
        {
            Debug.LogError("Save failed: " + reason);
            return false;
        }

        // Thumbnails are a separate service so the slot contract stays
        // capture-agnostic. A failed capture is not a failed save.
        if (FoundryServiceRegistry.TryResolve<IThumbnailService>(out var thumbs))
            thumbs.TryCapture(slotId, out _, out _);

        return true;
    }

    public void ListSlots()
    {
        var slots = FoundryServiceRegistry.Get<ISaveSlotService>();

        // Use the ids Enumerate returns; the id format belongs to the
        // implementation, not to the UI.
        foreach (var slot in slots.Enumerate())
        {
            Debug.Log(slot.DisplayName + " " +
                      (slot.IsEmpty ? "empty" : slot.FormatCreatedTime()));
        }
    }
}

Read this

Notes and caveats

See also