Skip to content

Feature-based folder structure

Plan for moving Assets/ParadiseFleet/ from a type-based layout (Scripts/Core, Scripts/Systems, Materials/, Prefabs/, Shaders/) to a feature-based one, including relocating the authored assets alongside the code that owns them.

Scope is Assets/ParadiseFleet/ only: 115 files (+159 .meta siblings), of which 78 are C# (~7,000 LOC), 41 are event classes, and 30 are binary assets.

Root Why it is not a feature
Assets/PodNet/ Distributable package (package.json, own README, ships to Asset Store). Must stay self-contained.
Assets/Grid3D/ Reusable voxel-math library with its own asmdef and tests. A dependency, not a feature.
Assets/Plugins/, Assets/Resources/, Assets/UI Toolkit/ Unity magic folder names. Cannot move or rename.
Assets/ParadiseFleet/Settings/ Project-level URP renderers/assets and input actions. Not owned by any one feature.
Assets/ParadiseFleet/Scenes/ DEV.unity composes every feature.
Assets/ParadiseFleet/Editor/PodNetMcp/ Editor-only developer bridge, already self-contained.

Assets/Grid3D/ sheds assets in phase 0 rather than gaining them β€” see below β€” leaving it as scripts and tests only, which is what makes it genuinely self-contained rather than merely separate.

Four things normally make a Unity reorg painful. All four are clean here:

  • No hardcoded asset paths. Zero Resources.Load, AssetDatabase.LoadAssetAtPath, or path strings anywhere in C#.
  • Every reference is a GUID. Scenes, prefabs, materials, and the UIDocument β†’ .uxml link all resolve through .meta. Move a file together with its .meta and nothing breaks.
  • asmdef references are GUID- or name-based, never path-based. Moving an asmdef does not break anything that points at it.
  • CI needs no changes. unity.yaml filters on ParadiseFleet/**; csharp-format.yaml on ParadiseFleet/Assets/**/*.cs with --include Assets/. Both survive any reshuffle.

Only three documentation lines and the README project-structure block reference concrete asset paths.

Every system talks over the static EventManager β€” Subscribe<T>, Broadcast, Request/RegisterHandler. Systems never reference each other, with one exception. That makes the folder move nearly free but concentrates all the difficulty into deciding which assembly owns what.

Five facts drive the design:

  1. GridManager is the single grid-model authority. It answers CanPlaceObject, PlaceObject, RemoveObject, CanPlaceFloor, PlaceFloor, CanPlaceWall, PlaceWall, IsRemovableStructure, and RemoveStructure. All four building behaviours route through it, so the grid is a shared layer beneath the building features, not a peer.
  2. PlacementDeck and WallEdgeLayout are shared across that boundary. PlacementDeck is used by GridManager and all three placers; WallEdgeLayout by GridManager and WallPlacer. They pin Building β†’ Ship as a real compile-time edge. It is acyclic β€” GridManager uses no Building symbol.
  3. StructureRunUndo is internal static, shared by FloorPlacer and WallPlacer. Splitting floors and walls into separate assemblies would break it. This is the main reason Building is one assembly, not four.
  4. PlaceableObjectData.Shape is deliberately internal β€” the docstring states the shared asset’s array must never escape the assembly. PlaceableObject and PlaceableObjectData must stay in the same assembly as each other.
  5. CameraPan coupled directly to InputEventManager.Instance via a C# event (OnMoveInput += HandleMove) rather than the bus. This was the only peer-to-peer edge in the codebase; it has since been removed β€” see below.
PodNet Grid3D
β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
β–Ό
ParadiseFleet.Contracts events Β· models Β· RotationUtility
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό β–Ό β–Ό β–Ό
.Ship .UndoHistory .Controls .CameraRig .Dev
β”‚
β–Ό
.Building

No cycles, and Building β†’ Ship is the only intra-game edge β€” a layer rather than a peer coupling, since the grid is the authority every placer places against.

Assembly Contains
ParadiseFleet.Contracts All 41 event types, EventCategories, PlaceableObject(+Data), FloorEdgeObject, WallEdgeObject, RotationUtility
ParadiseFleet.Ship GridManager, GridOverlay, GridPositionProvider, PlacementDeck, WallEdgeLayout, DeckClearance, DeckVisibility, GridPositionUtility
ParadiseFleet.Building ObjectPlacer, FloorPlacer, WallPlacer, ObjectDeleter, StructureRunUndo, PlacementValidation
ParadiseFleet.UndoHistory UndoSystem, UndoStack
ParadiseFleet.Controls InputEventManager
ParadiseFleet.CameraRig CameraPan
ParadiseFleet.Dev DevUIController

