52 KiB
Candidate System — Implementation Plan
Audience: the developer or agent implementing this. Assume no prior context on this project and no access to any design document. Everything needed is in this file. Nothing here says "see the design doc," because you do not have one.
Repo root: /Users/lennart/Dev/nightlcub-arcadia
Unity project: NightclubArcadia/ — Unity 6000.5.8f1, URP, Yarn Spinner 3.2.8 (vendored).
Unity editor binary: /Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity
Status: implemented (Phases 0–5). A predecessor plan,
docs/candidate-system-plan.md, covered the first slice of this
feature, and its Steps 1, 2 and 4 landed in commit 22b8c7a. That document has been marked
superseded and points here. Its §1 design summary and §8 traps remain accurate. §1 below was the
survey at plan-writing time; the code now also contains the Phase 1–5 deliverables
(Assets/Candidates/, lint/registry tests, Common.yarn flags, SC-101 prose, Debug_CandidateCheck).
0. How to read this
- §1 is a survey of the codebase as it stands, with paths. Read it first; most of the machinery this feature needs already exists and must be extended, not re-invented.
- §2 restates the whole design — the Voice-System it rides on, and the Candidate-System itself. It is decided intent, not a proposal. Do not redesign it.
- §3–§4 fix names and architecture decisions.
- §5 is the phased build. Each phase is independently buildable and testable.
- §6 turns the design's guardrails into enforceable implementation constraints.
- §7 lists decisions that are genuinely open. Do not resolve them; raise them with the lead developer. Where a placeholder was needed to keep a phase buildable, it is marked provisional.
- §8 lists traps that have already bitten this codebase.
1. Survey — what exists in the repo today
1.1 Dialogue and variable storage
| Thing | Path | Notes |
|---|---|---|
| Yarn Spinner 3.2.8 | NightclubArcadia/Packages/dev.yarnspinner.unity/ |
Vendored as an embedded package; not listed in Packages/manifest.json. |
| Yarn project | Assets/Dialogue/NightclubArcadia.yarnproject |
Compiles **/*.yarn under Assets/Dialogue/. New .yarn files are picked up automatically — no importer change needed to add a scene. |
| Working scene | Assets/Scenes/DialogueTest.unity |
Contains the Dialogue System objects: DialogueRunner, Line Presenter, Options Presenter, Line Advancer, Canvas, and a stock InMemoryVariableStorage. autoStart: 1, startNode: "Start". |
| Template leftover | Assets/Scenes/SampleScene.unity |
The only scene in EditorBuildSettings. DialogueTest is opened by hand. |
Existing .yarn files, all under Assets/Dialogue/:
| File | Contents |
|---|---|
Common.yarn |
The Declarations node. Never played. Every variable in the project is declared here. |
Scenes/Entrance.yarn |
Start, Entrance_Inside, Entrance_Outside. Scene files stay thin: set the stage, <<detour>> into character nodes, branch on the outcome. |
Scenes/SC101.yarn |
The opening candidate scene. Structure and flag writes are in; prose is TODO. See §1.4. |
Scenes/Debug.yarn |
Debug_SkillCheck, Debug_CandidateLean. Not story content; exists to exercise mechanics in isolation. This is the precedent for verifying each phase below. |
Characters/Bouncer.yarn |
Four nodes all titled Bouncer_Talk — a Yarn 3 node group with when: saliency variations. |
Commentary.yarn |
Passive skill barks. Node names follow Commentary_{contextId}_{skillId}. |
Every one of these opens with a // header comment explaining the file's role and its writing
conventions. Match that.
Persistence. There is no custom variable-storage subclass, no save system, and no game-state
singleton beyond SkillRuntime. There does not need to be:
VariableStorageBehaviour(Packages/dev.yarnspinner.unity/Runtime/Storage/VariableStorageBehaviour.cs) exposesSetValue(string, float/bool/string),TryGetValue<T>,GetAllVariables(),SetAllVariables(...),Contains(...),AddChangeListener(...).DialogueRunner.SaveStateToPersistentStorage(fileName)/LoadStateFromPersistentStorage(fileName)(Runtime/DialogueRunner/DialogueRunner.Utility.cs) serialise the whole variable storage to JSON underApplication.persistentDataPath. This is the persistence mechanism. Do not build another. No call site exists yet (§7.6).- The yarnproject importer's generated-variables feature (
generateVariablesSourceFile) is off. Leave it off.
1.2 The skill-voice system (the Voice-System, in code)
Assembly NightclubArcadia.Skills — Assets/Scripts/Skills/NightclubArcadia.Skills.asmdef,
references YarnSpinner.Unity and Unity.TextMeshPro.
| Piece | Path | Relevance here |
|---|---|---|
SkillAxis enum |
Scripts/Skills/Data/SkillAxis.cs |
{ Reason, Body, Social, Self, Specialist }. This is the design's "voice pair" concept. Do not duplicate it. |
TraceChannel enum |
Scripts/Skills/Data/TraceChannel.cs |
{ None, Documentary, Behavioural, Testimonial, Physical }. |
ClueYield enum |
Scripts/Skills/Data/ClueYield.cs |
{ Low, Medium, High } — a per-voice authoring hint, not a clue object. |
DcBand enum |
Scripts/Skills/Data/DcBand.cs |
Trivial=8, Routine=11, Hard=14, Specialist=17, BuildDefining=20. DcBands.TryParse accepts band names or raw ints (raw ints log a warning). |
SkillCheckDegree |
Scripts/Skills/Data/SkillCheckDegree.cs |
{ CriticalFailure, Failure, Success, CriticalSuccess }. |
SkillDefinition |
Scripts/Skills/Data/SkillDefinition.cs |
ScriptableObject per voice. Generated from JSON; hand-edits to .asset files are overwritten on script reload. |
SkillDatabase |
Scripts/Skills/Data/SkillDatabase.cs |
Roster ScriptableObject. Its OnValidate() runs a full audit: id regex ^[a-z][a-z0-9_]*$, duplicates, opposedTo symmetry, per-axis counts. This is the pattern to copy in Phase 1. |
skill_bible.json |
Assets/Skills/skill_bible.json |
Writer-facing source of truth for the roster. 11 voices. |
SkillSystemSetup |
Assets/Editor/SkillSystemSetup.cs |
[DidReloadScripts] generator: JSON → .asset files → database → scene wiring. |
PlayerSkills |
Scripts/Skills/Runtime/PlayerSkills.cs |
Plain C#. Ranks only, seeded from each definition's startingRank. In-memory; not persisted. |
SkillCheckSystem |
Scripts/Skills/Runtime/SkillCheckSystem.cs |
Plain C#. Resolve(skillId, dc, situationalMod) = d20 + rank + situational + state; nat 1 crit-fail, nat 20 crit-success, else total >= dc. |
SkillStateModifiers |
Scripts/Skills/Runtime/SkillStateModifiers.cs |
Nested dict sourceKey → (skillId → delta); "*" wildcard supported. |
SkillRuntime |
Scripts/Skills/Runtime/SkillRuntime.cs |
Composition root + singleton the static Yarn commands route through. |
CommentarySystem |
Scripts/Skills/Commentary/CommentarySystem.cs |
Fires one passive bark per request from Commentary_{contextId}_{skillId}, weighted by rank, gated by per-skill budget, recency, and a global floor. See §8.2. |
YarnSkillCommands |
Scripts/Skills/Yarn/YarnSkillCommands.cs |
The entire Yarn↔C# surface: <<check>>, <<skill_mod>>, <<clear_skill_mods>>, skill_rank(). |
| Tests | Assets/Tests/EditMode/ |
SkillRosterTests, SkillCheckSystemTests, CommentaryPickerTests, DcBandTests. SkillRosterTests loads the real SkillDatabase.asset via AssetDatabase and asserts roster invariants — the pattern to copy in Phase 1. |
The roster in skill_bible.json matches the design cast exactly:
| id | display | axis | opposedTo | primary / secondary channel |
|---|---|---|---|---|
provenance |
PROVENANCE | Reason | confluence |
Documentary / Physical |
confluence |
CONFLUENCE | Reason | provenance |
Behavioural / Testimonial |
house_pour |
HOUSE POUR | Body | long_shift |
Behavioural / Physical |
long_shift |
THE LONG SHIFT | Body | house_pour |
Physical / Behavioural |
facework |
FACEWORK | Social | placement |
Testimonial / Behavioural |
placement |
PLACEMENT | Social | facework |
Testimonial / None |
amnesty |
AMNESTY | Self | standing_order |
Behavioural / None |
standing_order |
STANDING ORDER | Self | amnesty |
Testimonial / None |
undisclosed |
UNDISCLOSED | Specialist | provenance |
Physical / Behavioural |
the_float |
THE FLOAT | Specialist | amnesty |
Documentary / Physical |
room_tone |
ROOM TONE | Specialist | placement |
Physical / Testimonial |
Nothing needs to be added to the roster for this feature.
1.3 Candidate state that already exists
Assets/Dialogue/Common.yarn already declares, with a comment block stating the no-winner guardrail:
<<declare $self_lean_made_asset = 0 as number>>
<<declare $self_lean_journalist = 0 as number>>
<<declare $self_lean_inheritor = 0 as number>>
<<declare $seen_sc_101 = false as bool>>
Assets/Dialogue/Scenes/Debug.yarn has Debug_CandidateLean, which increments the accumulators and
branches on a constant threshold — the round-trip proof.
1.4 SC-101 as it stands
Assets/Dialogue/Scenes/SC101.yarn exists with a house-style header and five nodes: an entry node
SC101_Desk that sets $seen_sc_101 and offers four <<detour>> routes, plus four hook nodes —
SC101_Hook_MadeAsset (CONFLUENCE:), SC101_Hook_Journalist (FACEWORK:),
SC101_Hook_Inheritor (STANDING ORDER:) and SC101_Hook_Neutral (HOUSE POUR:, deliberately
carrying no lean write). Every line of prose is a TODO placeholder.
1.5 What does not exist
State these plainly, because they are the starting conditions and two of them are dependencies:
- No clue, evidence, or deduction objects of any kind. No registry, no ids, no discovery flags.
docs/skill-system-refactor-plan.md§6 explicitly lists this as out of scope for that pass: "That system does not exist in this repo and is out of scope. Do not invent a schema for it." This plan is where it gets invented, deliberately and minimally (Phase 1). - No errand, quest, Act, location, or scene-progression system. Same doc, same section, lists "Act / errand state, or any gating for documentary routes" as an explicit non-goal. The Candidate-System requires exploration gating (§2.6), so Phase 4 proposes the minimal new structure — see §7.2 for who owns the larger system.
- No skill-rank progression.
PlayerSkills.SetRankhas no caller anywhere outside its own file and the tests. Ranks come fromstartingRankand never change, and are not persisted. The design premise "a player who has invested in a voice pair hears more advocacy" therefore has no mechanism behind it yet. This does not block anything here — see §7.5. - No save/load call site. The Yarn API in §1.1 is present but never called.
- No
CLAUDE.md, no projectREADME.md. - No representation of the two permanently unresolved lore threads referred to internally as "Thread 2" and "Thread 9". A repo-wide grep for thread identifiers returns nothing. See §6.8 — they are off-limits for this feature regardless.
- No NPC content beyond
Bouncer.yarn. In particular, the NPC named indocs/candidate-system-plan.md§7.6 as a possible Inheritor link does not exist in code. See §7.3.
1.6 Working tree
git status is clean at the time of writing. The TODO: ambient fallback note at the top of
Commentary.yarn is committed, not a stray edit; it is unrelated to this feature. Leave it alone.
2. The design, restated in full
This section is the complete statement of intent. It is decided design, not a proposal. Where something is genuinely open, §7 says so.
2.1 The game
NightclubArcadia is a mystery-driven, dialogue-heavy narrative game. Combat is a lightweight secondary system; the core is investigation and writing. One developer, roughly a one-year horizon.
2.2 The Voice-System — what a "skill" is here
A skill in this game is not a stat that unlocks a door. It is one of eleven internal character voices living in the protagonist's head, each with its own domain, agenda, blind spot, register and verbal tics. Skill text is written as that character's opinion, never as neutral system text.
The cast is four opposed pairs plus three specialists:
- Reason — PROVENANCE (things have an author; a paper trail is a confession) vs. CONFLUENCE (things have a shape across time; connection is authorship).
- Body — HOUSE POUR (appetite is honesty) vs. THE LONG SHIFT (endurance is honesty).
- Social — FACEWORK (know a person to understand them) vs. PLACEMENT (know a person to position them).
- Self — AMNESTY (letting go is mercy) vs. STANDING ORDER (holding the line is identity).
- Specialists, with no opposite, covering trace channels the pairs miss — UNDISCLOSED (leverage and reserves), THE FLOAT (money in motion), ROOM TONE (overheard sound).
There are two kinds of skill text:
- Passives — fire unprompted, cost nothing, and are the large majority of all skill text.
- Active checks — dice rolls against a visible difficulty number, used sparingly (on the order of once every few minutes of play), and only where both success and failure are worth writing.
Failure is never "nothing happens." A failed check does one of five things:
- hands the player a wrong-but-confident conclusion (the most common case);
- costs something instead of information;
- closes one route while opening an uglier one;
- teaches the player something about themselves rather than about the case;
- is logged silently and resurfaces later.
Wrong conclusions are never flagged as wrong in the prose. Only the roll's pass/fail state appears in
the UI banner; what is contaminated downstream is not marked. This is already the codebase's
convention — Common.yarn and Commentary.yarn both carry the header note "Wrong Truth failures
carry no failure signal in the prose — the UI banner is the only tell."
Every piece of evidence in the game is reachable through at least one of four trace channels: documentary, physical, testimonial, behavioural. Each voice has a primary and a secondary channel (table in §1.2).
2.3 The problem the Candidate-System solves
The protagonist has amnesia — a dissociative fugue. They have lost their own identity, not an external memory. This is designed to be genuinely open-ended: there is no secret canonical answer the writers are withholding, and no hidden ground-truth value anywhere in the data or the save file.
2.4 The three candidates
They are allowed to overlap and coexist. They are not mutually exclusive, and none of them is "the twist."
| Id | Name | Reading |
|---|---|---|
made_asset |
The Made Asset | The protagonist was trained, conditioned, or activated by one of the story's factions, for a role near its central conference. |
journalist |
The Journalist | The protagonist came for a specific person, not a story. "Journalist" is a cover identity. |
inheritor |
The Inheritor | This was never the protagonist's story. A parent or older relative had standing, or a debt, with a faction; it passed to the protagonist without full knowledge or consent. |
2.5 Symmetric evidence structure
Each candidate has exactly one object clue, one testimony clue, one document clue, and one intuition clue. The symmetry is deliberate: no evidence type should feel more trustworthy than another across candidates, so a player's growing belief comes from which clues they happen to find and choose to weight, never from the system tipping its hand through an imbalance.
This is an invariant to enforce mechanically as content grows (Phase 1), not a coincidence of the current content.
Each candidate's intuition clue is attached to a different voice pair, so that trusting "the smart voice" or "the gut voice" doesn't systematically favour one candidate:
| Candidate | Voice pair (SkillAxis) |
Shape of the intuition |
|---|---|---|
made_asset |
Reason | Parsing something too fluently, before consciously trying to. |
journalist |
Social | A physical or emotional reaction that lands wrong, which the protagonist can't yet explain. |
inheritor |
Self | Correcting an old etiquette rule they never consciously learned. |
| — | Body | Attached to no candidate, on purpose. It stays neutral texture, so that not every voice pulls the player toward a theory of their identity. |
Because each pair is two characters with opposite blind spots, a line leaning toward a candidate is that character arguing for it in its own biased register — never a flat statement of fact. Both voices of an attached pair may argue for the same candidate from opposite directions; which of the two speaks in a given beat is a writing decision.
2.6 How belief accumulates
- There is no "reveal the truth" moment. Belief in a candidate accumulates across the whole game from which clues the player finds and which clues and voices they choose to trust.
- Clue-surfacing order must be steerable through ordinary player choices — which errands they run, which locations they visit. It is exploration-gated, never skill-gated. This is a hard constraint, and it exists specifically so that no candidate is reliably surfaced first across playthroughs.
- A player who has invested in a given voice pair will naturally hear more advocacy for that pair's attached candidate — but that is an emergent consequence of play, not a system-imposed lock.
Lean is a running per-candidate accumulator. The player is never asked to pick an identity, and nothing ever resolves the three into a winner.
2.7 How it appears on the page
The Candidate-System is not a separate scene type and not separate UI. It rides inside the ordinary Voice-System, using the same passive/active split and the same voices argue, they don't report rule as everything else.
The opening scene, SC-101 "The Card at the Desk," is the reference example: four voices — one per pair — introduce themselves, three planting one candidate's intuition each, the fourth (Body) staying neutral. None of these lines is proof; each is in-character noticing.
Flagged exception. SC-101 uses four voice introductions, against a general "cap introductions at three voices" rule in the project's writing handbook (the handbook is not in this repo). This is a deliberate, signed-off exception for the opening scene only. Do not silently "correct" SC-101 down to three voices, and do not treat it as a new precedent that licenses four-voice beats elsewhere. See §7.4.
2.8 What this system is explicitly NOT
- Not a personality quiz with three outcomes.
- Not a hard branch that locks the player into one identity path early.
- Not resolved by a single reveal or one late dialogue choice.
- Must never produce a discoverable "correct answer" anywhere in game files or save data.
If a future feature (endings, epilogue text, achievements) needs one clean outcome, that is a new, separate design decision. Nothing built here may silently assume it.
3. Names and conventions
These are already in force in the repo, enforced by SkillDatabase.OnValidate() and
Assets/Tests/EditMode/SkillRosterTests.cs. Defer to them.
- Yarn variables:
$snake_case, every one declared inCommon.yarn'sDeclarationsnode, so a typo is a compile error rather than a silently-created second variable. - Ids (skills, and by extension candidates and clues):
^[a-z][a-z0-9_]*$. Enforced because ids become Yarn node-title and variable-name fragments (§8.4). - Yarn node titles:
Start,{Scene}_{Beat},{Character}_Talk,Commentary_{ContextId}_{skillId},Debug_*. Context ids are PascalCase-with-underscores; skill ids are lowercase. - Yarn commands: lowercase snake_case.
- C#: namespace
NightclubArcadia.Skills, classessealedby default,[SerializeField]fields barecamelCase, log prefixes[TypeName]. - Docs: lowercase-kebab-case
.md, flat indocs/.
Names locked by the predecessor plan and already in the repo — do not rename them, they are now content-wide:
| Concept | Name |
|---|---|
| Candidate ids | made_asset, journalist, inheritor |
| Lean accumulators | $self_lean_made_asset, $self_lean_journalist, $self_lean_inheritor |
| Scene-witnessed flag | $seen_<scene_id>, e.g. $seen_sc_101 |
| Scene id / node prefix | SC101, e.g. SC101_Desk, SC101_Hook_MadeAsset |
New names introduced by this plan:
| Concept | Name | Where |
|---|---|---|
| Clue ids | <candidate_id>_<type>, e.g. journalist_document |
Phase 1 |
| Clue-found flags | $found_<clue_id>, e.g. $found_journalist_document (bool) |
Phase 2 |
| Location-visited flags | $visited_<location_id> (bool) |
Phase 4 |
| Errand-state flags | $errand_<errand_id> (bool) |
Phase 4 |
4. Architecture decisions
4.1 — Lean stays a plain Yarn number, written by plain <<set>>.
Already the case, and it stays that way. A <<add_lean made_asset 1>> command would take the
candidate id as a string, throwing away the compile-time typo checking that declaring everything in
Common.yarn buys. Registry consistency is instead enforced at editor time by a lint test (Phase 2),
which gives the same safety without runtime coupling.
4.2 — The candidate registry is descriptive, not authoritative over runtime. The registry (Phase 1) records what evidence exists, of what type, gated behind what. It holds no weight, no truth value, no lean amount, and nothing from which a winner could be derived. No runtime code reads lean out of it. Its job is to make the symmetry invariant enforceable and the gating auditable.
4.3 — Candidate clue text is deterministic and scene-authored; CommentarySystem is not the
vehicle. CommentarySystem looks like an exact fit — it fires a named voice from a node — but it
is a probabilistic, rank-weighted channel (§8.2). Routing clue-bearing lines through it would make
discovery depend on skill rank, which is exactly the skill-gating that §2.6 forbids. Clue-bearing
lines are authored in scene nodes and reached by <<detour>>.
Where the "invested players hear more advocacy" property comes from, then:
CommentarySystem's rank-weighted pick is precisely that mechanism — a higher-ranked voice speaks
more often. Candidate-flavoured commentary that carries no clue and no lean may ride
CommentarySystem freely. The split is: advocacy is ambient and rank-weighted; evidence is
deterministic and exploration-gated.
4.4 — Lean is written on failed checks too. This follows from §2.3 and §2.2 rather than being a free choice, and it is easy to get wrong. A failed candidate check most often hands the player a wrong-but-confident conclusion, which the player believes. Lean tracks belief, not truth. If lean were only written on successes, the accumulator would encode which readings were "really" supported, and a player reading their save file could infer a ground truth that is not supposed to exist. So: a candidate-relevant check writes lean on both branches, with the failure branch writing its own (possibly different) candidate's lean. The amount is a separate open question (§7.1).
4.5 — Exploration gating is expressed as data, not as prose discipline.
Each clue entry names an unlock flag. A lint test forbids that flag from being a skill-derived
expression, and forbids clue-bearing nodes from sitting behind skill_rank(...) guards (Phase 4).
This is the one guardrail most likely to be violated by accident, because gating on rank is easier
to write than gating on exploration.
5. Phases
Each phase is independently buildable and independently testable. Phases 1–2 are engineering with no prose; Phase 5 is where writing lands.
Phase 0 — Confirm the landed slice, and prove persistence
Hooks into: Common.yarn, Debug.yarn, DialogueRunner's save API.
Adds: nothing permanent.
Steps 1, 2 and 4 of the predecessor plan are in the repo (§1.3, §1.4). Step 3 — persistence — has no committed evidence that it was ever run. Do it now, because everything later assumes it:
- Open
Assets/Scenes/DialogueTest.unity, temporarily point theDialogueRunner'sstartNodeatDebug_CandidateLean, press Play. Expectmade_asset 2 / journalist 1 / inheritor 0and the threshold branch to fire. RestorestartNodetoStartafterwards. - From a throwaway
[ContextMenu]component or editor script, callSaveStateToPersistentStorage("candidate-lean-smoketest.json"), exit Play, re-enter, callLoadStateFromPersistentStorage(...), and confirm the values come back. - Inspect the JSON under
Application.persistentDataPath:$self_lean_*should appear infloatKeys/floatValues,$seen_sc_101inboolKeys/boolValues. - While there, record whether Yarn 3's
when: oncesaliency state lives in the same variable storage. If it does, saliency survives save/load for free. Either way it is not this feature's problem — the explicit$seen_*bool convention exists precisely so nothing here depends on it. - Delete the throwaway script. When the game actually saves is a separate ticket (§7.6).
Done when: the round trip is confirmed and no temporary script remains.
Phase 1 — The candidate registry and the symmetry invariant
Hooks into: the skill_bible.json → generator → SkillDatabase → OnValidate → EditMode-test
pattern. Mirror it exactly; do not invent a second idiom.
Adds: new files only. Touches no existing runtime code.
This is genuinely new structure — no clue or evidence system exists in this repo (§1.5). It is
kept as small as it can be while still making §2.5's invariant enforceable.
1a. Writer-facing source of truth: Assets/Candidates/candidates.json, mirroring
Assets/Skills/skill_bible.json. Shape:
{
"candidates": [
{
"id": "journalist",
"displayName": "The Journalist",
"reading": "The PC came for a specific person, not a story; 'journalist' is a cover.",
"intuitionAxis": "Social",
"clues": [
{
"id": "journalist_object",
"type": "Object",
"sceneId": "SC101",
"unlockFlag": "$visited_desk", // Phase 4; empty string until then
"status": "Planned", // Planned | Authored
"notes": "writer-facing one-liner"
}
// ... exactly one each of Object, Testimony, Document, Intuition
]
}
]
}
Intuition entries additionally carry "skillId" — the voice that speaks the line.
1b. New enum CandidateEvidenceType { Object, Testimony, Document, Intuition } in
Assets/Scripts/Skills/Data/. Keep it separate from TraceChannel: the two vocabularies nearly but
not exactly align (document↔Documentary, object↔Physical, testimony↔Testimonial,
intuition↔Behavioural), and collapsing them would quietly redefine one of them. Document the
correspondence in a comment.
1c. New ScriptableObjects CandidateDefinition and CandidateDatabase in the same folder,
following SkillDefinition / SkillDatabase in style: [SerializeField] private fields with
public getters, #if UNITY_EDITOR EditorSet(...), sealed.
1d. Generator Assets/Editor/CandidateSystemSetup.cs, following SkillSystemSetup.cs: JSON →
Assets/Candidates/Definitions/<id>.asset → Assets/Candidates/CandidateDatabase.asset. Same
[DidReloadScripts] + SessionState guard. Put the "the JSON is the source of truth; .asset
files are build output and hand-edits are lost" comment at the top, as SkillSystemSetup does.
1e. CandidateDatabase.OnValidate() audit, following SkillDatabase.OnValidate():
- Candidate ids match
^[a-z][a-z0-9_]*$; no duplicates. Same for clue ids. - Exactly one clue of each of the four
CandidateEvidenceTypevalues per candidate. This is the §2.5 invariant. Assert it over the full planned set, so the design is symmetric from day one even while most clues are unwritten; reportAuthoredcoverage separately as an informationalDebug.Log, the waySkillDatabaselogs its channel tally. - Each candidate's
intuitionAxisresolves to aSkillAxis; no two candidates share one; and no candidate usesSkillAxis.Body(§2.5 — Body is deliberately unattached). - Every intuition clue's
skillIdresolves againstSkillDatabase, and that skill'sAxisequals the candidate'sintuitionAxis. - No field anywhere may express weight, truth, or ranking — see §6.3. Enforce by keeping the schema free of such fields; a reviewer reading the whole JSON must be unable to tell which candidate is "right," because none is.
1f. EditMode test Assets/Tests/EditMode/CandidateRegistryTests.cs, following
SkillRosterTests.cs: load the real CandidateDatabase.asset via AssetDatabase, assert every
invariant in 1e as hard test failures rather than only editor logs.
Extensibility is load-bearing. Adding a fourth candidate must mean adding a JSON entry, one
<<declare>>, and content — never editing a switch, an enum, or a hardcoded list of three. No code
in this phase may enumerate the three by name.
Done when: the three candidates and their twelve clue entries exist in JSON, the database asset
generates, CandidateRegistryTests passes, and deliberately breaking symmetry in the JSON (delete
one clue, duplicate a type, point two candidates at the same axis, point one at Body) makes the
test fail with a legible message.
Phase 2 — Clue-discovery state, and the lint that keeps lean honest
Hooks into: Common.yarn's Declarations node; the Phase 1 registry; Assets/Tests/EditMode/.
Adds: one declaration per clue, and one editor-time lint test. No C# runtime code.
2a. Declare a $found_<clue_id> bool per clue in Common.yarn, grouped under a comment block
that says what they are and that no code reads them. Twelve declarations at three candidates × four
types. Writers set them at the moment the clue is discovered, alongside the lean write.
Why a flag per clue rather than reading the count off lean: lean is belief, which can be moved by a
wrong-but-confident failure (§4.4). $found_* is coverage, which is what the symmetry audit and
the Phase 4 gating actually need. Conflating them would make one of the two useless.
2b. The lint test Assets/Tests/EditMode/CandidateYarnLintTests.cs. It reads the .yarn files
under Assets/Dialogue/ as text — this is a source lint, not a Yarn-runtime test — and asserts:
- Every
$self_lean_*and$found_*variable written anywhere in.yarncorresponds to a candidate or clue id in the Phase 1 registry. Catches typos and orphaned content. - Every registry clue whose
statusisAuthoredhas at least one<<set $found_<clue_id> = true>>somewhere in.yarn. Catches content marked done that was never wired. - No
.yarnfile contains a comparison of two$self_lean_*variables against each other, and no<<declare>>derives a variable from such a comparison. Comparing one lean against a constant (<<if $self_lean_journalist >= 3>>) is fine and intended; comparing two leans is the derived-winner the design forbids (§6.1, §6.2). - No node that writes
$self_lean_*or$found_*sits behind askill_rank(guard in the same node, and no<<if>>guarding a<<detour>>/<<jump>>to such a node mentionsskill_rank(. This is the exploration-gating guardrail (§6.5) made mechanical.
Be honest in the file's header comment about the limits of a regex lint: it catches the easy-and-wrong wirings, not every possible one. It is a tripwire, not a proof.
Done when: the lint passes on current content, and each of the four rules can be shown to fail by deliberately introducing the violation it targets.
Phase 3 — The authoring surface for candidate passives and active checks
Hooks into: existing Yarn commands (<<check>>, <<detour>>), Commentary.yarn's node-naming
convention, SkillCheckResultView.
Adds: documented patterns and one debug node. No new C# commands — the existing surface is
sufficient, and adding one would trade compile-time safety for a string argument (§4.1).
3a. The passive pattern (the large majority of candidate text). A clue-bearing passive is a node
in the owning scene file, spoken by one voice, reached by <<detour>> from an exploration-gated beat:
title: SC101_Hook_Journalist
---
FACEWORK: <line — FACEWORK arguing for this reading in its own biased register>
<<set $found_journalist_intuition = true>>
<<set $self_lean_journalist += 1>>
===
Rules, to be restated in each scene file's header comment:
- One voice per hook node. The voice must be from the candidate's attached pair (§2.5).
- The line is that character's argument, never a report. It must be legible as a biased reading even when it happens to be right (§6.6).
- Exactly one
$self_lean_*write per hook. - No
<<if>>guard on another hook having fired, on any$self_lean_*, or onskill_rank(...).
3b. The active-check pattern. Reuse <<check skillId band [situationalMod]>>, which writes
$check_result, $check_roll, $check_total, $check_dc, $check_modifier, $check_degree,
$check_skill and shows the roll banner. Difficulty uses the named bands (trivial, routine,
hard, specialist, build_defining → 8/11/14/17/20); raw integers work but log a warning.
Candidate checks are sparse — active checks across the whole game run at roughly one every few minutes, and only where both outcomes are worth writing. The failure branch must do one of the five things in §2.2, and carry no failure signal in the prose — the banner is the only tell.
<<check confluence hard>>
<<if $check_result>>
CONFLUENCE: <what it concludes>
<<set $found_made_asset_document = true>>
<<set $self_lean_made_asset += 1>>
<<else>>
CONFLUENCE: <a different, equally confident, wrong conclusion — unmarked>
<<set $self_lean_inheritor += 1>>
<<endif>>
Note the failure branch writing a different candidate's lean: that is the point of §4.4. A failure that writes nothing is a failure that "does nothing," which the design forbids. Which candidate a given failure feeds is a writing decision per check.
3c. Ambient candidate advocacy — lines that argue for a reading but carry no clue and no lean
— may use CommentarySystem via Commentary_{contextId}_{skillId} nodes. This is where the
"invested players hear more advocacy" property comes from (§4.3). Never put a $found_* or
$self_lean_* write in a Commentary_* node; the Phase 2 lint should be extended to assert that.
3d. Debug node Debug_CandidateCheck in Debug.yarn, exercising 3b with a seeded roll source
(SkillRuntime.rollSeed is non-zero → SeededRollSource) so both branches are reachable
deterministically.
Done when: the patterns are documented in the scene-file headers, Debug_CandidateCheck runs
both branches under a fixed seed, and the Phase 2 lint still passes.
Phase 4 — Exploration-gated clue availability
Hooks into: Common.yarn; the Phase 1 registry's unlockFlag; the Phase 2 lint.
Adds: the minimal gating vocabulary. This is new — no errand, quest, Act, or location system
exists (§1.5), and this phase deliberately does not build one (§7.2).
The constraint, restated: which candidate a player meets first must depend on where they choose to go and what they choose to do — never on which voices they have invested in, and never on a fixed script order.
4a. Two flag families, declared in Common.yarn under a comment block explaining the rule:
$visited_<location_id>(bool) — set once on first arrival at a location.$errand_<errand_id>(bool) — set when an errand reaches the state that matters.
Both are plain Yarn bools set by scene content. That is the whole mechanism for now. When a real errand/quest system arrives, it can set the same flags, and nothing authored against them breaks.
4b. Registry wiring. Each clue's unlockFlag names exactly one of those variables. Add to the
Phase 1 OnValidate and to CandidateRegistryTests:
- Every non-empty
unlockFlagmatches^\$(visited|errand)_[a-z][a-z0-9_]*$. This is the data-level enforcement: a skill threshold cannot be expressed in this field at all. It is not a free-form condition string, precisely so nobody can putskill_rank(...)in it. - Every
unlockFlagused is declared inCommon.yarn(grep the declarations file from the test). - Not every clue needs a distinct flag, and clues from different candidates may share one. What is forbidden is the pattern where all of one candidate's clues sit behind flags that are themselves only reachable in a fixed order relative to another candidate's — see 4c.
4c. The order-independence check. Add to CandidateRegistryTests: collect the set of unlock
flags per candidate and assert that no candidate's flag set is a strict superset of another's. That
is a cheap structural proxy for "no candidate is reliably surfaced after another." It is a proxy, not
a proof — say so in the test's comment. Genuine order coverage is a playtest question, and belongs in
the checklist for Phase 5.
4d. Playable verification. For SC-101, gating is already handled structurally: four independently
reachable <<detour>> hooks from one desk node. Play it in three different orders (A→B→C, C→A→B, B
alone) and confirm the accumulator totals depend only on which hooks were visited, never on the order,
and that visiting one hook never removes or unlocks another.
Done when: the flag families are declared, every clue's unlockFlag validates, the superset
check passes, and the three-order playthrough gives order-independent totals.
Phase 5 — SC-101 as the reference implementation
Hooks into: the existing Assets/Dialogue/Scenes/SC101.yarn skeleton (§1.4).
Adds: prose, the remaining clue types, and registry entries marked Authored.
SC-101 is "The Card at the Desk." Four voices — one per pair — introduce themselves. Three plant one candidate's intuition each; the fourth, from the Body pair, stays neutral. None of the four lines is proof. Each is in-character noticing.
- Write the four hook lines.
CONFLUENCE:formade_asset(parsing something too fluently, before consciously trying to),FACEWORK:forjournalist(a reaction that lands wrong and can't yet be explained),STANDING ORDER:forinheritor(correcting an etiquette rule never consciously learned), and a Body voice for the neutral beat. Which Body voice speaks —HOUSE POURorTHE LONG SHIFT— is a writing decision; the skeleton currently hasHOUSE POUR. - Write
SC101_Desk's stage-setting and exit beats. - Restate the four-voice exception in
SC101.yarn's header comment: this scene runs four voice introductions against the handbook's three-voice cap, deliberately and for this scene only (§2.7, §7.4). Writing it down in the file is what stops the next writer from either "fixing" it or copying it. - Author SC-101's non-intuition clues. The scene currently carries only the three intuition
hooks. The object / testimony / document clues for each candidate do not exist yet anywhere. They
need not all live in SC-101 — most should not — but the registry must show the full symmetric
twelve as
Plannedfrom Phase 1 onward, and each becomesAuthoredas it is written. - Flip
statustoAuthoredincandidates.jsonfor what was written, and confirm the Phase 2 lint's rule 2 passes.
Done when: SC-101 plays end to end with no TODO in the prose, the header states the four-voice
exception, all lint and registry tests pass, and the three-order playthrough from Phase 4d still gives
order-independent totals.
6. Guardrails as implementation constraints
Each of these is something a competent engineer would otherwise do by default. They are constraints,
not flavour text. Phase 1's OnValidate, Phase 2's lint, and Phase 4's registry rules are where most
of them are actually enforced.
- Never compute a winning candidate. No
GetLeadingCandidate(), no$dominant_candidate, no sort, no max, no ranking, no "which is highest" comparison — not in C#, not in Yarn, not in a debug UI, not in a log line. Comparing one lean against a constant threshold is fine and intended. Comparing two leans against each other is what must not exist. (Phase 2 lint, rule 3.) - Do not add a smart variable for lean.
Common.yarnalready contains the idiom (<<declare $is_vip = $reputation > 10>>) and it will be tempting to write<<declare $leans_journalist = $self_lean_journalist > $self_lean_made_asset>>. That is exactly the derived winner guardrail 1 forbids; being a smart variable does not make it less discoverable. - No ground-truth field anywhere. No
isTrue,isCanonical,actualIdentity, orweighton any candidate or clue record — not in JSON, not in a ScriptableObject, not in save data. - Save data must not become the leak. Persistence is a straight dump of Yarn variable storage. As long as 1–3 hold, the save file contains three integers, some bools, and no answer. Any future custom save format must preserve that property.
- Clue availability is exploration-gated, never skill-gated. Enforced at the data level: an
unlockFlagis a$visited_*/$errand_*variable name and syntactically cannot express a skill threshold (Phase 4b), and clue-bearing nodes may not sit behindskill_rank(guards (Phase 2 lint, rule 4). - No voice ever states a candidate as settled fact. Every candidate-relevant line is that voice's own biased reading, even when it happens to be right. This one is unenforceable by code and lives in the scene-file header comments and in review.
- Extensibility is load-bearing. Three candidates is the current count, not necessarily the final
one. Adding a fourth must mean a JSON entry, one
<<declare>>, and content — never editing aswitch, an enum, or a hardcoded list of three. - The two permanently unresolved lore threads are off-limits. The project has two threads, referred to internally as "Thread 2" and "Thread 9", that are designed never to resolve. The Candidate-System must stay structurally separate from them: do not resolve them, do not reference their contents, and do not map any candidate clue onto them. A repo-wide grep finds no existing representation of either thread in the codebase (§1.5), so there is currently nothing to accidentally couple to — but if such a representation ever appears (a flag, an id, a data file), candidate clue entries must not reference it, and the Phase 2 lint should grow a rule that fails if they do. This plan deliberately does not state what the two threads contain, because it does not need to know.
7. Open decisions — flag, do not resolve
Each needs the lead developer's sign-off. Where a placeholder was required to keep a phase buildable, it is marked provisional and is safe to change later.
7.1 — How lean is surfaced, and whether it decays or caps.
Settled: lean accumulates from play, not from one choice. Open: whether the player's accumulated
lean is ever surfaced back to them during play, only reflected at the end, or stays invisible
entirely; and whether it decays over time, caps at a ceiling, or grows without bound. Also open:
whether +1 per clue stays the only increment size, or larger commitments weigh more, and whether
any threshold means anything mechanically.
Provisional default, to keep Phases 2–5 buildable: +1 per clue moment, no cap, no decay, never
surfaced in UI. Nothing in this plan forecloses the alternatives — lean is a plain number in Yarn
storage, so changing the arithmetic is a content edit, and "surface it" is additive UI work that has
been given no hooks in either direction.
7.2 — The exploration-gating host system has no owner.
The design requires clue availability to be errand- or location-gated. No errand, quest, Act, or
location system exists, and docs/skill-system-refactor-plan.md §6 explicitly declined to build one.
Phase 4 introduces two flag families as the minimal vocabulary, which is enough for SC-101 and for
one or two more scenes. The moment candidate evidence spans several scenes, this becomes a hard
dependency on a system somebody has to design. Flagging it as an open dependency — and specifically
warning against the tempting workaround of building a bespoke candidate-only gating mechanism, which
would be exactly the parallel system this feature is supposed to avoid.
7.3 — Whether the Inheritor ties into the journalist-hook NPC.
There is a possible narrative link between the Inheritor candidate and an existing NPC hook. This is
a lore call, not a mechanical one, and this plan must not decide it. Do not treat any link as canon
and do not wire anything to it. For reference: docs/candidate-system-plan.md §7.6 names a specific
NPC as the candidate for this link and also marks it undecided; no NPC by that name exists in the
codebase, which contains only Bouncer.yarn.
7.4 — The four-voice opening against the three-voice cap.
SC-101 introduces four voices; the writing handbook (not in this repo) caps introductions at three.
This is a deliberate, flagged exception for the opening scene, not an oversight to correct and not
a new precedent to propagate. Recorded here and in SC101.yarn's header so that neither happens by
accident. If the lead developer wants the cap revisited generally, that is a separate handbook
decision.
7.5 — Voice investment has no mechanism.
"A player who has invested in a voice pair hears more advocacy for that pair's candidate" presumes
skill ranks change during play. They do not: PlayerSkills.SetRank has no caller, ranks come from
startingRank, and they are not persisted (§1.5). Nothing in this plan is blocked by that — the
rank-weighted CommentarySystem path (§4.3) will simply deliver a flat distribution until a
progression mechanism exists. Worth deciding whether progression is on the roadmap, since it changes
how much the ambient-advocacy channel is worth investing in.
7.6 — When does the game save? Phase 0 proves the mechanism works. No call site exists. Somebody has to choose scene-exit vs. autosave vs. manual. Separate ticket.
7.7 — Total candidate touchpoints across the game. SC-101 carries the first three intuition hooks plus one neutral beat. The full count of candidate moments across the game is unknown. It is the input to how much the Phase 1 registry earns its keep, and to §7.1's question about increment sizes.
8. Traps
8.1 — Yarn variables must be declared or they are compile errors.
Every variable in this project is declared in Common.yarn. A <<set $found_journalist_object = true>>
without the matching <<declare>> fails to compile rather than silently creating a variable. This is
the feature, not the bug — it is what makes plain <<set>> safer than a string-keyed C# command
(§4.1).
8.2 — CommentarySystem is the wrong vehicle for clue-bearing lines.
It picks one voice per request, weighted by rank, gated by a per-skill budget
(3600 / targetFiringsPerHour seconds), a global minSecondsBetween floor, a minRankToSpeak
threshold, and a recency penalty — and it refuses to fire while dialogue is running. Clue lines must be
deterministic and each independently discoverable. Route them through scene nodes via <<detour>>
(§4.3, Phase 3a).
8.3 — Node lookups are case-sensitive; skill lookups are not.
SkillDatabase and PlayerSkills use OrdinalIgnoreCase, but CommentarySystem.NodeExists uses
Ordinal. So <<check PLACEMENT routine>> works while Commentary_Entrance_Queue_PLACEMENT silently
never matches. Keep every authored id lowercase.
8.4 — Ids become node-title and variable-name fragments.
This is why candidate and clue ids follow ^[a-z][a-z0-9_]*$. An id with a space or a hyphen produces
a node title or variable name that either fails to compile or silently never matches.
8.5 — Generated assets are overwritten on script reload.
SkillSystemSetup.AutoSetupAfterReload regenerates every skill definition and the database once per
editor session from skill_bible.json. Phase 1's generator follows the same pattern, so the same rule
applies to candidates: the JSON is the source of truth; .asset files are build output. Hand-edits
to .asset files are lost.
8.6 — Do not delete or recreate SkillDatabase.asset.
Assets/Scenes/DialogueTest.unity wires SkillRuntime.database and CommentarySystem.database to it
by GUID. Nothing in this plan should touch it, but it is the standing landmine in this project. Phase 1
creates a separate CandidateDatabase.asset; wiring it into the scene (if it ever needs to be) is
additive and must not disturb the skill database's GUID.
8.7 — Declared Yarn numbers are floats.
<<declare $x = 0 as number>> stores a float and serialises into floatKeys/floatValues. Integer
comparisons behave correctly; do not be surprised by 0.0 in the save JSON.
8.8 — float is a keyword.
Restated from docs/skill-system-refactor-plan.md §7.5 because it will come up: the id for THE FLOAT
is the_float.
9. Definition of done
Per phase. Phases can land in separate commits; each should leave the project green.
Phase 0
Debug_CandidateLeanprintsmade_asset 2 / journalist 1 / inheritor 0and takes the threshold branch.- Lean values and
$seen_sc_101survive a save → exit Play → load round trip; verified in the JSON underApplication.persistentDataPath. - The temporary verification script is deleted.
Phase 1
Assets/Candidates/candidates.jsonholds three candidates × four clue entries, one of each type.CandidateEvidenceType,CandidateDefinition,CandidateDatabase, andCandidateSystemSetupexist, following the skill-system idiom.CandidateDatabase.OnValidate()audits ids, per-candidate type symmetry, unique non-Body intuition axes, and intuitionskillId→SkillDatabaseresolution.CandidateRegistryTestspasses, and fails legibly when symmetry, axis uniqueness, or the Body exclusion is deliberately broken.- No code enumerates the three candidates by name.
Phase 2
Common.yarndeclares one$found_<clue_id>bool per registry clue, under an explanatory comment block.CandidateYarnLintTestsimplements all four rules and each can be shown to fail on a deliberate violation.
Phase 3
- Passive and active-check patterns are documented in the scene-file header comments.
Debug_CandidateCheckexercises both branches deterministically under a fixed roll seed.- The failure branch writes lean (§4.4) and carries no failure signal in its prose.
- No
Commentary_*node contains a$found_*or$self_lean_*write. - Zero new Yarn commands; zero changes to
SkillRuntime,SkillCheckSystem,YarnSkillCommands, or the skill roster.
Phase 4
$visited_*/$errand_*families are declared inCommon.yarnwith the gating rule stated.- Every clue
unlockFlagmatches^\$(visited|errand)_[a-z][a-z0-9_]*$and is declared. - The no-superset order-independence check passes.
- SC-101 played in three different hook orders gives order-independent totals.
Phase 5
- SC-101 has no
TODOprose; four voices, three planting intuitions, the Body voice neutral. SC101.yarn's header states the four-voice exception, the one-of-each-evidence-type rule, and the discovery-order-independence rule.- Authored clues are flipped to
Authoredincandidates.jsonand pass lint rule 2.
Every phase
- The yarnproject reimports with no compiler diagnostics.
grep -rniE "leading_?candidate|dominant_?candidate|winning_?candidate|actual_?identity|is_?canonical" NightclubArcadia/Assetsreturns nothing.- EditMode tests pass:
/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity -batchmode -quit -nographics -projectPath /Users/lennart/Dev/nightlcub-arcadia/NightclubArcadia -runTests -testPlatform EditMode -testFilter "NightclubArcadia.Skills.Tests" -testResults /tmp/candidate-tests.xml -logFile -