First-run onboarding: contextual coach-marks + How-to-Play card + dev replay toggle

Teaches the deep, interlocking loop — especially the inverted win condition
(you win by CLEARING EXPEDITIONS, not by surviving base sieges; DR-042/DR-043).

- OnboardingSystem: client-only observe-only PresentationSystemGroup overlay
  (own UIDocument @ sortingOrder 60), soft-gated 10-beat coach-mark sequence
  with a world-space ▶ pointer; never mutates sim / never destroys a ghost.
- OnboardingStepMath: pure, unit-tested step machine (snapshot + IsSatisfied +
  scheme-aware prompts + pointer kinds + persisted-mask helpers).
- HowToPlayPanel: tabbed reference card (Controls / The Loop / Build / Threats /
  Win-Lose), reachable from the main menu and the pause overlay.
- Per-client client-local state in GameSettings (TutorialHints + OnboardingMask
  bitmask, additive) — a Join client keeps its own; a host save-wipe never
  re-teaches. Settings toggle + menu "Replay Tutorial".
- Dev "Force Each Launch" toggle (GameSettings.ForceOnboardingEachLaunch):
  SettingsService.Boot wipes the mask + forces hints on in-memory every launch
  so the tutorial always replays fresh.
- HudSystem suppresses its own location hint while onboarding is active
  (single prompt voice), via OnboardingState + [UpdateAfter(OnboardingSystem)].

Validated green: 20/20 EditMode; Play smoke confirmed overlay render, clean
U+25B6 pointer glyph, no system sort-cycle, and the force-wipe end-to-end.