Every namespace equals its assembly name. Three of these assemblies were renamed in phase 3 to make that possible: Undo, Input and Camera each shadow a type sitting right beside them (UnityEditor.Undo, UnityEngine.Input, UnityEngine.Camera). Inside namespace ParadiseFleet.Camera the simple name Camera binds to the namespace rather than the engine type β€” CS0118, which broke CameraPan outright β€” and the other two are the same trap waiting for whoever writes Input.GetKey or Undo.RecordObject next. Features/ and Runtime/ are folder groupings only and deliberately absent from namespaces, for the same reason Scripts was.

RotationUtility sits in Contracts because WallEdgeObject and FloorEdgeObject both use it. PlacementValidation is used only by the three placers, so it goes to Building.

This originally stayed a direct reference, on the reasoning that broadcasting move input every frame would cost allocation and dispatch, and that a struct event would make it free once PodNet 2.0 landed. Both halves of that turned out to be wrong on inspection:

  • Struct events already exist. IEvent documents that events may be structs, Broadcast<T> and Subscribe<T> are generic over it, and CancelButtonPressed is already a readonly struct.
  • They would not have helped anyway. Broadcast<T> boxes its argument immediately (object boxed = eventData), so a struct event allocates exactly like a class one today.
  • The cost was overstated. OnMove fires when the input value changes, not every frame, so a held key produces one event on press and one on release. One small allocation per input change is not worth a bespoke coupling.

CameraPan now subscribes to MoveInputChanged in Awake like every other system, and ParadiseFleet.CameraRig no longer references ParadiseFleet.Controls. That also retired the InternalsVisibleTo exception the camera tests needed, since they broadcast rather than reaching into the input manager.

A stricter reading would give Objects, Floors, Walls, and Deletion their own assemblies. That requires promoting StructureRunUndo out of internal, and buys nothing β€” all four already route through GridManager and share PlacementDeck. Use feature folders inside one Building assembly instead. Folder structure and assembly granularity are separate axes; only the folders need to be feature-shaped.

Assets/ParadiseFleet/
β”œβ”€β”€ Contracts/ asmdef: ParadiseFleet.Contracts
β”‚ β”œβ”€β”€ Events/ Grid/ Floor/ Wall/ ObjectPlacement/ Placement/
β”‚ β”‚ Deletion/ Input/ Undo/ + EventCategories.cs
β”‚ β”œβ”€β”€ Models/ PlaceableObject(+Data), Floor/WallEdgeObject
β”‚ └── AssemblyInfo.cs InternalsVisibleTo(ParadiseFleet.Tests)
β”œβ”€β”€ Features/
β”‚ β”œβ”€β”€ Ship/ asmdef: ParadiseFleet.Ship
β”‚ β”‚ β”œβ”€β”€ Runtime/
β”‚ β”‚ β”œβ”€β”€ Prefabs/ GridOverlay.prefab (from Grid3D, phase 0)
β”‚ β”‚ β”œβ”€β”€ Materials/ OverlayMaterial.mat
β”‚ β”‚ └── Shaders/ OverlayShader.shader
β”‚ β”œβ”€β”€ Building/ asmdef: ParadiseFleet.Building
β”‚ β”‚ β”œβ”€β”€ Runtime/Objects/ ObjectPlacer, PlacementValidation
β”‚ β”‚ β”œβ”€β”€ Runtime/Floors/ FloorPlacer
β”‚ β”‚ β”œβ”€β”€ Runtime/Walls/ WallPlacer
β”‚ β”‚ β”œβ”€β”€ Runtime/Deletion/ ObjectDeleter
β”‚ β”‚ β”œβ”€β”€ Runtime/StructureRunUndo.cs
β”‚ β”‚ β”œβ”€β”€ Materials/ ObjectPlacement.mat, DeletionHighlight.mat
β”‚ β”‚ └── Shaders/ ObjectPlacement.shader
β”‚ β”œβ”€β”€ Undo/ asmdef: ParadiseFleet.Undo
β”‚ β”œβ”€β”€ Input/ asmdef: ParadiseFleet.Input
β”‚ β”œβ”€β”€ Camera/ asmdef: ParadiseFleet.Camera
β”‚ └── Dev/ asmdef: ParadiseFleet.Dev
β”‚ β”œβ”€β”€ Runtime/ DevUIController
β”‚ └── UI/ DevUI.uxml, DevPanelSettings.asset
β”œβ”€β”€ Content/ no asmdef β€” authored data, not code
β”‚ β”œβ”€β”€ Objects/Basic/ Chair/Cube/Cube_Bevel prefabs, Cube.asset,
β”‚ β”‚ Chair mesh, ChairGreybox.mat
β”‚ β”œβ”€β”€ Structural/ Floor.prefab, Wall.prefab
β”‚ └── Art/Models/ Blocks/Floors/Walls .blend sources
β”œβ”€β”€ Editor/PodNetMcp/ unchanged
β”œβ”€β”€ Scenes/DEV.unity unchanged
β”œβ”€β”€ Settings/ unchanged
└── Tests/ see phase 5

