Files
trail-into-darkness-demo/README.md
2026-06-18 11:45:43 +02:00

11 KiB

Trail into Darkness (Nox)

A party-based exploration RPG demo built in Unity 6 (6000.3.7f1) with URP. The player travels a world map, manages supplies and time, and resolves randomized encounters. The codebase is the focus here — the gameplay is a vertical slice meant to exercise the architecture.

This is a portfolio demo. It showcases a hand-rolled application framework, clean separation of concerns, and a set of reusable in-house packages — not a finished game.


At a Glance

  • Engine: Unity 6, Universal Render Pipeline
  • Input: new Input System
  • Asset loading: Addressables (everything content-facing is loaded by address/reference)
  • Config: data-driven via ScriptableObject settings assets
  • Language: C# 9+ (nullable enabled in core gameplay)
  • Localization, Test Framework, Memory Profiler wired in

Goals

The architecture is built around a few deliberate goals, aimed at production workflows for small-to-medium teams:

  • Scene-independent. Logic does not depend on a particular scene being set up by hand. Scenes are data; the framework boots and wires itself regardless of where you press Play.
  • Core abstracted from gameplay. Gameplay coders work inside relatively isolated, modular "feature cocoons" against clean interfaces, without needing to understand boot, platform, or state-management internals.
  • Built to extend. Adding or modifying features — new play modes, systems, or DLC-style content — should be additive and low-risk, not a rewrite.
  • Testable by design. POCO (Plain Old CLR Object — a plain C# class with no MonoBehaviour/engine dependency) logic and interface boundaries keep systems coverable by both editor tests and play-mode tests, decoupled from the engine.
  • Team-friendly. Code lives in C# and small assets rather than large monolithic scenes/prefabs, reducing the asset-locking and merge conflicts that stall multi-person teams.
  • Swappable systems. Concrete implementations sit behind interfaces (save, platform, input, camera, transitions, …) so any one system can be replaced without touching its callers.

Architecture

The project runs on a custom state machine with a single Unity update driver. Almost all gameplay logic lives in plain C# classes (POCOs) that are ticked manually — MonoBehaviour is used only where Unity forces it.

Boot flow

Boot (RuntimeInitializeOnLoadMethod)
  ├─ platform initialization (early, pre-Initializer)
  └─ instantiates "Initializer" Addressable
       └─ EntryPoint (MonoBehaviour)
            ├─ loads InitializerSettingsFile + BootstrapReferences
            ├─ selects + initializes IPlatform (Desktop / UnityEditor)
            ├─ builds GameDataState + the IGameState lookup
            └─ spawns GameStateRunner ── drives everything

Platform initialization runs in both Boot and EntryPoint. Boot establishes the platform context early so that platform-specific data is available before the Initializer loads, and EntryPoint then selects and fully initializes the concrete IPlatform for the rest of the runtime. This two-step setup guarantees the correct platform-specific data is provided at every stage of boot.

Layers

  • Boot / EntryPoint — entry point and dependency wiring. Loads settings, picks the platform, constructs the game states, hands off to the runner.
  • GameStateRunner — the only class that uses Unity's Update/FixedUpdate/LateUpdate. Ticks the active state and owns screen-fade transitions between states.
  • Game States (IGameState) — top-level application modes: BootState (splash), MainMenu, GameMode. Each implements EnterGameState / Tick / LateTick / ExitGameState / Dispose.
  • Play Modes (IPlayMode) — gameplay sub-modes living inside GameMode: Adventure, Town, Rest, Combat, PauseMenu. GameModeGameState handles switching, caching, and pause suspend/resume.

How the state machines run

There are two nested state machines, and at any moment exactly one branch of each is live:

  • GameStateRunner keeps a single active IGameState and ticks only that one. The others simply exist in a lookup and consume nothing.
  • Inside GameMode, GameModeGameState keeps a single active IPlayMode and ticks only that one. Inactive modes are kept in a cache (or suspended), dormant, until switched back in.

This keeps each part isolated and self-contained — a state or play mode owns its own setup, update, and teardown, and never reaches into another's lifecycle. It also means no resources are spent on ticks that don't matter:

  • Nothing ticks until it reports ready (IsGameStateInitialized / IsGameModeInitialized); half-loaded systems stay idle instead of running against missing data.
  • Ticks are frozen entirely during fade transitions, so nothing runs mid-swap.
  • Pausing suspends the underlying play mode rather than tearing it down — it stops ticking but keeps its state, so resume is instant and allocation-free.
  • Switching play modes reuses the cached instance when possible, avoiding reload/re-alloc churn.

The result is one predictable update per frame: one state, one play mode, with everything else parked.

Design principles

  • One update loop. All logic is driven from GameStateRunner via manual ticks — predictable order, no scattered Update() methods.
  • Constructor injection. States and play modes receive their dependencies explicitly; no service locators or singletons in gameplay.
  • Data-driven. Behavior is configured through ScriptableObject settings (*Settings, *References) loaded as Addressables, not hardcoded.
  • Interface-first. IGameState, IPlayMode, IPlatform, ICameraController, ISceneTransition, save-system interfaces — concrete types are swappable.
  • Platform abstraction. IPlatform + PlatformSelector isolate input/platform differences (Desktop vs. Editor).
  • Allocation-aware. Uses ZLinq for allocation-free LINQ on hot paths.

UI architecture

The UI follows the same philosophy as the rest of the codebase: logic lives in plain C#, MonoBehaviour is only a thin bridge to the scene. Each screen is split into two pieces:

  • *References (MonoBehaviour) — a dumb scene-authoring component that just holds serialized links to the actual widgets (buttons, images, TMP texts, containers). No logic. Examples: GuiReferences, PauseMenuReferences, MainMenuReference.
  • *View (POCO, implements IMenuView) — the view logic. It receives its references and the data it renders via constructor injection, and exposes Initialize / Show / Hide / Tick. Examples: AdventureView, PauseMenuView, PartyGuiView.

Key points:

  • Manually ticked. Views are driven by their owning play mode (e.g. AdventurePlayMode.Tick() calls adventureView.Tick()) — consistent with the single-update-loop model, no hidden Update() methods.
  • One-way coupling. UI never reaches into game logic directly; it requests changes through GameDataState (e.g. a button calls ChangePlayMode(PlayMode.PauseMenu)). State drives UI, UI signals intent.
  • Lazy, Addressable-loaded. UI prefabs are loaded by address and instantiated on demand (PauseMenuView pulls PauseMenuPrefabs); references already present in the scene are reused via FindFirstObjectByType.
  • Allocation-aware updates. Views update only what changed — AdventureView rewrites text only when day/time changes; PartyGuiView uses a fixed-size slot pool (activate/deactivate, never instantiate per frame) and rebuilds only when party composition changes.
  • Data-driven popups. Rich tooltips/popups are composed through a builder (PopupContentBuilder) backed by the Jovian.PopupSystem package, so content is declared, not hand-wired per widget.
  • Built on uGUI + TextMeshPro.

In-house packages (Packages/com.jovian.*)

Reusable systems are split into local packages, each with Runtime/Editor (and some Tests) assembly definitions:

Package Purpose
save-system Save/load with JSON or obfuscated binary, slots, file-system storage
encounter-system Encounter tables, registries, quest progress/log
zone-system Spatial zones used to trigger encounters during travel
calendar World clock, day phases, in-game time
popup-system Categorized UI popups
ingame-logging / logger In-game event log + general logging
tag-system Lightweight tagging
inspector-tools / assets-history / unitypackagesync Editor tooling
utilities Shared helpers

Third-party: ZLinq (zero-alloc LINQ), ayellowpaper.serialized-dictionary.


Project layout

Assets/
  Code/
    Core/         Boot, EntryPoint, GameStateRunner, IGameState, settings
    GameState/
      Camera/     camera controller + settings
      Entities/   characters, parties, stats, perks, modifiers (factories + registries)
      PlayModes/  Adventure, Town, Rest, Combat, Pause + encounters, map, time
      UI/         views and GUI references
    Input/        IInput, desktop/editor input + settings
    Platform/     IPlatform, desktop/editor platforms + settings
    SplashMainMenuUI/  splash + main menu views
    Util/         BootMode (editor), SceneReference editor
  Scenes/         Startup, MainMenu, Adventure, Combat
  Art/            map, UI, animations
Packages/         in-house com.jovian.* packages + third-party

Running the project

  1. Open with Unity 6000.3.7f1.
  2. Press Play — boot behavior depends on the boot mode (below). Default, play from any scene.

Boot modes — start from any scene

The project can launch from any scene. This is controlled by the Nox/Boot editor menu, which sets a BootType:

  • Full Boot — loads the Startup scene first, then the normal boot chain. Closest to a real build.
  • Scene Boot — instantiates the Initializer directly into the current scene, so you can press Play on Adventure, MainMenu, etc. and boot in place.
  • Unity Default — no boot injection; plays the scene as-is (raw Unity behavior).

In a player build, the full boot chain always runs.

Scene authoring (SceneReference)

Each standalone-playable scene carries a SceneReference component declaring its entry point:

  • gameState — which IGameState the scene belongs to (e.g. GameMode)
  • playMode — which IPlayMode to start in (e.g. Adventure)

EntryPoint reads this on boot to start in the right state. If a scene has no SceneReference, it defaults to BootState.


Notes for reviewers

  • Start with EntryPoint.csGameStateRunner.csGameModeGameState.cs to follow the control flow top-down.
  • AdventurePlayMode.cs is the most representative gameplay file — it shows how a play mode composes handlers (movement, time, encounters, inventory, UI) and integrates the in-house packages.
  • Save/load round-trips through NoxSaveData + Jovian.SaveSystem.

Status

This is a vertical slice. The application framework, boot/state flow, save system, and adventure loop are in place, but:

  • The combat system is not implementedCombat exists as a play mode hook only.
  • Design data is still minimal — zones, encounters, encounter tables, and related content are placeholders rather than authored, balanced data.

The intent is to demonstrate the architecture and systems, with content authoring and combat as the next steps.