ADR-0004: Organise Scripts/ by feature, not by language construct
- Status: Accepted
- Date: 2026-08-22
- Supersedes:
STRUCTURE_CONSOLIDATION_PLAN.md(2026-07-24, untracked, repo root)
Context
Section titled “Context”Assets/RacoonRiot/Scripts/ holds 100 .cs files in 66 directories. Thirty-nine of those
directories contain exactly one .cs file and no subdirectory.
The top level splits by C# language construct:
Scripts/Core/Enums/ 4 filesScripts/Core/Interfaces/ 14 filesScripts/Core/Structs/ 2 filesScripts/Core/Superclasses/ 2 filesScripts/Core/ReferenceHolders/ 2 filesScripts/Core/Events/{Common,Local,Networked}/ 16 filesScripts/System/{Common,Items,Managers,Misc,Player,Tasks,Traps}/Working on the player today means holding eight top-level branches open at once — Core/Interfaces
for IPlayer, Core/Events/Local for PlayerEvents, Core/Enums for LocalOwnership,
System/Player/Base, System/Player/Input, System/Player/Camera, System/Player/Modules/<one folder per module>, and System/Common/PromptBuilder — for 25 files. The grouping tells you what
kind of C# a file contains, which you already know from opening it, and hides what feature it
belongs to, which is the only thing you were actually looking for.
Where the sprawl came from
Section titled “Where the sprawl came from”It is a fossil, not a design. On 2026-07-24 the project had 42 asmdefs, one per leaf directory. Every one of those tiny folders existed to hold an assembly definition. Two days later the assemblies were consolidated to five. The asmdefs went; the folders stayed. The directory granularity has had no justification since.
STRUCTURE_CONSOLIDATION_PLAN.md, written the day before that consolidation, states “The mess is
NOT the code structure” and “Code layering is already sound … one asmdef per leaf, path-matched
rootNamespace”. That was true of the 42-assembly project it described. It is not true of this one,
and its remaining six-step sequence (merging GameProper/ into RacoonRiot/) has already been
carried out. This ADR supersedes it.
Secondary defects the current layout hides
Section titled “Secondary defects the current layout hides”Coreis not a leaf, and lies about it.Core/ReferenceHolders/TaskSlot.cslives in theRacoonRiot.Coreassembly but declaresnamespace RacoonRiot.System.Managers.TaskManager.Core/Interfaces/ITaskUIManager.csthen writesusing RacoonRiot.System.Managers.TaskManagerand appears to reference gameplay from Core. It compiles only because the namespace is a fiction — the type it resolves to isTaskSlot, two folders away in the same assembly.- Six files sit in the global namespace:
PlayerManager,SingletonCameraController,TaskCommunication,PlayerLook, and five of the six files underScripts/Debug/. - Folder and namespace disagree in at least four places:
Misc/Prompts/PromptView.csdeclaresSystem.Common.Prompts;Modules/PlayerRenderer/declaresModules.Renderer;Modules/PlayerAttack/declaresModules.Attack. System/Player/Modules/names modules two different ways —Jump,Look,Walking,Inventory,InteractionbesidePlayerAttack,PlayerRoll,PlayerCamera,PlayerRenderer.- Two camera controllers.
System/Player/Camera/SingletonCameraController.cs(a bareDontDestroyOnLoadsingleton with no behaviour, see RIOT-200) andSystem/Player/Modules/PlayerCamera/CameraController.cs(the real one). RacoonRiot.Systemshadows the BCLSystemnamespace — RIOT-35.
Decision
Section titled “Decision”Adopt the Paradise Fleet layout: a Contracts/ assembly organised by domain, and a
Features/<X>/{Runtime,Tests}/ tree with one assembly per feature.
Enums/, Interfaces/, Structs/, Superclasses/, ReferenceHolders/ and Misc/ are removed
outright. Splitting by architectural role (contracts vs features vs tests) and by domain is
kept; splitting by language construct is not.
Target tree
Section titled “Target tree”Assets/RacoonRiot/Scripts/├── Contracts/ → RacoonRiot.Contracts│ ├── Common/ IIdentified, ICategorised, IReadOnlyReference, IColliderForwarding,│ │ VectorReference, BasicResponses, GeneralEvents│ ├── Game/ GameState, GameManagementEvents, SceneManagerEvents, InitEvents│ ├── Interaction/ IInteractable, IActivatable, IHighlightable, IPromptView,│ │ InteractionType, PromptConfig, PromptData│ ├── Items/ IItem, ItemState, CommonObjectEvents, OverrideEvents, ModifyItemOwnership│ ├── Networking/ INetworkIdentified, INetworkOwned, LocalOwnership, TransportFront│ ├── Player/ IPlayer, PlayerEvents, PlayerManagerEvents, SpawnPointEvents,│ │ VelocityCompilerEvents│ ├── Status/ StatusEffectBase, StatusEvents, ModifierEvents│ ├── Tasks/ ITask, ITaskUIManager, TaskSlot, TaskEvents│ └── AssemblyInfo.cs├── Features/│ ├── Actors/{Runtime,Tests}/ → RacoonRiot.Actors│ ├── Dev/Runtime/ → RacoonRiot.Dev│ ├── Items/{Runtime,Tests}/ → RacoonRiot.Items│ ├── Player/{Runtime,Tests}/ → RacoonRiot.Player│ ├── Session/{Runtime,Tests}/ → RacoonRiot.Session│ ├── Tasks/{Runtime,Tests}/ → RacoonRiot.Tasks│ └── Traps/{Runtime,Tests}/ → RacoonRiot.TrapsEditor/ and Tests/ are siblings of Scripts/, not children, and stay where they are. The 14
existing tests are project-wide invariants — scene YAML, PurrNet settings, codegen — not feature
tests, so they belong in a root Tests/. Features/<X>/Tests/ folders appear when a feature grows
tests of its own.
The result is 31 directories, down from 66. Two leaf directories still hold one file each —
Features/{Items,Traps}/Runtime/Variants/ — and both stay: a Variants/ folder is a category that
expects siblings, which is the opposite of the accidental one-file folders being removed here.
Player work becomes two folders: Contracts/Player/ and Features/Player/Runtime/. Contracts
averages five files across eight domain folders, against 41 files across nine construct folders now.
Assembly graph
Section titled “Assembly graph”One direction, no cycles. Contracts has no first-party dependency.
Contracts ──┬── Actors ──┬── Player ──┬── Session │ │ │ ├── Items ───┼── Tasks └── (Session → Dev) │ └── Traps └── Dev| Assembly | References (first-party) | Why it exists |
|---|---|---|
RacoonRiot.Contracts |
— | Events, interfaces, enums, value types. The only assembly anything is allowed to depend on unconditionally. |
RacoonRiot.Actors |
Contracts | Behaviour shared by players, items and traps: health, status effects, gravity, velocity, item holding, prompts, collider forwarding. |
RacoonRiot.Items |
Contracts | ItemBase and variants. |
RacoonRiot.Player |
Contracts, Actors | Player base, input, camera, and every module. |
RacoonRiot.Tasks |
Contracts, Items | Task types, variants, the manager, the database and the task UI. |
RacoonRiot.Traps |
Contracts, Items | Trap bases, activators, variants. |
RacoonRiot.Session |
Contracts, Player, Dev | Game/player/local-player managers, menu, start button, startup, spawn points. |
RacoonRiot.Dev |
Contracts, Actors, Items | Debug behaviours and the dev transport front. Never referenced by a shipping feature except Session’s startup selection. |
Two cycles that the graph resolves, and how
Section titled “Two cycles that the graph resolves, and how”-
Tasks ↔ TaskManager.
Tasks/UI/TaskCell.csandTasks/UI/TaskUIManager.csdepend onManagers/TaskManager;TaskManager.csdepends onSystem.Tasks. If tasks and their manager are separate assemblies this does not compile. Resolution: oneRacoonRiot.Tasksassembly holding the types, the manager, the database and the UI.This is the concrete reason a top-level
Managers/folder is dropped — see Deviations below. -
Core→ gameplay.ITaskUIManager→TaskSlot. Both land inContracts/Tasks/, andTaskSlot’s namespace is corrected fromRacoonRiot.System.Managers.TaskManagertoRacoonRiot.Contracts.Tasks. The apparent upward dependency disappears because it was never real.
Namespaces
Section titled “Namespaces”A file’s namespace is its assembly’s name. Nothing else. Contracts/Tasks/ITask.cs declares
namespace RacoonRiot.Contracts, not RacoonRiot.Contracts.Tasks; Features/Player/Runtime/Modules/PlayerJump.cs
declares namespace RacoonRiot.Player. Every consumer’s using RacoonRiot.Core; becomes
using RacoonRiot.Contracts; — one substitution rather than eight. System and Modules are dropped from
every namespace, which also closes RIOT-35.
Folders below the assembly root are filing, not namespace levels. This is Paradise Fleet’s actual
practice — Contracts/Events/GridSystem/Floor/*.cs all declare
ParadiseFleet.Contracts.Events.GridSystem, with Floor/ purely a filing aid. It contradicts
STRUCTURE_CONSOLIDATION_PLAN.md’s “Namespace = folder path = asmdef name, enforced
project-wide”, and the contradiction is deliberate: that rule is what produced 39 single-file
directories, because under it every namespace level demands a folder.
The six global-namespace files get the namespace of the feature they land in. System and
Modules disappear from every namespace, which also closes RIOT-35.
Deviations from Paradise Fleet, stated so they are choices and not drift
Section titled “Deviations from Paradise Fleet, stated so they are choices and not drift”-
Managers/is not a top-level folder. The five managers split across two feature assemblies:GameManager,PlayerManagerandLocalPlayerManager→Session;TaskManagerandTaskUIManager→Tasks. A sharedManagers/folder would either need two asmdefs inside it — a folder whose only purpose is to hold an assembly boundary it does not own — or force the managers into one assembly, which reintroduces cycle 1 above. Paradise Fleet has noManagers/for the same reason.The split is also the honest description of what these types are.
GameManagerandPlayerManagerare session lifecycle;TaskManageris the task system’s own root. They share a suffix, not a responsibility, and the old folder made that resemblance look like structure. -
Scripts/is kept as a level. Paradise Fleet has noScripts/;Features/Building/holdsRuntime/,Tests/,Materials/,Prefabs/andShaders/together. Co-locating content with its feature is the better end state, but it moves assets, which requiresAssetDatabase.MoveAssetand a running editor. Moving.csfiles does not — script references are GUID-based and live in the.cs.meta. KeepingScripts/lets the whole code reorganisation happen with the editor closed. Content co-location is a later phase, tracked separately.
Blast radius outside Scripts/
Section titled “Blast radius outside Scripts/”Found by running the migration against a throwaway clone rather than by reading:
-
Assets/SceneFlow/is not a library.SceneFlow/Core/SceneDirector.csandSceneEvents.csbothusing RacoonRiot.Coreand consumeBoolResponse. It sits besidePurrNet/andOpenFracture/and reads as vendored third-party code, but it depends on the game. Its asmdef referencesRacoonRiot.Coreby GUID, so a rename that mints a fresh GUID breaks it silently — SceneFlow simply stops seeing RacoonRiot types.Mitigation:
RacoonRiot.Contracts.asmdefinheritsRacoonRiot.Core.asmdef’s GUID (acb43129…). Contracts is Core renamed, so the identity should carry over. Every GUID-based reference then survives untouched.That SceneFlow points the wrong way is a separate defect and needs its own ticket — either it loses its RacoonRiot dependency and becomes a real library, or it moves inside the game root.
-
Four other files import RacoonRiot namespaces from outside
Scripts/:Editor/Migrations/ThrowableItemMigration.cs,Tests/Editor/LocalPlayerInvariantTests.cs,Tests/Editor/NetworkedEventSerializationTests.cs, plus the two SceneFlow files. All are rewritten in the same change. -
AssemblyInfo.cscannot be wrapped in a namespace.[assembly: ...]attributes must precede every other element, so the file keeps the global namespace. Wrapping it is CS1730 — which is how the first migration attempt failed.
Consequences
Section titled “Consequences”- Every C# file under
Scripts/moves. Every namespace underScripts/changes. - Eight runtime assemblies instead of four (
Core,Gameplay,Debug,DevBuild). Rebuild granularity improves; an accidental dependency from, say,ItemsintoPlayernow fails to compile instead of passing unnoticed inside one 54-fileGameplayassembly. RacoonRiot.Gameplayceases to exist.Editor,TestsandPlayModeTestsreference it by name and must be updated in the same change.AssemblyInfo.cs’s[InternalsVisibleTo("RacoonRiot.Tests")]currently grants access toRacoonRiot.Coreonly. Each feature assembly that wants internals visible to its tests needs its own declaration; the alternative is that tests only touch public API, which is the better default and should be tried first.git log --followstill works on moved files;git blameon unmoved lines is unaffected.- Merge cost against outstanding branches is real. This should land while
devandpurrnet-implementationare identical (they are, as of ADR-0003’s follow-up), and before any parallel feature work starts.
Why not the alternatives
Section titled “Why not the alternatives”- Leave it and document it. Rejected. The layout is the first thing a new contributor meets,
and the project is about to onboard one. A
READMEexplaining why the folders are shaped like a 2026-07 assembly graph is a worse artefact than the fix. - Flatten only the
Core/construct folders, keepSystem/. Rejected as a half-measure that leavesSystem/Player/Modules/<one folder per module>— 15 of the 39 single-file directories — and leavesRacoonRiot.Systemshadowing the BCL. - Keep five assemblies, reorganise folders only. This was the initial recommendation, on the grounds that assembly count is a compile-time cost and folder shape is the actual complaint. It was overruled: per-feature assemblies make the dependency direction enforceable rather than aspirational, which is the property that stops the graph rotting again once more than one person is working in it.
Verification
Section titled “Verification”The reorganisation is a mechanical transformation, so the burden is proving that nothing was lost — not that something was gained.
-
No component loses its script.
MissingScriptInventoryTestscounts, by GUID, every component inNewApartment.unitywhose script does not resolve. It reads 45 today. It must read exactly 45 afterwards. A move that breaks a binding shows up as 46+; a move that silently drops a component shows up as 44 or fewer, which is data loss. This test already exists and is the single most important check on the change. -
NewApartmentSceneIntegrityTestsholds the 45 authored values that survive only because nothing has re-serialised the scene. Unchanged. -
Every
.csmoves with its.cs.meta. Assertfind Scripts -name '*.cs' | wc -lequalsfind Scripts -name '*.cs.meta' | wc -l, and that the set of GUIDs in the meta files before and after the move is identical.A lost meta does not reliably show up on the machine that lost it. Unity’s asset database remembers the path-to-GUID mapping, so on a warm
Library/it regenerates the deleted meta at the original GUID and every binding silently survives — measured, not assumed. The breakage only appears where theLibrary/is cold: CI, and whatever fresh clone the next person makes. So this check has to be a filesystem assertion on the meta set, not “it still worked when I ran it”. -
The full CI gate set —
unity,csharp-format,repo-hygiene— green, run headless against an APFS clone before anything is pushed. -
Mutation check on the move itself: a verification that cannot fail is not a verification. Rewriting the GUID inside a single
.cs.metain a throwaway clone —TaskItem, which has exactly one component in the scene — must take check 1 from 45 to 46. It does. One lost binding out of several hundred is enough to turn the gate red, which is the sensitivity the ratchet needs to be worth anything.Deleting the meta outright is the weaker mutation and passes, for the warm-
Library/reason in check 3. Do not use it as the canary.
References
Section titled “References”- Paradise Fleet:
Assets/ParadiseFleet/{Contracts,Features}/ - ADR-0003 (
docs/adr/0003-drop-local-split-screen.md) — the ownership invariant thatFeatures/Player/must not quietly widen - RIOT-35 (
RacoonRiot.Systemshadows BCLSystem), RIOT-200 (SingletonCameraControllerstub)