Floor.prefab and Wall.prefab go under Content/ rather than Features/Building/ because they are authored content that will multiply as wall and floor variants are added, not code the Building feature owns.

Each phase ends with a green build so a failure is always attributable to one step.

No moves. Clear the ground first so the reorg diff is not polluted.

  • Delete Cube.prefab (referenced by no scene or prefab) and the orphaned folder metas Assets/VoxelGrid.meta, Assets/Grid3D/Materials.meta and Assets/Grid3D/Prefabs.meta, whose directories are empty once the move below is done.
  • Fix the layering violation by moving the prefab, not the material. Assets/Grid3D/Prefabs/GridOverlay.prefab moves to Assets/ParadiseFleet/Prefabs/GridSystem/.

Assets/_Recovery/ needs no action: it is already gitignored (ParadiseFleet/.gitignore:82-83) and has never been in the repository.

The obvious reading is that GridOverlay.prefab merely references a ParadiseFleet material, and that hauling OverlayMaterial.mat (plus OverlayShader.shader, which the material references) into Grid3D would settle it. It would not. The prefab’s only component is GridOverlay.cs, which lives in Assets/ParadiseFleet/Scripts/Systems/GridSystem/ and compiles into the game’s GridSystem assembly. Moving the art across would leave the prefab still unable to instantiate without game code, so Grid3D still would not drop into another project.

The script cannot move the other way either: it uses ParadiseFleet.Core.Events.GridSystem and PodNet.EventSystem, while Grid3D.asmdef references only Unity.InputSystem. Making the overlay real library code means inverting that dependency β€” exposing plain C# events instead of listening on the bus β€” which is a genuine refactor and not part of this work.

So the prefab was simply filed in the wrong place. It is a game asset; it joins the script and material it already depends on, and Grid3D reduces to scripts and tests.

Related, deferred to phase 3: GridOverlay.cs declares namespace Grid3D despite living in ParadiseFleet and compiling into GridSystem. Nothing references the type by name β€” it is only ever attached through the prefab β€” so the rename is free whenever the namespace pass happens.

Assets/_PodNetDevHarness/ stays where it is. It is excluded through .git/info/exclude β€” a personal, machine-local exclude rather than a shared .gitignore β€” so it has never been repository content, is absent from CI and from every other clone, and is not this refactor’s to relocate.

Pure asmdef edits β€” cheap, isolated, trivially revertible.

  • Core β†’ ParadiseFleet.Contracts, GridSystem β†’ ParadiseFleet.Ship, Input β†’ ParadiseFleet.Input, Cam β†’ ParadiseFleet.Camera, Dev β†’ ParadiseFleet.Dev. The .asmdef files are renamed to match, so no assembly is declared by a file of a different name; because the .meta moves with each file the GUID survives, and every GUID-based reference is unaffected. Phase 3 then only has to move these files, not rename them as well.
  • Set rootNamespace on each β€” to the namespace the assembly actually uses today (ParadiseFleet.Core, ParadiseFleet.Systems.GridSystem, …), not the eventual one. Pointing it at a future namespace would make every newly created script land somewhere nothing else lives. Phase 3 moves the code and this field together.
  • Update the by-name references that break. All of them are in ParadiseFleet.Tests.asmdef, which names Core, GridSystem, Input and Cam β€” the last three added when the systems gained test coverage. Every other reference in the project is a GUID and is unaffected.

Related, deferred to phase 3: the Dev assembly’s namespace is ParadiseFleet.Scripts.Dev β€” Scripts there is a folder artifact that should not be in a namespace at all.

Core, Input, Cam, and Dev are dangerously generic assembly names for a project that will grow; renaming them is worth doing regardless of the rest of this plan.

Phase 2 β€” Split Building out of Ship (~2–3h)

Section titled β€œPhase 2 β€” Split Building out of Ship (~2–3h)”

The one genuinely risky compile step. Do it before any file moves so failures are diagnosable.

  • Add ParadiseFleet.Building.asmdef over the existing Scripts/Systems/GridSystem/GridObjects/ folder, which already contains exactly the four placers plus StructureRunUndo.
  • References: ParadiseFleet.Contracts, ParadiseFleet.Ship, Grid3D, PodNet, Unity.InputSystem.
  • Add ParadiseFleet.Building to ParadiseFleet.Tests, which covers ObjectPlacer.