Docs: DR-043 + session log; reusable lesson archived in the build-gotchas note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luis Gonzalez
2026-06-29 14:18:22 -07:00
parent 3bb9999173
commit 29e90a5008
20 changed files with 1058 additions and 9 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0861914135cacf948ae2adfd7f7d6870
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Tiny static coordination bridge for the first-run onboarding overlay. <see cref="Active"/> is true while a
/// coach-mark step is on screen (set each frame by <see cref="OnboardingSystem"/>); <see cref="HudSystem"/>
/// reads it to suppress its own ad-hoc location/gate hint so the player ever sees a single prompt voice.
/// A presentation-layer static, so it is RESET on play-enter (the CLAUDE.md stale-static rule) to avoid a
/// stale flag surviving a fast-enter-playmode domain reload and leaving the HUD hint suppressed.
/// </summary>
public static class OnboardingState
{
/// <summary>True while the coach-mark sequence is the active prompt voice (a step is being shown).</summary>
public static bool Active;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnPlayEnter() => Active = false;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e9b3bb074eef1c40b90591e85b90e32
@@ -0,0 +1,131 @@
using ProjectM.Simulation;
namespace ProjectM.Client
{
/// <summary>
/// Pure, engine-free logic for the first-run onboarding coach-mark sequence — the testable core of
/// <see cref="OnboardingSystem"/> (mirrors the project's <c>*Math</c> helper discipline; no UnityEngine /
/// Entities types so it unit-tests as plain C#). Defines the ordered step list, a <see cref="Snapshot"/> of the
/// observable client state each step reads, the deterministic per-step completion test, prompt copy, the
/// spatial-cue kind, and the persisted-mask helpers.
///
/// Pacing (operator-locked = soft-gated): a step shows until its action is performed (no per-step timeout),
/// EXCEPT two info beats — <see cref="Fabricator"/> and <see cref="Defend"/> — which also auto-advance, plus
/// the timed <see cref="Welcome"/> strip. Veteran / co-op auto-suppress falls out for free: the count-based
/// steps (<see cref="Build"/>, <see cref="Fabricator"/>) test an ABSOLUTE structure count, so a client joining
/// an already-built base satisfies them on entry and skips straight past.
/// </summary>
public static class OnboardingStepMath
{
// ---- ordered steps (byte ids; bit i of GameSettings.OnboardingMask = step i complete) ----
public const byte Welcome = 0; // tiny win-condition framing strip (timed)
public const byte Move = 1;
public const byte Mine = 2; // attack an Ore node — mining IS combat at the base (Calm = no enemies yet)
public const byte Build = 3; // open palette + place a Turret
public const byte Fabricator = 4; // Ore -> Charge (soft info beat)
public const byte Gate = 5; // reach the Expedition Gate
public const byte Clear = 6; // clear the expedition wave (the first real enemies)
public const byte Return = 7; // walk back to base to bank +1 charge
public const byte Defend = 8; // survive the retaliation siege (soft info beat)
public const byte Done = 9; // closing beat
public const byte StepCount = 10;
// ---- tunable thresholds (public so the EditMode tests pin the contract) ----
public const float WelcomeSeconds = 5f;
public const float MoveThreshold = 3f; // accumulated player movement (world units)
public const float FabricatorSoftSeconds = 14f; // soft beat auto-advance if no Fabricator built
public const float DefendNoSiegeSeconds = 20f; // advance if no siege ever materialises
public const float DoneSeconds = 6f; // closing beat lingers before going dormant
// ---- spatial-cue kinds the System resolves to a live world target ----
public const byte PointerNone = 0;
public const byte PointerOreNode = 1;
public const byte PointerBaseGate = 2; // the base-region gate (go to the expedition)
public const byte PointerExpeditionGate = 3; // the expedition-region gate (return home)
/// <summary>Observable client state for one evaluation. Built by the System from ECS + input each frame.</summary>
public struct Snapshot
{
public float StepElapsed; // seconds the current step has been shown
public float MoveDistance; // accumulated player movement since the Move step began
public int OreNow; // shared-ledger Ore right now
public int OreBaseline; // ledger Ore captured when the Mine step began
public int TurretCount; // live Turret structures (absolute)
public int FabricatorCount; // live Fabricator structures (absolute)
public bool OnExpedition; // local player is in the expedition region
public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared)
public bool SawSiege; // a Siege phase was observed while the Defend step was showing
public byte Phase; // CycleState.Phase (Calm/Siege)
}
/// <summary>True when the step's taught action is complete (or its soft timeout has elapsed).</summary>
public static bool IsSatisfied(byte step, in Snapshot s)
{
switch (step)
{
case Welcome: return s.StepElapsed >= WelcomeSeconds;
case Move: return s.MoveDistance >= MoveThreshold;
case Mine: return s.OreNow > s.OreBaseline;
case Build: return s.TurretCount >= 1;
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
case Gate: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
case Clear: return s.ObjectiveState == ExpeditionObjectiveState.Cleared;
case Return: return !s.OnExpedition; // entered while on expedition; satisfied on crossing home
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
case Done: return s.StepElapsed >= DoneSeconds;
default: return true;
}
}
/// <summary>Which world target (if any) the prompt should point at this step.</summary>
public static byte PointerKind(byte step)
{
switch (step)
{
case Mine: return PointerOreNode;
case Gate: return PointerBaseGate;
case Return: return PointerExpeditionGate;
default: return PointerNone;
}
}
/// <summary>Ultra-short, verb-first prompt copy with the player's real input glyph (scheme-aware).</summary>
public static string Prompt(byte step, bool gamepad)
{
string move = gamepad ? "Left Stick" : "WASD";
string attack = gamepad ? "RT" : "LMB";
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
switch (step)
{
case Welcome: return "CLEAR EXPEDITIONS to charge the Engine — defend the Core while you do. (Esc → Pause → How to Play)";
case Move: return move + " — Move";
case Mine: return attack + " — Attack the glowing Ore to mine it";
case Build: return build + " — open Build, then place a Turret by your Core";
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
case Gate: return "Reach the Expedition Gate — clearing it charges the Engine";
case Clear: return "Clear the zone — defeat every enemy";
case Return: return "Return through the gate — bank your clear (+1 Engine charge)";
case Defend: return "Defend the Core! — hold the line through the siege";
case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
default: return "";
}
}
// ---- persisted-mask helpers (GameSettings.OnboardingMask) ----
/// <summary>All steps complete (the sequence is dormant).</summary>
public static bool AllComplete(int mask)
{
int all = (1 << StepCount) - 1;
return (mask & all) == all;
}
/// <summary>The lowest not-yet-completed step (resume point); <see cref="Done"/> when all are complete.</summary>
public static byte FirstIncomplete(int mask)
{
for (byte i = 0; i < StepCount; i++)
if ((mask & (1 << i)) == 0) return i;
return Done;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c264496096436e74ebba163a7a5d2205
@@ -0,0 +1,321 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// First-run onboarding overlay — a CLIENT-ONLY, observe-only presentation <see cref="SystemBase"/> in
/// <see cref="PresentationSystemGroup"/> (same shape/constraints as <see cref="HudSystem"/>: never mutates the
/// sim, never destroys a ghost, reads already-replicated state once per frame). Owns its own runtime UIDocument
/// (sortingOrder 60 — above the HUD's 50, below the pause overlay's 100) showing a single bottom-center
/// coach-mark prompt plus a world-space directional pointer.
///
/// The sequence is PER-CLIENT and client-local: progress lives in <see cref="GameSettings.OnboardingMask"/>
/// (via <see cref="SettingsService"/>), keyed to THIS player's own first-encounter — so a veteran host sees
/// nothing, a brand-new join-client is still taught, and a save wipe never re-teaches the host (the mask is in
/// settings.json, not the host-only SaveData). Soft-gated pacing: a step shows until its action is done; the
/// pure rules + auto-suppress (absolute count checks) live in <see cref="OnboardingStepMath"/>.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class OnboardingSystem : SystemBase
{
const float ExpeditionRegionXMin = 500f; // player x past this = the +1000 expedition region (mirrors HudSystem)
GameObject _go;
UIDocument _doc;
bool _built;
Label _prompt;
Label _pointer;
// step machine (in-memory; persisted to the mask on each completion)
bool _maskLoaded;
int _mask;
byte _step;
bool _stepInit;
float _stepElapsed;
float _moveAccum;
float3 _lastPos;
int _oreBaseline;
bool _sawSiege;
protected override void OnStartRunning()
{
if (_go != null) return;
_go = new GameObject("~Onboarding");
_doc = _go.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 60; // above HUD (50), below pause (100)
}
protected override void OnDestroy()
{
OnboardingState.Active = false; // never let the static outlive its owning system (HUD suppression)
if (_go != null) Object.Destroy(_go);
}
protected override void OnUpdate()
{
if (_doc == null) return;
var root = _doc.rootVisualElement;
if (root == null) return;
if (!_built) { BuildTree(root); _built = true; }
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
// ---- local player presence + position ----
bool havePlayer = false; float3 playerPos = default;
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{ havePlayer = true; playerPos = lt.ValueRO.Position; break; }
var settings = SettingsService.Current;
if (!_maskLoaded)
{
_mask = settings.OnboardingMask;
_step = OnboardingStepMath.FirstIncomplete(_mask);
_stepInit = false;
_maskLoaded = true;
}
bool hintsOn = settings.TutorialHints != 0;
// Dormant (hints off / all steps done) or no local player yet → fully hidden, no voice.
if (!hintsOn || OnboardingStepMath.AllComplete(_mask) || !havePlayer)
{
OnboardingState.Active = false;
root.style.display = DisplayStyle.None;
return;
}
// ---- remaining observable state ----
int ore = LedgerOre();
CountStructures(out int turrets, out int fabs);
byte phase = CyclePhase.Calm;
if (SystemAPI.TryGetSingleton<CycleState>(out var cyc)) phase = cyc.Phase;
byte objState = ExpeditionObjectiveState.Idle;
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj)) objState = obj.State;
bool onExp = playerPos.x > ExpeditionRegionXMin;
// ---- per-step entry init (baselines) ----
if (!_stepInit)
{
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false;
_oreBaseline = ore; _lastPos = playerPos;
_stepInit = true;
}
// ---- advance (FROZEN while the pause overlay is open, so the timed beats — Welcome/Fabricator/
// Defend/Done — aren't silently lost behind the pause dim that sits above this overlay) ----
if (!PauseMenuController.Open)
{
_stepElapsed += dt;
if (_step == OnboardingStepMath.Move) _moveAccum += math.distance(playerPos, _lastPos);
if (_step == OnboardingStepMath.Defend && phase == CyclePhase.Siege) _sawSiege = true;
var snap = new OnboardingStepMath.Snapshot
{
StepElapsed = _stepElapsed,
MoveDistance = _moveAccum,
OreNow = ore,
OreBaseline = _oreBaseline,
TurretCount = turrets,
FabricatorCount = fabs,
OnExpedition = onExp,
ObjectiveState = objState,
SawSiege = _sawSiege,
Phase = phase,
};
// The two pure-message beats can be dismissed with any input EXCEPT Esc (Esc opens Pause; see
// AnyInputPressed) so following the "Esc → Pause → How to Play" hint doesn't self-skip the framing.
bool skip = (_step == OnboardingStepMath.Welcome || _step == OnboardingStepMath.Done) && AnyInputPressed();
if (skip || OnboardingStepMath.IsSatisfied(_step, snap))
{
_mask |= (1 << _step);
Persist(_mask);
_step = OnboardingStepMath.FirstIncomplete(_mask); // auto-suppressed steps cascade one/frame
_stepInit = false;
if (OnboardingStepMath.AllComplete(_mask))
{
OnboardingState.Active = false;
root.style.display = DisplayStyle.None;
return;
}
}
}
_lastPos = playerPos;
// ---- show the current step ----
OnboardingState.Active = true;
root.style.display = DisplayStyle.Flex;
bool gamepad = AimPresentation.Scheme == InputSchemeId.Gamepad;
_prompt.text = OnboardingStepMath.Prompt(_step, gamepad);
UpdatePointer(_step, playerPos);
}
// ---- state gathering helpers ----
int LedgerOre()
{
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var e))
{
var buf = SystemAPI.GetBuffer<StorageEntry>(e);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count;
}
return 0;
}
void CountStructures(out int turrets, out int fabs)
{
turrets = 0; fabs = 0;
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
{
byte t = ps.ValueRO.Type;
if (t == StructureType.Turret) turrets++;
else if (t == StructureType.Fabricator) fabs++;
}
}
void Persist(int mask)
{
var s = SettingsService.Current;
s.OnboardingMask = mask;
SettingsService.Save(s); // atomic write; ~once per completed step
}
static bool AnyInputPressed()
{
var kb = UnityEngine.InputSystem.Keyboard.current;
// any key dismisses a message beat — EXCEPT Esc, which is the pause key (don't self-skip the framing).
if (kb != null && kb.anyKey.wasPressedThisFrame && !kb.escapeKey.wasPressedThisFrame) return true;
var ms = UnityEngine.InputSystem.Mouse.current;
if (ms != null && ms.leftButton.wasPressedThisFrame) return true;
var gp = UnityEngine.InputSystem.Gamepad.current;
if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true;
return false;
}
// ---- world-space pointer ----
bool ResolveTarget(byte kind, float3 playerPos, out float3 target)
{
target = default;
if (kind == OnboardingStepMath.PointerOreNode)
{
float best = float.MaxValue; bool found = false;
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ResourceNode>())
{
float d = math.distancesq(lt.ValueRO.Position, playerPos);
if (d < best) { best = d; target = lt.ValueRO.Position; found = true; }
}
return found;
}
// base gate (go) lives in the base region; expedition gate (return) lives past the region split.
bool wantBase = kind == OnboardingStepMath.PointerBaseGate;
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ExpeditionGate>())
{
var p = lt.ValueRO.Position;
if ((p.x < ExpeditionRegionXMin) == wantBase) { target = p; return true; }
}
return false;
}
void UpdatePointer(byte step, float3 playerPos)
{
byte kind = OnboardingStepMath.PointerKind(step);
var cam = Camera.main;
var root = _doc.rootVisualElement;
if (kind == OnboardingStepMath.PointerNone || cam == null || !ResolveTarget(kind, playerPos, out float3 target))
{ _pointer.style.display = DisplayStyle.None; return; }
float pw = root.layout.width, ph = root.layout.height;
if (pw <= 1f || ph <= 1f) { _pointer.style.display = DisplayStyle.None; return; }
Vector3 sp = cam.WorldToScreenPoint((Vector3)target);
bool behind = sp.z < 0f;
float px = (sp.x / Mathf.Max(1f, Screen.width)) * pw;
float py = (1f - sp.y / Mathf.Max(1f, Screen.height)) * ph;
if (behind) { px = pw - px; py = ph - py; }
const float margin = 64f;
bool off = behind || px < margin || px > pw - margin || py < margin || py > ph - margin;
float cx = pw * 0.5f, cy = ph * 0.5f;
float dx = px - cx, dy = py - cy;
float len = Mathf.Sqrt(dx * dx + dy * dy);
if (len < 0.001f) { dx = 1f; dy = 0f; len = 1f; }
float ndx = dx / len, ndy = dy / len;
float ax, ay;
if (off)
{
// intersect the center→target ray with the margin rectangle (edge arrow)
float tx = (ndx > 0 ? (pw - margin - cx) : (margin - cx)) / (Mathf.Abs(ndx) < 1e-4f ? (ndx < 0 ? -1e-4f : 1e-4f) : ndx);
float ty = (ndy > 0 ? (ph - margin - cy) : (margin - cy)) / (Mathf.Abs(ndy) < 1e-4f ? (ndy < 0 ? -1e-4f : 1e-4f) : ndy);
float tt = Mathf.Min(Mathf.Abs(tx), Mathf.Abs(ty));
ax = cx + ndx * tt; ay = cy + ndy * tt;
}
else { ax = px; ay = py - 44f; } // float just above the on-screen target
float angle = Mathf.Atan2(dy, dx) * Mathf.Rad2Deg; // "▶" art points +x at 0°
_pointer.style.left = ax - 15f;
_pointer.style.top = ay - 18f;
_pointer.style.rotate = new StyleRotate(new Rotate(new Angle(angle)));
_pointer.style.display = DisplayStyle.Flex;
}
// ---- UITK construction ----
void BuildTree(VisualElement root)
{
root.style.position = Position.Absolute;
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
root.pickingMode = PickingMode.Ignore; // never eat world clicks
var panel = new VisualElement();
panel.style.position = Position.Absolute;
panel.style.bottom = 210; panel.style.left = 0; panel.style.right = 0;
panel.style.flexDirection = FlexDirection.Row;
panel.style.justifyContent = Justify.Center;
panel.style.alignItems = Align.Center;
panel.pickingMode = PickingMode.Ignore;
var chip = new VisualElement();
chip.style.backgroundColor = new Color(0.05f, 0.07f, 0.10f, 0.92f);
chip.style.paddingLeft = 22; chip.style.paddingRight = 22;
chip.style.paddingTop = 10; chip.style.paddingBottom = 10;
chip.style.maxWidth = 920;
chip.pickingMode = PickingMode.Ignore;
MenuUi.Round(chip, 8);
MenuUi.Border(chip, new Color(MenuUi.Accent.r, MenuUi.Accent.g, MenuUi.Accent.b, 0.55f), 1);
_prompt = new Label(string.Empty);
_prompt.style.color = MenuUi.TextCol;
_prompt.style.fontSize = 18;
_prompt.style.unityFontStyleAndWeight = FontStyle.Bold;
_prompt.style.unityTextAlign = TextAnchor.MiddleCenter;
_prompt.style.whiteSpace = WhiteSpace.Normal;
var theme = HudTheme.Get();
if (theme != null) theme.ApplyBody(_prompt.style);
chip.Add(_prompt);
panel.Add(chip);
root.Add(panel);
_pointer = new Label("▶"); // ▶ right-pointing triangle (rotated toward the target)
_pointer.style.position = Position.Absolute;
_pointer.style.fontSize = 30;
_pointer.style.color = MenuUi.Accent;
_pointer.style.unityFontStyleAndWeight = FontStyle.Bold;
_pointer.pickingMode = PickingMode.Ignore;
_pointer.style.display = DisplayStyle.None;
root.Add(_pointer);
root.style.display = DisplayStyle.None;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b4828f5a68386fa4da379dcddbf629de
@@ -22,6 +22,7 @@ namespace ProjectM.Client
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
[UpdateAfter(typeof(OnboardingSystem))] // read OnboardingState.Active same-frame (single prompt voice)
public partial class HudSystem : SystemBase
{
// ---- palette (Aether language; Synty white skins are tinted into these) ----
@@ -290,6 +291,9 @@ namespace ProjectM.Client
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
_locationText.style.color = new Color(1f, 0.3f, 0.25f);
}
// First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own
// location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay).
if (OnboardingState.Active) _locationText.text = "";
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ----
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
{
@@ -12,7 +12,7 @@ namespace ProjectM.Client
[Serializable]
public struct GameSettings
{
public const int CurrentVersion = 1;
public const int CurrentVersion = 2;
public int Version;
@@ -30,6 +30,13 @@ namespace ProjectM.Client
public float Music;
public float Sfx;
// ---- Onboarding (client-local first-run state; NEVER replicated — a Join client keeps its own,
// unlike the host-only SaveData) ----
public int TutorialHints; // 0 = first-run coach-marks off, 1 = on
public int OnboardingMask; // bitmask of completed coach-mark steps (0 = nothing seen; bit i = step i done)
public int ForceOnboardingEachLaunch; // DEV: 1 = wipe OnboardingMask + force hints on at every launch so the
// first-run coach-marks always replay fresh (additive; 0-default off)
/// <summary>Sensible defaults derived from the current display + active quality level.</summary>
public static GameSettings Defaults()
{
@@ -47,6 +54,9 @@ namespace ProjectM.Client
Master = 1f,
Music = 1f,
Sfx = 1f,
TutorialHints = 1,
OnboardingMask = 0,
ForceOnboardingEachLaunch = 0,
};
}
@@ -65,6 +75,9 @@ namespace ProjectM.Client
s.Master = Mathf.Clamp01(s.Master);
s.Music = Mathf.Clamp01(s.Music);
s.Sfx = Mathf.Clamp01(s.Sfx);
s.TutorialHints = s.TutorialHints != 0 ? 1 : 0;
s.ForceOnboardingEachLaunch = s.ForceOnboardingEachLaunch != 0 ? 1 : 0;
// OnboardingMask is an opaque bitmask — deliberately NOT clamped.
return s;
}
}
@@ -22,6 +22,18 @@ namespace ProjectM.Client
static void Boot()
{
Load();
// DEV convenience ("Force Each Launch", Settings → Onboarding): wipe the persisted completed-step mask at
// EVERY boot — each editor Play-enter / each built-player launch runs this hook — so the first-run
// coach-marks always replay from the top, and force hints on so they actually show. In-memory only (the
// wipe is NOT written back to disk); OnboardingSystem re-persists progress as the player advances, and the
// next launch wipes it again. Toggle it off to return to normal once-only first-run behaviour.
if (Current.ForceOnboardingEachLaunch != 0)
{
var s = Current;
s.OnboardingMask = 0;
s.TutorialHints = 1;
Current = s;
}
Apply(Current);
}
@@ -103,14 +115,21 @@ namespace ProjectM.Client
// Additive-only as the schema grows (never throws on an unknown version).
static GameSettings Migrate(GameSettings old)
{
var def = GameSettings.Defaults();
if (old.ResWidth > 0) def.ResWidth = old.ResWidth;
if (old.ResHeight > 0) def.ResHeight = old.ResHeight;
if (old.Master > 0f) def.Master = old.Master;
if (old.Music > 0f) def.Music = old.Music;
if (old.Sfx > 0f) def.Sfx = old.Sfx;
def.Version = GameSettings.CurrentVersion;
return def;
// Preserve EVERY recognized field from the old save (Load() Clamps the result, fixing any 0/garbage),
// and seed ONLY the genuinely-new fields — a v1 file deserializes those to 0. Migrating from a fresh
// Defaults() instead would silently reset graphics fields (display mode / quality / v-sync / fps cap /
// refresh rate) that v1 already carried — a regression the version bump would otherwise surface.
var s = old;
if (old.Version < 2)
{
// v1 had no onboarding fields. An existing settings.json ⇒ a returning player who already played a
// pre-onboarding build, so mark every coach-mark step complete (dormant); hints stay on so
// "Replay Tutorial" (which clears OnboardingMask) still re-arms.
s.TutorialHints = 1;
s.OnboardingMask = int.MaxValue;
}
s.Version = GameSettings.CurrentVersion;
return s;
}
}
}
@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using ProjectM.Simulation;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// The replayable "How to Play" reference card (UI Toolkit), built on <see cref="MenuUi"/> like
/// <see cref="SettingsScreen"/> and reachable from BOTH the main menu and the in-game pause overlay. Tabbed,
/// one glanceable page each: Controls (for the chosen class), The Loop (the annotated win-condition diagram —
/// the single highest-value page, since the inverted goal "clear expeditions to win" is the #1 new-player
/// confusion), Build &amp; Economy, Threats, Win/Lose. Static + stateless: <see cref="Build"/> returns a fresh
/// full-screen panel each call; the caller owns its lifetime (RemoveFromHierarchy on close).
/// </summary>
public static class HowToPlayPanel
{
static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats", "Win / Lose" };
public static VisualElement Build(Action onClose)
{
var root = MenuUi.FullScreenRoot(true);
var card = MenuUi.Card("HOW TO PLAY");
card.style.minWidth = 640;
card.style.maxWidth = 780;
root.Add(card);
var tabBar = new VisualElement();
tabBar.style.flexDirection = FlexDirection.Row;
tabBar.style.justifyContent = Justify.Center;
tabBar.style.marginBottom = 12;
card.Add(tabBar);
var content = new VisualElement();
content.style.minHeight = 230;
card.Add(content);
var tabButtons = new List<Button>();
void Show(int idx)
{
content.Clear();
BuildTab(content, idx);
for (int i = 0; i < tabButtons.Count; i++)
tabButtons[i].style.backgroundColor = i == idx
? new Color(0.16f, 0.34f, 0.42f, 1f)
: new Color(0.16f, 0.20f, 0.27f, 1f);
}
for (int i = 0; i < Tabs.Length; i++)
{
int idx = i;
var b = MenuUi.Button(Tabs[i], () => Show(idx));
b.style.height = 32; b.style.fontSize = 13;
b.style.marginLeft = 3; b.style.marginRight = 3;
b.style.flexGrow = 1;
tabButtons.Add(b);
tabBar.Add(b);
}
card.Add(MenuUi.Button("Back", () => onClose?.Invoke()));
Show(0);
return root;
}
static void BuildTab(VisualElement c, int idx)
{
switch (idx)
{
case 0: // Controls (chosen class)
bool ranger = WorldLauncher.SelectedClass == (byte)CharacterId.Ranger;
Head(c, ranger ? "CLASS: Ranger (ranged anchor)" : "CLASS: Warrior (melee anchor)");
Body(c, "Move — WASD / Left Stick");
Body(c, "Aim — Mouse cursor / Right Stick");
Body(c, "Attack — LMB / RT (your primary verb)");
Body(c, "Build menu — Tab / Y");
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
Body(c, "Deposit at base — G");
Body(c, "Inventory — I");
Body(c, "Pause — Esc");
break;
case 1: // The Loop
Head(c, "THE LOOP — expeditions are how you win");
Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core.");
Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat.");
Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter.");
Body(c, "4. SIEGE — returning provokes a retaliation attack. Defend the Core!");
Body(c, "5. WIN — fill the Engine meter, then hold the final siege.");
Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win.");
break;
case 2: // Build & Economy
Head(c, "RESOURCES & BUILDING");
Body(c, "Ore — main currency. Mine it by attacking the glowing nodes at base.");
Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo.");
Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing.");
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
Body(c, "Aether — spend it on UPGRADE DMG to boost your damage.");
Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it.");
break;
case 3: // Threats
Head(c, "THREATS");
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core.");
Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends.");
Body(c, "Expeditions throw waves at you — clear them all to bank the win.");
Body(c, "Enemy variety scales the deeper you push.");
break;
default: // Win / Lose
Head(c, "WIN / LOSE");
Body(c, "WIN — clear expeditions to fill the Engine meter, then hold the final siege.");
Body(c, "LOSE — the Engine Core falls during the final siege.");
Body(c, "A Core breach mid-run is only a setback: resources lost, the Core recovers in Calm.");
break;
}
}
static void Head(VisualElement c, string t)
{
var l = new Label(t);
l.style.color = MenuUi.Accent;
l.style.fontSize = 16;
l.style.unityFontStyleAndWeight = FontStyle.Bold;
l.style.marginBottom = 8;
l.style.whiteSpace = WhiteSpace.Normal;
var th = HudTheme.Get();
if (th != null) th.ApplyDisplay(l.style);
c.Add(l);
}
static void Body(VisualElement c, string t)
{
var l = new Label(t);
l.style.color = MenuUi.TextCol;
l.style.fontSize = 14;
l.style.marginBottom = 5;
l.style.whiteSpace = WhiteSpace.Normal;
var th = HudTheme.Get();
if (th != null) th.ApplyBody(l.style);
c.Add(l);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fec1f60cab08999419a195c68923634e
@@ -18,6 +18,7 @@ namespace ProjectM.Client
UIDocument _doc;
VisualElement _mainPanel;
VisualElement _settingsPanel;
VisualElement _howToPanel;
TextField _ipField;
Label _classLabel;
@@ -79,6 +80,21 @@ namespace ProjectM.Client
card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false)));
card.Add(MenuUi.Button("Settings", ShowSettings));
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
// Re-arm the first-run coach-marks (clears the client-local completed-step mask). The next session
// replays them; the How-to-Play card stays available regardless.
Button replayBtn = null;
replayBtn = MenuUi.Button("Replay Tutorial", () =>
{
var s = SettingsService.Current;
s.OnboardingMask = 0;
s.TutorialHints = 1;
SettingsService.Save(s);
if (replayBtn != null) replayBtn.text = "Tutorial armed ✓";
});
card.Add(replayBtn);
card.Add(MenuUi.Button("Quit", Quit));
_mainPanel.Add(card);
@@ -112,6 +128,19 @@ namespace ProjectM.Client
_mainPanel.style.display = DisplayStyle.Flex;
}
void ShowHowToPlay()
{
_mainPanel.style.display = DisplayStyle.None;
_howToPanel = HowToPlayPanel.Build(HideHowToPlay);
_doc.rootVisualElement.Add(_howToPanel);
}
void HideHowToPlay()
{
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
_mainPanel.style.display = DisplayStyle.Flex;
}
static void Quit()
{
#if UNITY_EDITOR
@@ -16,6 +16,7 @@ namespace ProjectM.Client
VisualElement _root;
VisualElement _pausePanel;
VisualElement _settingsPanel;
VisualElement _howToPanel;
bool _open;
/// <summary>True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused).</summary>
public static bool Open;
@@ -49,6 +50,7 @@ namespace ProjectM.Client
var card = MenuUi.Card("PAUSED");
card.Add(MenuUi.Button("Resume", () => SetOpen(false)));
card.Add(MenuUi.Button("Settings", ShowSettings));
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
card.Add(MenuUi.Button("Quit to Menu", WorldLauncher.TeardownToMenu));
card.Add(MenuUi.Button("Quit to Desktop", Quit));
_pausePanel.Add(card);
@@ -61,6 +63,7 @@ namespace ProjectM.Client
Open = open;
if (_pausePanel != null) _pausePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
if (!open && _settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
if (!open && _howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
if (open) { UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; }
}
@@ -75,6 +78,17 @@ namespace ProjectM.Client
_root.Add(_settingsPanel);
}
void ShowHowToPlay()
{
_pausePanel.style.display = DisplayStyle.None;
_howToPanel = HowToPlayPanel.Build(() =>
{
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
_pausePanel.style.display = DisplayStyle.Flex;
});
_root.Add(_howToPanel);
}
static void Quit()
{
#if UNITY_EDITOR
@@ -59,6 +59,19 @@ namespace ProjectM.Client
card.Add(VolumeRow("Music", working.Music, v => { working.Music = v; GameVolume.Music = v; }));
card.Add(VolumeRow("SFX", working.Sfx, v => { working.Sfx = v; GameVolume.Sfx = v; }));
// ---------------- Onboarding ----------------
card.Add(MenuUi.Caption("— ONBOARDING —"));
string[] onoff = { "Off", "On" };
card.Add(CycleRow("Tutorial Hints",
() => onoff[Mathf.Clamp(working.TutorialHints, 0, 1)],
dir => working.TutorialHints = Wrap(working.TutorialHints + dir, 2)));
// DEV: forces the first-run coach-marks to replay fresh on every launch (wipes the completed-step mask at
// each boot — see SettingsService.Boot). Off = normal once-only first-run behaviour.
card.Add(CycleRow("Force Each Launch (Dev)",
() => onoff[Mathf.Clamp(working.ForceOnboardingEachLaunch, 0, 1)],
dir => working.ForceOnboardingEachLaunch = Wrap(working.ForceOnboardingEachLaunch + dir, 2)));
// ---------------- Buttons ----------------
var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working));
apply.style.backgroundColor = new Color(0.12f, 0.30f, 0.22f, 1f);
@@ -0,0 +1,211 @@
using NUnit.Framework;
using ProjectM.Client;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-logic coverage for the first-run onboarding step machine (<see cref="OnboardingStepMath"/>) — the
/// testable core of the client-only <c>OnboardingSystem</c>. No World/ECS needed: each case builds a
/// <see cref="OnboardingStepMath.Snapshot"/> and asserts the deterministic advance rule, the mask helpers,
/// the scheme-aware prompts, and the pointer kinds.
/// </summary>
public class OnboardingStepTests
{
static OnboardingStepMath.Snapshot Empty() => new OnboardingStepMath.Snapshot();
// ---- mask helpers (resume point + dormant detection) ----
[Test]
public void FirstIncomplete_EmptyMask_IsWelcome()
=> Assert.AreEqual(OnboardingStepMath.Welcome, OnboardingStepMath.FirstIncomplete(0));
[Test]
public void FirstIncomplete_SkipsCompletedPrefix()
{
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Mine);
Assert.AreEqual(OnboardingStepMath.Build, OnboardingStepMath.FirstIncomplete(mask));
}
[Test]
public void AllComplete_TrueForFullMaskAndMigrationSentinel()
{
Assert.IsFalse(OnboardingStepMath.AllComplete(0));
int full = (1 << OnboardingStepMath.StepCount) - 1;
Assert.IsTrue(OnboardingStepMath.AllComplete(full));
Assert.IsTrue(OnboardingStepMath.AllComplete(int.MaxValue)); // the v1->v2 migration sentinel reads as done
}
// ---- per-step completion rules ----
[Test]
public void Welcome_AdvancesOnTimer()
{
var s = Empty(); s.StepElapsed = OnboardingStepMath.WelcomeSeconds - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
s.StepElapsed = OnboardingStepMath.WelcomeSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
}
[Test]
public void Move_AdvancesAfterThreshold()
{
var s = Empty(); s.MoveDistance = OnboardingStepMath.MoveThreshold - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
s.MoveDistance = OnboardingStepMath.MoveThreshold;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
}
[Test]
public void Mine_AdvancesOnlyWhenLedgerOreRises()
{
var s = Empty(); s.OreBaseline = 50; s.OreNow = 50; // starts at the seeded baseline
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
s.OreNow = 51;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
}
[Test]
public void Build_AbsoluteTurretCount_AutoSuppressesAtBuiltBase()
{
var s = Empty(); s.TurretCount = 0;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
s.TurretCount = 1; // a join-client landing at an already-built base satisfies it on entry
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
}
[Test]
public void Fabricator_SoftBeat_AdvancesOnBuildOrTimeout()
{
var none = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, none));
var built = Empty(); built.FabricatorCount = 1;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, built));
var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.FabricatorSoftSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, timedOut));
}
[Test]
public void Gate_AdvancesOnExpeditionEntryOrActiveObjective()
{
var s = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, s));
var onExp = Empty(); onExp.OnExpedition = true;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, onExp));
var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, active));
}
[Test]
public void Clear_AdvancesOnClearedObjective()
{
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
s.ObjectiveState = ExpeditionObjectiveState.Cleared;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
}
[Test]
public void Return_AdvancesOnLeavingExpedition()
{
var s = Empty(); s.OnExpedition = true;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s));
s.OnExpedition = false;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s));
}
[Test]
public void Defend_WaitsForSiegeEndButTimesOutWithoutOne()
{
var mid = Empty(); mid.SawSiege = true; mid.Phase = CyclePhase.Siege;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, mid));
var survived = Empty(); survived.SawSiege = true; survived.Phase = CyclePhase.Calm;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, survived));
var noSiege = Empty(); noSiege.StepElapsed = OnboardingStepMath.DefendNoSiegeSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, noSiege));
}
[Test]
public void Done_LingersThenCompletes()
{
var s = Empty(); s.StepElapsed = OnboardingStepMath.DoneSeconds - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
s.StepElapsed = OnboardingStepMath.DoneSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
}
// ---- prompts (scheme-aware, never empty) ----
[Test]
public void Prompts_NonEmptyForEveryStep()
{
for (byte i = 0; i < OnboardingStepMath.StepCount; i++)
{
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, false), "kbm step " + i);
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, true), "pad step " + i);
}
}
[Test]
public void Prompts_AreSchemeAware()
{
StringAssert.Contains("WASD", OnboardingStepMath.Prompt(OnboardingStepMath.Move, false));
StringAssert.Contains("Tab", OnboardingStepMath.Prompt(OnboardingStepMath.Build, false));
StringAssert.Contains("Y", OnboardingStepMath.Prompt(OnboardingStepMath.Build, true));
}
// ---- pointer kinds ----
[Test]
public void PointerKinds_MatchSpatialStepsOnly()
{
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Mine));
Assert.AreEqual(OnboardingStepMath.PointerBaseGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Gate));
Assert.AreEqual(OnboardingStepMath.PointerExpeditionGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
}
}
/// <summary>
/// Public-surface coverage of the onboarding settings fields + their interaction with the dormant check
/// (the v1->v2 migration itself runs through the private SettingsService.Migrate at load — its EFFECT is
/// pinned here via the all-done sentinel + Defaults/Clamped, and end-to-end in the Play smoke).
/// </summary>
public class OnboardingSettingsTests
{
[Test]
public void Defaults_TutorialOn_MaskEmpty()
{
var d = GameSettings.Defaults();
Assert.AreEqual(1, d.TutorialHints);
Assert.AreEqual(0, d.OnboardingMask);
Assert.AreEqual(GameSettings.CurrentVersion, d.Version);
}
[Test]
public void Clamped_NormalizesHints_PreservesMask()
{
var s = GameSettings.Defaults();
s.TutorialHints = 5; // out of the 0/1 range
s.OnboardingMask = 0x55; // an arbitrary bitmask must survive untouched
var c = s.Clamped();
Assert.AreEqual(1, c.TutorialHints);
Assert.AreEqual(0x55, c.OnboardingMask);
}
[Test]
public void Defaults_ForceEachLaunch_Off()
=> Assert.AreEqual(0, GameSettings.Defaults().ForceOnboardingEachLaunch);
[Test]
public void Clamped_NormalizesForceEachLaunchToBool()
{
var s = GameSettings.Defaults();
s.ForceOnboardingEachLaunch = 7; // any non-zero collapses to the 0/1 dev flag
Assert.AreEqual(1, s.Clamped().ForceOnboardingEachLaunch);
s.ForceOnboardingEachLaunch = 0;
Assert.AreEqual(0, s.Clamped().ForceOnboardingEachLaunch);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b7226d166f601d43add545e1532c3e1
@@ -0,0 +1,33 @@
---
title: First-Run Onboarding — design decision-tree + offline build (Unity GPU-crash session)
date: 2026-06-28
tags: [session, onboarding, ux, hud, client-only, presentation, dots-dev]
permalink: gamevault/07-sessions/2026/2026-06-28-first-run-onboarding
---
# First-run onboarding — session
`/dots-dev` session on the operator's brief: *"This game needs an onboarding style type of thing, plan something that makes sense."* Full decision: [[DR-043_First_Run_Onboarding]].
## How it went
1. **Ground** — 5-agent read-only fan-out (`wf_670a0cdf-832`) mapped the onboarding-relevant surfaces (HUD/UITK, controls, the macro loop, economy/build, frontend lifecycle) + an exhaustive search confirming **no onboarding/tutorial/help exists anywhere**. (2 of 5 mappers returned degenerate stubs; the 3 working ones triangulated the rest, so no re-run was needed.)
2. **Forks** — operator asked to drive it **decision-tree style**. Two rounds of `AskUserQuestion` (3 + 4 forks) locked the 7 design decisions (table in DR-043). A genre-precedent research pass (`wf_f41c8423-68b`, NN/g + CHI-2012 + DRG/Riftbreaker/CotL/Hades/Helldivers/Remnant) backed every recommendation; the operator chose the recommended option on all 7.
3. **Build** — mid-session the editor began **crashing randomly**. Diagnosed from `Editor.log`: empty managed stack + faulting `dxgi.dll`, GPU = **RTX 5060 Ti, driver 32.0.16.1062** → a **GPU/driver (TDR) fault, not project code** (recurring `Unity.exe.*.dmp`). Operator chose "peek at the crash, then code." Pivoted to building the whole feature **via the filesystem** (decoupled from the unstable bridge), with a single `refresh_unity force` deferred to when the editor is back.
4. **Static review in lieu of a compiler** — 3-lens adversarial review (`wf_d804925a-f7b`) verified every symbol against source: **Lens 1 compile/API = clean PASS**. Fixed 1 major (the v1→v2 migration dropped returning players' graphics settings) + 4 minors (Esc/pause copy + self-skip, pause-freeze for timed beats, static reset on teardown, same-frame HUD suppression ordering).
## What shipped (code-complete, NOT yet validated)
Contextual coach-marks (`OnboardingSystem` — client-only observe-only, own UIDocument @ sortingOrder 60) running the full first lap soft-gated, a world-space `▶` pointer, a tiny welcome strip naming the inverted win goal, a tabbed **How-to-Play** card (menu + pause), a Settings **Tutorial Hints** toggle + **Replay Tutorial**, all per-client via a client-local `GameSettings.OnboardingMask` (v1→v2 additive). Pure logic in `OnboardingStepMath` with `OnboardingStepTests`. **Zero netcode/replication/bake surface.** Files in DR-043.
## Validation — DONE (2026-06-29, editor stable)
**Green.** `refresh_unity force` → console clean → **20/20 EditMode** (incl. 2 new `ForceOnboardingEachLaunch` cases) → Play smoke proved the new dev toggle (seeded a veteran `int.MaxValue` mask + `force=1` → Boot wiped it → tutorial replayed from Welcome), confirmed **no system sort-cycle** from `HudSystem`'s `[UpdateAfter(OnboardingSystem)]`, and verified the `▶` pointer renders as a clean **U+25B6 triangle** (not tofu) + HUD hint suppression. Also shipped this session: a dev **Force Each Launch** onboarding toggle (`GameSettings.ForceOnboardingEachLaunch` + `SettingsService.Boot` per-launch wipe + a Settings cycle row). The deferred CLAUDE.md gotcha was parked in `_Meta/CLAUDE_Build_Gotchas_Archive.md` (file at cap; one-time pattern doesn't earn inline space). Full detail in [[DR-043_First_Run_Onboarding]].
### Original plan (for the record — now executed)
Not compiled/tested/Play-run yet. When the editor is stable: `refresh_unity scope=all mode=force` (the 5 new `.cs` have no `.meta`) → `read_console` clean → `run_tests` EditMode `ProjectM.Tests.EditMode` → Play smoke (welcome+step1 on a fresh client; dormant when hints off / mask full; per-client; no sort-cycle from the new `[UpdateAfter]`) → L3 screenshots (strip / pointer / card; confirm the `▶` glyph renders). Then add the deferred **CLAUDE.md** gotcha line (first-run flags → client-local `GameSettings`, not host `SaveData`) once green.
## Gotchas worth remembering
- **Unity random/idle crashes on this machine = the RTX 5060 Ti driver (`dxgi.dll`), not code.** Fix path: DDU + NVIDIA Studio driver / disable HAGS / `-force-d3d11`. (Native memory: `gpu-crash-dxgi-driver`.)
- **When the editor is unstable, write Assets `.cs` via the filesystem + one `refresh_unity force` later** instead of per-edit MCP `create_script` — the bridge dies with the editor; a static adversarial review can stand in for the compiler.
- **A version bump on an additive settings/save struct re-activates the migration path for every existing file** — migrate by carrying forward the old value and seeding only new fields, never by rebuilding from `Defaults()` (else you silently reset untouched fields). Caught by the post-impl review.
## Next-session intent
~~Get the editor stable (driver), run the verify ladder for DR-043~~ — **done 2026-06-29** (green). Remaining: the **operator fun-gate** — a real first-run playthrough to feel whether the welcome framing lands, the pointers read, and it stays un-naggy (and to confirm the Welcome→Move→Mine→Build→Fabricator→Gate→Clear→Return→Defend→Done cascade paces well with actual input). The dev **Force Each Launch** toggle (Settings → Onboarding) makes that repeatable.
@@ -0,0 +1,71 @@
---
id: DR-043
title: First-Run Onboarding — contextual coach-marks + replayable How-to-Play card
status: accepted
date: 2026-06-28
tags:
- decision
- design
- onboarding
- ux
- hud
- client-only
- presentation
permalink: gamevault/07-sessions/decisions/dr-043-first-run-onboarding
---
# DR-043 — First-Run Onboarding
> Operator (2026-06-28): *"This game needs an onboarding style type of thing, plan something that makes sense."* The game teaches NOTHING today (only a "Tab/Y — BUILD" discovery chip + a one-line expedition objective readout) yet has a deep, interlocking stack to learn: twin-stick combat → mine/economy → build palette → defend-the-Core sieges → walk-the-gate expeditions → an **inverted win condition** (you win by CLEARING EXPEDITIONS — [[DR-042_Loop_Reshape_Expedition_Driven]] — not by surviving base sieges). Designed via a 7-fork decision-tree (operator-locked) backed by a genre-precedent research pass, then built through the `/dots-dev` ladder.
## The problem
A new player is dropped cold into the full loop with no scaffolding, and the win condition is **counter-intuitive** — base defense *feels* like the goal, so without explicit framing players read it as tower-defense and never discover that expeditions are the win (the "Don't Starve / Hades II — no framing" failure the research flagged). Nothing in the build addresses this.
## Locked design — 7 forks (decision-tree, operator-chosen)
| Fork | Decision |
|---|---|
| **Style** | Contextual just-in-time coach-marks **+** a replayable How-to-Play reference card (not a guided rail, not a static-only screen) |
| **Scope** | The **full first lap** — through one expedition clear AND the retaliation siege (stopping earlier hides the win) |
| **Pacing** | **Soft-gated** objectives (a step shows until its action is done; never physically blocks) + auto-suppress |
| **Guidance** | Text prompt **+ one world-space pointer** per step (off-screen edge-arrow for navigation; text for conceptual beats) |
| **Welcome** | A **tiny non-modal welcome strip** on first spawn (names the inverted goal) → then silent coach-marks |
| **Reference card** | **Tabbed** (Controls · The Loop · Build & Economy · Threats · Win/Lose), reachable from **menu + pause** |
| **Opt-out** | Settings **toggle** + **auto-suppress** (a taught action already done never fires) + **replayable** from the menu + a dev **Force Each Launch** toggle (wipes the mask every boot — see below) |
| **Co-op** | **Per-client**, keyed to each player's own first-encounter; flags in **client-local `GameSettings`**, NOT the host-only `SaveData` |
Research backing (3-agent genre-precedent pass, run `wf_f41c8423-68b`): NN/g pull-not-push / one-thing-at-a-time / advance-by-doing; CHI-2012 (context-sensitive > forced); shipped analogs Riftbreaker / Cult of the Lamb / DRG / Hades / Helldivers 2 / Remnant 2 all teach **one complete lap then release**, optional+replayable, per-client. The annotated loop diagram is the single highest-value asset (answers the #1 confusion "how do the pillars connect").
## Architecture — pure client-side presentation, ZERO netcode surface
- **`OnboardingSystem`** — client-only observe-only `SystemBase` in `PresentationSystemGroup` (same constraints as `HudSystem`: never mutates sim, never destroys a ghost, reads replicated state once/frame). Owns its own runtime UIDocument (sortingOrder **60** — above HUD 50, below pause 100, root `pickingMode=Ignore`). A bottom-center prompt chip + a world-space `▶` pointer.
- **`OnboardingStepMath`** — pure, engine-free step list + `Snapshot` + `IsSatisfied` + prompt copy + pointer kind + mask helpers (the unit-testable core; mirrors the `*Math` discipline).
- **`OnboardingState`** — static `Active` flag (HudSystem reads it to blank its own location hint → single prompt voice) + `[RuntimeInitializeOnLoadMethod]` reset-on-play-enter (stale-static rule).
- Progress persists per-client in **`GameSettings.OnboardingMask`** (a completed-step bitmask) + `TutorialHints` toggle (v1→v2 **additive**). Client-local JSON — a Join client keeps its own; a save wipe never re-teaches the host.
- **Dev replay-each-launch** (`GameSettings.ForceOnboardingEachLaunch`, added 2026-06-29): an additive **0-default** int (no version bump — a v2 file deserializes it to 0/off). When set, `SettingsService.Boot` (a `RuntimeInitializeOnLoadMethod` that runs on **every** editor Play-enter / built-player launch) wipes the mask + forces hints on **in-memory** (not written back — the system re-persists as the player advances, and the next launch wipes again). Surfaced as a "Force Each Launch (Dev)" cycle row in Settings → Onboarding. The menu "Replay Tutorial" remains the one-shot equivalent.
### The lap (10 beats; soft-gated; scheme-aware glyphs; auto-suppress via absolute counts)
Welcome(timed, names the goal) → Move → Mine (attack an Ore node — mining IS combat at the base since Calm has no enemies; pointer→nearest node) → Build a Turret → Fabricator (soft) → Reach the Gate (edge-arrow) → Clear the zone → Return (leave expedition) → Defend the siege (soft) → Done. Each step gated on the **local** player's own first-encounter; count-based steps (Build/Fabricator) test an **absolute** count so a join-client at a built base auto-skips.
## Wire/bake classification — **LOW blast radius**
No `[GhostField]`, no ghost prefab/hash change, **no subscene re-bake**, no RPC, no `GhostRelevancy`, no server-system ordering. Only client-local `GameSettings` v1→v2 (additive JSON). ⇒ no pre-code netcode design review needed; relied on the verify ladder + a post-impl static review instead.
## Build status — shipped + validated green (built 2026-06-28, validated 2026-06-29)
Built **2026-06-28**, written via the **filesystem** (not MCP `create_script`) because the Unity editor was crashing — a **GPU/driver fault** (`dxgi.dll`, RTX 5060 Ti, driver 32.0.16.1062; recurring `Unity.exe.*.dmp` — NOT project code; see the session log + native memory). The feature has **NOT yet been compiled / tested / Play-validated**.
In lieu of a compiler, a **3-lens adversarial static review** (run `wf_d804925a-f7b`) verified every symbol against the codebase: **Lens 1 (compile/API) = clean PASS** (SystemAPI-in-helpers pattern confirmed valid, the `PlayerInput`/InputSystem CS8377 gotcha respected, asmdef refs + UITK APIs all resolve). Findings fixed:
- **[MAJOR, fixed]** the v1→v2 bump activated the lossy `SettingsService.Migrate` (rebuilt from `Defaults()`, dropping returning players' display-mode/quality/v-sync/fps/refresh) → rewrote Migrate to carry forward all old fields and seed only the new ones (Load already Clamps).
- **[minor, fixed]** Welcome copy said "Esc: How to Play" but Esc opens Pause, and Esc (via `anyKey`) self-dismissed the framing → reworded to "Esc → Pause → How to Play" + excluded `escapeKey` from the message-beat skip.
- **[minor, fixed]** timed beats advanced behind the pause overlay → freeze accumulation/eval on `PauseMenuController.Open`.
- **[minor, fixed]** `OnboardingState.Active` not reset on system teardown → reset in `OnDestroy`; HudSystem now `[UpdateAfter(OnboardingSystem)]` for same-frame suppression.
- **[L3 watch]** the `▶` pointer glyph's font coverage — verify it renders (swap to a font-independent shape if it's a missing-glyph box).
**Validation (2026-06-29, editor stable):** `refresh_unity scope=all mode=force` imported the 5 new `.cs` + recompiled → `read_console` clean (1 unrelated AI-assistant warning) → **20/20 EditMode pass** (`OnboardingStepTests` + `OnboardingSettingsTests`, incl. 2 new `ForceOnboardingEachLaunch` cases). Play smoke: seeded a **veteran** mask (`int.MaxValue`) + `force=1` → Boot wiped it → the overlay replayed from Welcome (live `mask` observed back at 0, then 1 as Welcome auto-completed) — proving the dev toggle AND that `HudSystem`'s new `[UpdateAfter(OnboardingSystem)]` introduces **no system sort-cycle** (world booted clean). Captured the Move + Build + Gate prompt chips and the `▶` pointer — the glyph renders as a **clean U+25B6 triangle, not a tofu box** (the flagged L3 watch), and the HUD location hint is correctly suppressed during onboarding. (The `▶` step was forced via reflection on the live `OnboardingSystem` since input can't be injected in an MCP smoke.)
## Files
**New:** `Client/Onboarding/{OnboardingState, OnboardingStepMath, OnboardingSystem}.cs`, `Client/UI/HowToPlayPanel.cs`, `Tests/EditMode/OnboardingStepTests.cs`.
**Edited:** `Client/Settings/{GameSettings, SettingsService}.cs`, `Client/UI/{MainMenuController, PauseMenuController, SettingsScreen}.cs`, `Client/Presentation/HudSystem.cs`.
## Consequences / open
- **CLAUDE.md gotcha — parked in the archive, NOT inlined** (resolved 2026-06-29): the reusable lesson (*first-run / has-played / per-player UI flags → client-local `GameSettings`, never host `SaveData`* + the client-onboarding-overlay pattern + the dev force-each-launch wipe) is captured under a dated heading in `_Meta/CLAUDE_Build_Gotchas_Archive.md`. It stayed out of CLAUDE.md by design: the file is at **40884/40960 B** (76 B headroom) and this is a one-time client-overlay pattern, not a high-recurrence hazard, so by CLAUDE.md's own "hottest rules only" budget rule it doesn't earn inline space — evicting a hot rule to make room for a cold one would be a net loss.
- Tuning knobs (live): `OnboardingStepMath.{WelcomeSeconds, MoveThreshold, FabricatorSoftSeconds, DefendNoSiegeSeconds, DoneSeconds}`.
- Deferred (not built): contextual `?` deep-link from a coach-mark to the card page; target/HUD highlights (operator chose pointers only); a structured guided-tutorial variant.
- See [[2026-06-28_First_Run_Onboarding]] · [[DR-042_Loop_Reshape_Expedition_Driven]] · [[DR-038_Slice1_Combat_Readability_HUD_Declutter]] (the discovery-chip/build-mode precedent) · [[DR-019_Frontend_Menu_Settings_Saves_Build]] (settings/save lifecycle).
@@ -384,3 +384,14 @@ Added the **EB-2 felt spend ★** bullet ([[DR-033_EB2_Felt_Spend_Charge_Economy
- **Resource-gated ability tiers / buffs ([[DR-026_Inventory_Equipment_Progression_Foundation]]):** dropped the "(replace/clear-by-SourceId → bounded buffer; folds into … both worlds)" mechanism phrasing → kept "reuse `StatModifier``StatRecomputeSystem``EffectiveAbilityStats`". (Note: this bullet's `GoalProgress.Charge` is the **goal-meter** charge, unrelated to EB-2's `ResourceId.Charge` turret ammo.)
- **PlacedStructure ([[DR-014_M6_Build_Structures_Automation_Foundation]]):** dropped the "(turret reuses `NextTick` as fire cooldown; they're the offline-catch-up linchpin)" + "Data-driven `StructureCatalog` buffer" asides → kept the field layout + the bake-the-tick-fields rule + the DERIVED-occupancy rule.
- **M7 Automation ([[DR-020_M7_Automation_Production_Chains]]):** dropped the "server-only `MachineInput`/`MachineOutput`" restatement and noted `Fabricator` is now LIVE on the palette via EB-2 while `Harvester`/`Conveyor` stay trimmed (code intact).
## 2026-06-29 — First-run onboarding validated (DR-043); CLAUDE.md line kept archive-only (file at cap)
DR-043's first-run onboarding shipped + Play-validated green (20/20 EditMode; live Play: a "veteran" full mask wiped by the dev toggle → tutorial replayed from Welcome, the `▶` pointer = a clean U+25B6 triangle (not tofu), no system sort-cycle from `HudSystem`'s new `[UpdateAfter(OnboardingSystem)]`). The deferred CLAUDE.md gotcha is parked HERE, not inline — the file sits at 40884/40960 B (76 B headroom) and the lesson is a one-time client-overlay pattern, not a high-recurrence hazard, so by CLAUDE.md's own "hottest rules only" rule it doesn't earn inline space. The lesson:
- **First-run / "has-played" / per-player UI flags belong in client-local `GameSettings` (settings.json), NEVER the host-only `SaveData`** — in co-op a Join client never sees the host save, and a host save-wipe must not re-teach. Progress = a completed-step **bitmask** in settings. Additive field, **0-default → no version bump** (a missing field deserializes to 0 = the safe off/fresh default; bumping `CurrentVersion` instead re-activates the migration path for every existing file — see the 2026-06-28 migration regression below/in DR-043).
- **Onboarding overlay = a client-only observe-only `PresentationSystemGroup` `SystemBase`** (same constraints as `HudSystem`: never mutates sim, never destroys a ghost, reads replicated state once/frame) owning its own runtime `UIDocument` (sortingOrder 60 — above HUD 50, below pause 100, root `pickingMode=Ignore`). A static `OnboardingState.Active` (reset on `SubsystemRegistration`) lets `HudSystem` (`[UpdateAfter(OnboardingSystem)]`) blank its own location hint → a single prompt voice. Auto-suppress for veterans/co-op falls out of **ABSOLUTE count checks** (turret/fabricator count ≥1 satisfies on entry at an already-built base).
- **Dev "Force Each Launch" toggle:** `GameSettings.ForceOnboardingEachLaunch``SettingsService.Boot` (a `RuntimeInitializeOnLoadMethod` that runs each editor Play-enter / built-player launch) wipes the mask + forces hints on **IN-MEMORY** (NOT written back; the system re-persists progress as the player advances, and the next launch wipes again). Validated by seeding `OnboardingMask=int.MaxValue` (veteran) + `force=1` then Play → mask observed back at 0/fresh.
- **Forcing a specific step in a live Play smoke without input:** the step machine's `_step`/`_mask`/`_stepInit` are private — set them via reflection on `world.GetExistingSystemManaged(typeof(OnboardingSystem))`; remember an MCP `screenshot` can leave the editor **paused** (`EditorApplication.isPaused`), so the field write won't surface until you unpause. Pause + reflection-restyle the `_pointer` Label is also how to get a clean glyph capture (OnUpdate would otherwise overwrite the style next frame).
Net-zero: archive-only add (no CLAUDE.md bytes changed), so no inline condensation needed.