Skip to content

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)

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 files
Scripts/Core/Interfaces/ 14 files
Scripts/Core/Structs/ 2 files
Scripts/Core/Superclasses/ 2 files
Scripts/Core/ReferenceHolders/ 2 files
Scripts/Core/Events/{Common,Local,Networked}/ 16 files
Scripts/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.

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”
  • Core is not a leaf, and lies about it. Core/ReferenceHolders/TaskSlot.cs lives in the RacoonRiot.Core assembly but declares namespace RacoonRiot.System.Managers.TaskManager. Core/Interfaces/ITaskUIManager.cs then writes using RacoonRiot.System.Managers.TaskManager and appears to reference gameplay from Core. It compiles only because the namespace is a fiction — the type it resolves to is TaskSlot, two folders away in the same assembly.
  • Six files sit in the global namespace: PlayerManager, SingletonCameraController, TaskCommunication, PlayerLook, and five of the six files under Scripts/Debug/.
  • Folder and namespace disagree in at least four places: Misc/Prompts/PromptView.cs declares System.Common.Prompts; Modules/PlayerRenderer/ declares Modules.Renderer; Modules/PlayerAttack/ declares Modules.Attack.
  • System/Player/Modules/ names modules two different waysJump, Look, Walking, Inventory, Interaction beside PlayerAttack, PlayerRoll, PlayerCamera, PlayerRenderer.
  • Two camera controllers. System/Player/Camera/SingletonCameraController.cs (a bare DontDestroyOnLoad singleton with no behaviour, see RIOT-200) and System/Player/Modules/PlayerCamera/CameraController.cs (the real one).
  • RacoonRiot.System shadows the BCL System namespace — RIOT-35.

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.

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.Traps

Editor/ 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.

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”
  1. Tasks ↔ TaskManager. Tasks/UI/TaskCell.cs and Tasks/UI/TaskUIManager.cs depend on Managers/TaskManager; TaskManager.cs depends on System.Tasks. If tasks and their manager are separate assemblies this does not compile. Resolution: one RacoonRiot.Tasks assembly 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.

  2. Core → gameplay. ITaskUIManagerTaskSlot. Both land in Contracts/Tasks/, and TaskSlot’s namespace is corrected from RacoonRiot.System.Managers.TaskManager to RacoonRiot.Contracts.Tasks. The apparent upward dependency disappears because it was never real.

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”
  1. Managers/ is not a top-level folder. The five managers split across two feature assemblies: GameManager, PlayerManager and LocalPlayerManagerSession; TaskManager and TaskUIManagerTasks. A shared Managers/ 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 no Managers/ for the same reason.

    The split is also the honest description of what these types are. GameManager and PlayerManager are session lifecycle; TaskManager is the task system’s own root. They share a suffix, not a responsibility, and the old folder made that resemblance look like structure.

  2. Scripts/ is kept as a level. Paradise Fleet has no Scripts/; Features/Building/ holds Runtime/, Tests/, Materials/, Prefabs/ and Shaders/ together. Co-locating content with its feature is the better end state, but it moves assets, which requires AssetDatabase.MoveAsset and a running editor. Moving .cs files does not — script references are GUID-based and live in the .cs.meta. Keeping Scripts/ lets the whole code reorganisation happen with the editor closed. Content co-location is a later phase, tracked separately.

Found by running the migration against a throwaway clone rather than by reading:

  • Assets/SceneFlow/ is not a library. SceneFlow/Core/SceneDirector.cs and SceneEvents.cs both using RacoonRiot.Core and consume BoolResponse. It sits beside PurrNet/ and OpenFracture/ and reads as vendored third-party code, but it depends on the game. Its asmdef references RacoonRiot.Core by GUID, so a rename that mints a fresh GUID breaks it silently — SceneFlow simply stops seeing RacoonRiot types.

    Mitigation: RacoonRiot.Contracts.asmdef inherits RacoonRiot.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.cs cannot 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.

  • Every C# file under Scripts/ moves. Every namespace under Scripts/ changes.
  • Eight runtime assemblies instead of four (Core, Gameplay, Debug, DevBuild). Rebuild granularity improves; an accidental dependency from, say, Items into Player now fails to compile instead of passing unnoticed inside one 54-file Gameplay assembly.
  • RacoonRiot.Gameplay ceases to exist. Editor, Tests and PlayModeTests reference it by name and must be updated in the same change.
  • AssemblyInfo.cs’s [InternalsVisibleTo("RacoonRiot.Tests")] currently grants access to RacoonRiot.Core only. 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 --follow still works on moved files; git blame on unmoved lines is unaffected.
  • Merge cost against outstanding branches is real. This should land while dev and purrnet-implementation are identical (they are, as of ADR-0003’s follow-up), and before any parallel feature work starts.
  • 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 README explaining why the folders are shaped like a 2026-07 assembly graph is a worse artefact than the fix.
  • Flatten only the Core/ construct folders, keep System/. Rejected as a half-measure that leaves System/Player/Modules/<one folder per module> — 15 of the 39 single-file directories — and leaves RacoonRiot.System shadowing 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.

The reorganisation is a mechanical transformation, so the burden is proving that nothing was lost — not that something was gained.

  1. No component loses its script. MissingScriptInventoryTests counts, by GUID, every component in NewApartment.unity whose 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.

  2. NewApartmentSceneIntegrityTests holds the 45 authored values that survive only because nothing has re-serialised the scene. Unchanged.

  3. Every .cs moves with its .cs.meta. Assert find Scripts -name '*.cs' | wc -l equals find 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 the Library/ 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”.

  4. The full CI gate setunity, csharp-format, repo-hygiene — green, run headless against an APFS clone before anything is pushed.

  5. Mutation check on the move itself: a verification that cannot fail is not a verification. Rewriting the GUID inside a single .cs.meta in 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.

  • Paradise Fleet: Assets/ParadiseFleet/{Contracts,Features}/
  • ADR-0003 (docs/adr/0003-drop-local-split-screen.md) — the ownership invariant that Features/Player/ must not quietly widen
  • RIOT-35 (RacoonRiot.System shadows BCL System), RIOT-200 (SingletonCameraController stub)