PlacementValidation stays in Contracts for now rather than moving here as originally sketched. Building compiles against it either way, so moving it buys nothing at this stage β€” and moving a file would both undercut the point of this phase (an assembly-only change, so any failure is unambiguously the split) and leave the ParadiseFleet.Core.Models namespace straddling two assemblies until phase 3. It moves in phase 3, where its namespace is corrected in the same step.

If this compiles, the rest of the plan is filesystem work.

Close the Unity editor. Use git mv on file and .meta pairs. Moving with the editor open, or moving a .cs without its .meta, loses the GUID and breaks every scene reference to that script.

  • Move the 78 C# files into the target tree.
  • Realign namespaces to match folders (78 declarations, 48 using ParadiseFleet.* lines).
  • Add an AssemblyInfo.cs with InternalsVisibleTo("ParadiseFleet.Tests") to Contracts and Undo β€” both expose internal members the tests use (PlaceableObjectData.Shape, UndoSystem.Initialize/Shutdown). StructureRunUndo stays internal within Building and needs nothing.

Commit the moves and the namespace edits separately. A combined diff is unreviewable and degrades git log --follow.

Same git mv discipline, editor closed. GUIDs hold, so DEV.unity keeps resolving everything.

A green test suite proves nothing here β€” the assets are art, and nothing in the suite loads them. The check that matters compares the full GUID set before and after and then resolves every GUID referenced from a scene, prefab or material, which catches the dropped .meta that would otherwise surface as a pink material or a null prefab field. Folder metas need excluding from that comparison: they are legitimately deleted when a directory empties, and some of this project’s are written in a minimal form with no folderAsset: marker, so classify by whether the meta’s target is a directory rather than by the file’s contents.

Assets/ParadiseFleet/Art/Models/ carries three .blend1 files β€” Blender’s automatic previous-save backups, roughly 1.3 MB each and tracked. They also have .meta files, so Unity imports them as models alongside the real .blend. Moved with everything else here and removed in phase 5, along with a *.blend1 ignore rule, since deleting art is a separate decision from relocating it.

  • Move the 18 test files under Features/<X>/Tests/, each feature’s tests in their own assembly. An asmdef claims its whole subtree, so tests placed under a feature folder would otherwise compile into that feature β€” co-locating them requires either per-feature test assemblies or .asmref files pointing back at a single one. Per-feature assemblies win: a test can then only reach the feature it covers, which is the same discipline the production asmdefs enforce, and it avoids introducing .asmref scaffolding that would be replaced later anyway.
  • Shared helpers (TestScene, FakeVoxelObject) get their own ParadiseFleet.Tests.TestUtilities assembly, mirroring the existing PodNet.Tests.TestUtilities. They must become public β€” internal no longer reaches them across an assembly boundary.
  • Retarget each InternalsVisibleTo at the test assembly that now covers it. Ship and Building need none; everything their tests touch is already public.
  • Update the README project-structure block and the doc lines that name asset paths.
  • Add this document to docs/internal/technical/README.md.

The original plan said to keep one test assembly, deferring the split to β€œthe coverage work”. That work landed first, so the reason for deferring was already spent by the time this phase ran.

docs/internal/technical/podnet-2.0.md mentions old paths in a historical Phase 0 narrative β€” leave it.

Per phase:

  1. Compiles clean, zero console errors or warnings.
  2. EditMode suite green β€” currently 516 tests.
  3. DEV.unity opens with no missing references.
  4. Manual play-test: place an object, a floor run, a wall run; delete each; undo each; toggle grid and deck visibility; raise/lower deck; pan the camera.

Step 4 is not optional. ParadiseFleet.Tests references only Core, Grid3D, and PodNet β€” GridSystem, Input, Cam, and Dev have no test assembly pointing at them at all. If this reorg breaks wiring in any of those, all 516 tests still pass and CI still goes green. The manual pass is the only real gate on four of the seven assemblies.

Phase Estimate Risk
0 β€” cleanup + layering fix 1–2h Low
1 β€” assembly renames 1h Low
2 β€” Building split 2–3h Moderate β€” compile-time asmdef failures
3 β€” move code + namespaces 4–6h Low mechanically, high volume
4 β€” move assets 2–3h Low, but silent failure mode (dropped .meta)
5 β€” tests + docs 2–3h Low

Total: 2–3 days.

Add EditMode coverage for Ship, Building, Input, and Camera before phase 2. Doing an assembly-topology rewrite with no regression fence under four of seven assemblies means the reorg’s correctness rests entirely on one manual play-test. That coverage is worth having regardless, and it converts this from a trust-the-play-test exercise into a verifiable one.