The game now starts from Bootstrap.unity, which additively loads Systems (the dialogue runner, skills, UI layer, camera rig and the player) and then a level (geometry, light, navmesh, reveal cameras). The level becomes the active scene so new objects and lighting land there. The player moved into Systems. It had been parented under Room_101 — under level geometry — and it is persistent content, not level content. Moving it also removes most of the cross-scene breakage on its own. Unity nulls any serialized reference that crosses a scene boundary. Measured rather than guessed: the pre-split scene was pulled from git and its null references diffed against the split result. Exactly seven were lost — both NPCs' dialogueRunner, dialogueUI and player, plus the reveal camera's tracking target. A first naive audit reported 210, which turned out to be pre-existing Unity defaults like Image.m_Material; the baseline diff is what separated the two. Each of the seven now resolves at runtime. SceneServices.Resolve fills in a null Inspector reference by searching the loaded scenes, keeping an assigned value if there is one; CinemachineFollowsPlayer binds the reveal camera once the player exists. Both follow the pattern already used by CameraFramingVolume and by NPCStandIn's player lookup, rather than introducing a new one. Assets/Tests/PlayMode is new, and it immediately paid for itself. BootstrapTests loads Bootstrap and asserts the whole game comes up; on its first run it caught a real bug the split had introduced. The player's NavMeshAgent lives in Systems while the NavMesh is baked into the level, so during load the agent exists off-mesh and ResetPath logs an error. ClickToMoveController now guards on agent.isOnNavMesh rather than a bare null check. No EditMode test could have seen that, because none of it has run yet. Two of those checks reach their types by name through reflection: NPCStandIn and the Cinematics namespace live in Assembly-CSharp, and an asmdef test assembly cannot reference the predefined assemblies. Per-area asmdefs remove the need. Build settings list all three scenes with Bootstrap at index 0, which LoadSceneAsync by name requires. EditMode 42/42, PlayMode 5/5, YarnCheck 9 files / 35 nodes. Co-Authored-By: Claude Opus 5 <[email protected]>
24 KiB
Nightclub Arcadia — agent reference
Reference for coding agents (Claude Code and others) working in this repository.
Human-facing design docs live in docs/. This file is the operational one: where
things are, what is generated, and how to build/test/validate from the terminal.
Companion: AGENTS.md (pointer file, same content applies).
1. Repository layout
Repo root is nightlcub-arcadia (the typo is in the directory name, not a mistake
to fix — it is the checkout path). The Unity project is a subdirectory:
/Users/lennart/Dev/nightlcub-arcadia/ # git root
├── CLAUDE.md, AGENTS.md # this reference
├── docs/ # design + plan docs (markdown)
├── writing/ # writer-facing context — see §4
├── tools/YarnCheck/ # .NET 9 Yarn compiler/player, no Unity needed
├── tools/writing/ # voice-sheet sync (md <-> skill_bible.json)
├── .gitignore # macOS / IDE / toolchain rules
└── NightclubArcadia/ # ← the Unity project (-projectPath)
├── .gitignore # Unity rules (anchored, must stay here)
├── Assets/
├── Packages/ # incl. embedded dev.yarnspinner.unity
└── ProjectSettings/
Two .gitignore files, deliberately. The Unity one anchors every rule with a
leading /, so it only works at the root of the Unity project folder. Do not merge them.
Unity 6000.5.8f1, URP 17.5.0, Cinemachine 3.1.7, Input System 1.20.0,
AI Navigation 2.0.14, ProBuilder 6.1.2, Test Framework 1.7.0. Yarn Spinner is an
embedded package at NightclubArcadia/Packages/dev.yarnspinner.unity (plus the
textanimator addon) — it is committed, not resolved from a registry.
2. Ground rules
- Never hand-edit generated
.assetfiles.Assets/Skills/Definitions/*.asset,Assets/Skills/SkillDatabase.asset,Assets/Candidates/Definitions/*.assetandAssets/Candidates/CandidateDatabase.assetare build output. Their sources areAssets/Skills/skill_bible.jsonandAssets/Candidates/candidates.json. Edits are overwritten by the Editor setup scripts. Edit the JSON. - Never hand-edit scene or prefab YAML unless you have read the surrounding block.
GUID/fileID surgery in
.unity/.prefabfiles silently detaches components. - One Unity instance per project path. Unity refuses to open a project in batch
mode while the Editor has it open — a single instance at a time. Always check before
any
-batchmodeinvocation (§3.1). - Prefer YarnCheck over Unity for dialogue validation. It is seconds, not minutes, and needs no Editor lock (§5.5).
- Do not commit on the user's behalf unless asked. Unity writes to tracked files
(
ProjectSettings/*,.meta, scenes) as a side effect of simply being open, sogit statusnoise is expected and is not always yours.
2b. Everyday commands
A Makefile at the repo root wraps everything in this document. Prefer it — the exact
invocations live there instead of being retyped.
make # list targets
make check # yarn + voice sheets + EditMode — run this before committing
make test # EditMode suite, with a readable summary
make yarn # compile every Yarn script (seconds, no Unity)
make build # macOS player into build/
make lock # fail if the Editor has the project open
make test and make build refuse to run while the Editor is open, which removes the most
common way these commands fail confusingly.
Merge driver — one-time per machine
.gitattributes marks Unity YAML for unityyamlmerge. Git needs to be told what that is, or
it falls back to a line-oriented text merge that can produce a scene Unity still loads and that
is quietly wrong:
make merge-driver
That points git at UnityYAMLMerge inside the Editor bundle (Contents/Helpers/, not
Contents/Tools/ — the path in most online guides is wrong for Unity 6 on macOS).
3. Unity from the command line
3.0 Two different tools, both present
| What it is | Status | |
|---|---|---|
/Users/lennart/.unity/bin/unity |
The Unity CLI (v1.0.0-beta.6). Wraps the Editor for CI-shaped work: build, test, run, open, plus editor/license/cloud management. |
Installed. Prefer this. |
/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity |
The Editor executable. Raw -batchmode flags. |
Installed. Fallback, for flags the CLI does not expose. |
The CLI is not on PATH for non-interactive shells — which unity fails even though the
binary exists, and unity doctor reports check.binary-on-path warn. Always invoke it by
absolute path, or export it:
export UNITY_CLI="/Users/lennart/.unity/bin/unity"
export UNITY="/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity"
export UNITY_PROJECT="/Users/lennart/Dev/nightlcub-arcadia/NightclubArcadia"
unity doctor --no-banner confirms the environment: signed in via OAuth keyring, editor
6000.5.8f1 arm64 resolved from ProjectVersion.txt. Add --json to any command for
machine-readable output and --no-banner to keep logs clean.
3.1 Always check the lock first
Only one Unity instance per project path. Check before any headless run:
pgrep -fl "Unity.app/Contents/MacOS/Unity" | grep -v AssetImportWorker
AssetImportWorker* processes are children of the main Editor — filter them out or you will
misread the check. Temp/UnityLockfile and unity status are the other two signals.
3.2 Run tests
"$UNITY_CLI" test "$UNITY_PROJECT" --no-banner --mode EditMode \
--output /tmp/editmode-results.xml \
--report-format both --junit-output /tmp/editmode-junit.xml
Useful flags: --filter <pattern>, --retries <0-10> (reports tests that pass on retry as
flaky), --rerun-failed, --shard N/M for parallel CI, --coverage, --timeout <seconds>.
--mode PlayMode runs Assets/Tests/PlayMode — currently BootstrapTests, which loads
Bootstrap.unity and asserts the whole game comes up: both scenes loaded, the player present
and tagged, the dialogue runner found, both NPCs having resolved their runner across the scene
boundary, and the reveal camera bound to the player. If that suite fails, the game does not
start.
Raw-Editor equivalent, if you need a flag the CLI does not expose:
"$UNITY" -batchmode -nographics -projectPath "$UNITY_PROJECT" \
-runTests -testPlatform EditMode \
-testResults /tmp/editmode-results.xml -logFile /tmp/unity-editmode.log
Do not add -quit to a -runTests run — the runner terminates the Editor itself, and
-quit on top of async work can hang it.
Parse the results XML; do not trust the exit code. Unity's own docs state there is no common
exit-code definition across the components under test. Read total/passed/failed off the
root element and list any test-case whose result is not Passed.
3.3 Build a player
"$UNITY_CLI" build "$UNITY_PROJECT" --no-banner \
--target StandaloneOSX -o /tmp/NightclubArcadia.app
Without --execute-method the CLI uses Unity's built-in build — a Build Profile on Unity 6+, or
the legacy desktop player flags — and builds whatever scenes EditorBuildSettings enables.
--profile <name> selects a profile from Assets/Settings/Build Profiles (the profile defines
the target). -l/--log-file overrides the log path; the log streams to console unless --no-tail.
No build script exists yet. When one is added, put it at Assets/Editor/BuildPipeline.cs and
call --execute-method NightclubArcadia.Editor.BuildPipeline.BuildMacOS. The CLI forwards the
output path as -buildOutput and your method is responsible for honouring it. A build method
must call EditorApplication.Exit(1) on failure, or Unity exits 0 and CI reports a green build
that produced nothing.
3.4 Run an arbitrary Editor method
"$UNITY_CLI" run "$UNITY_PROJECT" --no-banner --timeout 900 -- \
-executeMethod SomeClass.SomeStaticMethod
unity run owns -batchmode, -nographics, -quit and -logFile. Passing any of them
after -- fails with a reserved-flag error. Pass only your own arguments.
Output does not stream to stdout. The log lands at $UNITY_PROJECT/Logs/Editor.log (the
previous run rotates to Editor-prev.log), so tag your Debug.Log calls and grep for them:
grep -n "\[YourTag\]" "$UNITY_PROJECT/Logs/Editor.log" | tail -20
This is the mechanism for the JSON → ScriptableObject regeneration in
Assets/Editor/SkillSystemSetup.cs and CandidateSystemSetup.cs. Read the actual class and
method names out of those files before invoking — do not guess them.
4. The dialogue content pipeline
4.1 Where content lives
NightclubArcadia/Assets/Dialogue/
├── NightclubArcadia.yarnproject # globs **/*.yarn — new files need no registration
├── Common.yarn # every <<declare>>, project-wide. Never played.
├── Commentary.yarn # passive skill barks, one node per (context, skill)
├── Scenes/ SC101, SC102, Debug
├── Characters/ Bartender, Bradford Kane, Chevalier Cassian Thal
└── Objects/ Chair
The writer-facing context sits at the repo root, outside Assets/ so it gets no .meta files:
The scenes are split three ways, loaded additively from Bootstrap:
Assets/Scenes/
├── Bootstrap.unity entry point; GameBootstrap loads the rest
├── Systems/Systems.unity dialogue, skills, UI, camera rig, and the PLAYER
└── Levels/
├── SC101_ConferenceHall.unity geometry, light, navmesh, reveal cameras
└── SC101_ConferenceHall/ baked data for that scene (NavMesh asset)
Bootstrap must stay index 0 in the build settings. The level is made the active scene after
load, so new objects and lighting land there rather than in Bootstrap.
writing/
├── README.md session entry point
├── STYLE.md register, tone moves, ban list, branching rules ← read first
├── YARN-PRIMER.md the one page of syntax a writer needs
├── GLOSSARY.md in-world and project vocabulary
├── voices/ the eleven skill voices, one sheet each (generated — see §4.7)
├── characters/ named NPCs, dialogue-facing
├── scenes/ per-scene briefs: intent, beats, checks, constraints
└── lore/ candidates.md — the three identity readings and their hard rules
Design material deeper than this lives in Obsidian, not the repo:
~/Obsidian-Vaults/obsidian-vault/Projects/Active/NightclubArcadia/. Ground Truth.md there
is the solution document — never copy, quote, or summarise it into this repo, including into
commit messages. The game's premise is that the protagonist's identity never resolves.
Dialogue is already plain text in the repo. It is authored in .yarn files, not inside
the Unity Editor, and any new .yarn file dropped anywhere under Assets/Dialogue/ is picked
up automatically by the **/*.yarn glob in the .yarnproject. Adding a scene or a character
requires no C#, no Unity, and no .yarnproject edit.
The one caveat: the .yarnproject also stores editorOptions.yarnScriptEditor.projectGraphPositions
— per-file x/y coordinates for the Yarn graph editor. These churn whenever anyone opens the
graph view. Treat that block as editor state; do not fight over it in review.
4.2 The variable contract
Every variable used anywhere must be <<declare>>d in Common.yarn. This is the whole point
of that file: the compiler then catches $reputaton instead of silently creating a second
variable at runtime. Adding a scene flag means adding a <<declare>> line — that is a writer
action, not a code action.
Naming families that are load-bearing (enforced or lint-checked):
| Family | Meaning | Checked by |
|---|---|---|
$self_lean_<candidate_id> |
identity-lean counter | CandidateYarnLintTests — id must exist in candidates.json |
$found_<clue_id> |
clue coverage | CandidateYarnLintTests — id must exist in candidates.json |
$visited_<location_id> |
first arrival | convention; unlockFlag in candidates.json may only name these |
$errand_<errand_id> |
errand state | same |
$seen_<scene_id> |
scene witnessed | convention |
$check_* |
written by <<check>> |
never <<set>> by hand |
4.3 Writer-facing Yarn vocabulary
Commands and functions registered by game code, i.e. the full set a writer may use:
| Syntax | Defined in | Notes |
|---|---|---|
<<check <skill> <Band>>> |
Skills/Yarn/YarnSkillCommands.cs |
writes $check_result, $check_roll, $check_total, $check_dc, $check_modifier, $check_degree, $check_skill |
<<skill_mod <source> <skill> <n>>> |
same | source = bucket key, e.g. scene, drunk |
<<clear_skill_mods <source>>> |
same | |
skill_rank(<skill>) |
same | function |
top_skill(a, b, …) |
Dialogue/DialougueSkillComparison.cs |
returns its FIRST argument on a tie — argument order is a design decision, not incidental |
alias_name(<name>) |
Dialogue/AliasNameGenerator.cs |
deterministic; $player_alias is a smart variable derived from it |
<<enter_environment>> |
Dialogue/DialogueTransitionController.cs |
hands control back to the world |
(The filename DialougueSkillComparison.cs is misspelled in the repo. Left alone deliberately —
renaming it changes a .meta GUID. Fold it into a rename batch, not a drive-by fix.)
4.4 Flow control discipline
<<detour>> returns to the caller; <<jump>> does not and clears the return stack.
The rule the existing files follow, and which new content must follow:
- A node started directly by
DialogueInteractablehas nothing waiting on it → its branches<<jump>>, and each destination calls<<enter_environment>>itself. - A sub-conversation that must resume its caller →
<<detour>>, and never<<jump>>inside it.
Nodes sharing a title: form a node group; Yarn runs the most specific variation whose
when: conditions pass. Prefer adding a variation over growing an <<if>> chain.
4.5 How content reaches Unity
There is no manual sync step for dialogue. The chain is:
.yarn file saved → Unity's YarnProjectImporter recompiles the .yarnproject on focus
→ DialogueRunner plays the new nodes on next Play
The only things that need a human/Editor step are the data files, not the dialogue:
skill_bible.json → Tools ▸ Nightclub Arcadia ▸ (SkillSystemSetup) → Skills/Definitions/*.asset + SkillDatabase.asset
candidates.json → Tools ▸ Nightclub Arcadia ▸ (CandidateSystemSetup) → Candidates/Definitions/*.asset + CandidateDatabase.asset
Those setup scripts also register a [DidReloadScripts] callback, so in practice the assets
regenerate on script reload too. The JSON is the source of truth either way.
4.6 The voice-sheet pipeline
The Skill Bible prose used to live as a \n-escaped string inside skill_bible.json, which no
writer could comfortably read or revise. It now lives in writing/voices/*.md, and truth flows
in opposite directions for the two halves of each sheet:
PROSE writing/voices/<id>.md -> skill_bible.json -> Skills/Definitions/*.asset
MACHINE skill_bible.json -> the "Machine fields" table in the .md
python3 tools/writing/sync_voices.py --check # verify; exit 1 on drift
python3 tools/writing/sync_voices.py --to-json # fold edited prose into the bible
python3 tools/writing/sync_voices.py --to-md # refresh the machine table from the bible
tools/writing/voicelib.py does the parsing and guarantees notes -> dict -> notes is identity
for all eleven skills. --to-json on unchanged sheets is byte-identical, so it is safe to run
at any time.
VoiceSheetSyncTests (EditMode) enforces the same invariant, so drift fails the build instead
of silently shipping a voice that no longer matches the sheet the next scene is written against.
It deliberately does not reimplement the markdown parser in C# — it asserts the shipped prose
appears verbatim in the sheet, which catches drift in either direction with no second parser to
keep in step.
4.7 Code-owned vs writer-owned
| Writer-owned (edit freely, no Unity) | Code-owned (needs an engineer) |
|---|---|
writing/** |
Assets/Scripts/**/*.cs |
Assets/Dialogue/**/*.yarn |
Assets/Editor/**/*.cs |
<<declare>> lines in Common.yarn |
Yarn command/function implementations |
writing/voices/*.md (prose → the bible, §4.6) |
Assets/Skills/skill_bible.json (machine fields) |
Assets/Candidates/candidates.json (status, sceneId, notes, unlockFlag) |
.asset, .prefab, .unity, .meta |
docs/ writing docs |
ProjectSettings/** |
A writer must never need to open Unity or touch C# to write or revise a scene, a character, or a branch. If a writing task requires either, that is a pipeline bug — file it.
5. Validation
5.1 EditMode test suite
NightclubArcadia/Assets/Tests/EditMode/ — 7 files, ~870 LOC, two assemblies:
NightclubArcadia.Skills.Tests—SkillRosterTests,SkillCheckSystemTests,DcBandTests,CommentaryPickerTests,CandidateRegistryTests,CandidateYarnLintTestsNightclubArcadia.Locomotion.Math.Tests—MotionInputMathTests
CandidateYarnLintTests is a regex lint over Yarn source: it asserts every
$self_lean_* / $found_* in any .yarn file resolves to a real id in CandidateDatabase.
It is a tripwire, not a proof.
5.2 PlayMode suite
Assets/Tests/PlayMode/BootstrapTests.cs. Verifies the scene split at runtime — the thing no
EditMode test can see, because none of the runtime resolution has happened yet.
Two of its checks reach their types by name through reflection. NPCStandIn and
NightclubArcadia.Cinematics live in Assembly-CSharp, and an asmdef test assembly cannot
reference the predefined assemblies — only the reverse. Giving the runtime code its own asmdefs
removes the need; until then the reflection is confined to those two tests.
5.3 Voice-sheet sync
python3 tools/writing/sync_voices.py --check
Seconds, no Unity. Run it after touching writing/voices/*.md or skill_bible.json (§4.6).
5.4 One-click in-Editor run
Tools ▸ Nightclub Arcadia ▸ Run Skill EditMode Tests (Assets/Editor/SkillSystemTestRunner.cs).
5.5 YarnCheck — validate dialogue without Unity ⭐
tools/YarnCheck is a .NET 9 console app that compiles and plays the Yarn scripts using
the exact Yarn Spinner DLLs shipped in Packages/dev.yarnspinner.unity/Runtime/DLLs, so it
can never drift from the compiler the game uses. This is the fastest correctness signal in the
repo and it needs no Editor lock. Use it on every dialogue change.
cd /Users/lennart/Dev/nightlcub-arcadia/tools/YarnCheck
# compile everything — exit 1 + diagnostics on failure
dotnet run -- ../../NightclubArcadia/Assets/Dialogue
# compile, then play a node, choosing option 2, 2, 0, 0
dotnet run -- ../../NightclubArcadia/Assets/Dialogue Bartender_Talk 2 2 0 0
Leftover picks restart the node — that deliberately models the player walking away and coming back, which is how these scenes are actually played.
What it does not model (read traces with this in mind): Unity commands are printed, not
executed, so <<check>> / <<enter_environment>> / <<skill_mod>> are stepped over and
$check_result stays false — every check-gated branch shows its failure side. Yarn
functions are stubbed: top_skill mirrors the real first-argument-wins tie behaviour,
alias_name returns a placeholder, skill_rank returns 0. Variables reset to their
<<declare>> defaults each run; there is no save state.
Requires the .NET 9 SDK. tools/YarnCheck/bin/ and obj/ are currently committed and
should not be — see docs/restructure-plan.md.
6. Code conventions
- Namespaces:
NightclubArcadia.<Area>—Skills,Dialogue,UI,UI.Dialogue,UI.HUD,UI.Menu,Cinematics,Interaction,Player,Player.Math. Exception:NPCStandIn.cssits in the global namespace. That is a scaffold tell, not a convention to copy — new runtime types always get a namespace. - Assembly definitions exist for
NightclubArcadia.SkillsandNightclubArcadia.Locomotion.Mathonly. Camera, Player, UI, Dialogue, NPCs and Interaction all currently compile into the defaultAssembly-CSharp. Widening asmdef coverage is planned, not done. - Comments in this codebase carry decision history — why a flag is written at the bottom of a node rather than the top, why four voices in SC-101 breaks the three-voice cap on purpose. Match that density. Do not strip such comments as "noise"; they are the design record.
- Editor-only code lives under
Assets/Editor/. Anything there is stripped from builds and may referenceUnityEditor.
7. Traps
-
A scene silently turns binary if it holds an object that prefers binary serialization. This one cost real time.
DialogueTest.unitywas binary for weeks despite the project being set to Force Text, because theNavMeshSurfaceheld its bakedNavMeshDataembedded in the scene rather than as an asset. One such object forces the whole file to binary, and no amount of re-saving or toggling the setting fixes it — Force Text only converts at the moment the setting changes. The fix is to extract the data to its own asset, which is what baking from the NavMeshSurface inspector produces anyway. If a scene goes binary again, bisect: move the roots one at a time into a fresh scene and see which one yields a binary file. -
Serialized references cannot cross a scene boundary. Unity nulls them. The split cost exactly seven: both NPCs'
dialogueRunner,dialogueUIandplayer, plus the reveal camera's tracking target. All are resolved at runtime now —SceneServices.Resolvefor the first,CinemachineFollowsPlayerfor the last. Anything new that a level object needs from Systems must follow that pattern, andBootstrapTestsis where you prove it works. -
The player's NavMeshAgent outlives the NavMesh. The player is in Systems, the NavMesh is baked into the level, so there is a window during load where the agent exists and is not on a mesh.
ResetPath,isStoppedandSetDestinationall log errors in that state. Guard onagent.isOnNavMesh, not just a null check. -
An asmdef assembly cannot reference
Assembly-CSharp. Only the reverse. Most runtime code here has no asmdef, so a test assembly cannot seeNPCStandInorNightclubArcadia.Cinematicsdirectly — hence the reflection inBootstrapTests. Adding per-area asmdefs removes this. -
unity runreserves-batchmode,-nographics,-quit,-logFile. Passing any of them after--is a hard error, and its output does not reach stdout — readLogs/Editor.log(§3.4). -
-quitwith-runTestshangs or truncates. Omit it (§3.2). -
Exit code alone is not a test verdict. Parse the results XML (§3.2), which is what
make testdoes. -
which unityfails in non-interactive shells even though the CLI is installed. Use the absolute path/Users/lennart/.unity/bin/unity(§3.0). -
Editor lock — check before every headless run, filtering out
AssetImportWorkerchildren.make testandmake builddo this for you (§2b). -
Regenerated assets — hand edits to
Skills/Definitions/*,Candidates/Definitions/*and the two*Database.assetfiles are silently discarded (§2.1). -
top_skillties resolve to the first argument. Reordering arguments changes the game. -
Bootstrapmust stay index 0 in the build settings, and all three scenes must be listed, orLoadSceneAsyncby name fails at runtime with nothing but a console error.