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.
What stays where it is
Section titled βWhat stays where it isβ| 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.
Why the mechanical move is safe
Section titled βWhy the mechanical move is safeβ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β.uxmllink all resolve through.meta. Move a file together with its.metaand 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.yamlfilters onParadiseFleet/**;csharp-format.yamlonParadiseFleet/Assets/**/*.cswith--include Assets/. Both survive any reshuffle.
Only three documentation lines and the README project-structure block reference concrete asset paths.
Why the assembly split is the actual work
Section titled βWhy the assembly split is the actual workβ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:
GridManageris the single grid-model authority. It answersCanPlaceObject,PlaceObject,RemoveObject,CanPlaceFloor,PlaceFloor,CanPlaceWall,PlaceWall,IsRemovableStructure, andRemoveStructure. All four building behaviours route through it, so the grid is a shared layer beneath the building features, not a peer.PlacementDeckandWallEdgeLayoutare shared across that boundary.PlacementDeckis used byGridManagerand all three placers;WallEdgeLayoutbyGridManagerandWallPlacer. They pinBuilding β Shipas a real compile-time edge. It is acyclic βGridManageruses no Building symbol.StructureRunUndoisinternal static, shared byFloorPlacerandWallPlacer. Splitting floors and walls into separate assemblies would break it. This is the main reason Building is one assembly, not four.PlaceableObjectData.Shapeis deliberatelyinternalβ the docstring states the shared assetβs array must never escape the assembly.PlaceableObjectandPlaceableObjectDatamust stay in the same assembly as each other.CameraPancoupled directly toInputEventManager.Instancevia 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.
Target assembly graph
Section titled βTarget assembly graphβ PodNet Grid3D βββββββββ¬ββββββββ βΌ ParadiseFleet.Contracts events Β· models Β· RotationUtility β ββββββββββ¬βββββ΄ββββββ¬βββββββββββββ¬ββββββββββ βΌ βΌ βΌ βΌ βΌ .Ship .UndoHistory .Controls .CameraRig .Dev β βΌ .BuildingNo 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 contents
Section titled βAssembly contentsβ| 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.
The camera rig now goes through the bus
Section titled βThe camera rig now goes through the busβ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.
IEventdocuments that events may be structs,Broadcast<T>andSubscribe<T>are generic over it, andCancelButtonPressedis already areadonly 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.
OnMovefires 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.
On splitting Building four ways
Section titled βOn splitting Building four waysβ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.
Target tree
Section titled βTarget treeβ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 5Floor.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.
Phase 0 β Pre-flight cleanup (~1β2h)
Section titled βPhase 0 β Pre-flight cleanup (~1β2h)β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 metasAssets/VoxelGrid.meta,Assets/Grid3D/Materials.metaandAssets/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.prefabmoves toAssets/ParadiseFleet/Prefabs/GridSystem/.
Assets/_Recovery/ needs no action: it is already gitignored
(ParadiseFleet/.gitignore:82-83) and has never been in the repository.
Why the prefab moves rather than the material
Section titled βWhy the prefab moves rather than the materialβ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.
Phase 1 β Rename assemblies, no file moves (~1h)
Section titled βPhase 1 β Rename assemblies, no file moves (~1h)βPure asmdef edits β cheap, isolated, trivially revertible.
CoreβParadiseFleet.Contracts,GridSystemβParadiseFleet.Ship,InputβParadiseFleet.Input,CamβParadiseFleet.Camera,DevβParadiseFleet.Dev. The.asmdeffiles are renamed to match, so no assembly is declared by a file of a different name; because the.metamoves 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
rootNamespaceon 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 namesCore,GridSystem,InputandCamβ 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.asmdefover the existingScripts/Systems/GridSystem/GridObjects/folder, which already contains exactly the four placers plusStructureRunUndo. - References:
ParadiseFleet.Contracts,ParadiseFleet.Ship,Grid3D,PodNet,Unity.InputSystem. - Add
ParadiseFleet.BuildingtoParadiseFleet.Tests, which coversObjectPlacer.
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.
Phase 3 β Move code (~4β6h)
Section titled βPhase 3 β Move code (~4β6h)β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.cswithInternalsVisibleTo("ParadiseFleet.Tests")toContractsandUndoβ both exposeinternalmembers the tests use (PlaceableObjectData.Shape,UndoSystem.Initialize/Shutdown).StructureRunUndostays internal within Building and needs nothing.
Commit the moves and the namespace edits separately. A combined diff is unreviewable and
degrades git log --follow.
Phase 4 β Move assets (~2β3h)
Section titled βPhase 4 β Move assets (~2β3h)β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.
Phase 5 β Tests and docs (~2β3h)
Section titled βPhase 5 β Tests and docs (~2β3h)β- 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.asmreffiles 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.asmrefscaffolding that would be replaced later anyway. - Shared helpers (
TestScene,FakeVoxelObject) get their ownParadiseFleet.Tests.TestUtilitiesassembly, mirroring the existingPodNet.Tests.TestUtilities. They must becomepublicβinternalno longer reaches them across an assembly boundary. - Retarget each
InternalsVisibleToat the test assembly that now covers it.ShipandBuildingneed 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.
Verification
Section titled βVerificationβPer phase:
- Compiles clean, zero console errors or warnings.
- EditMode suite green β currently 516 tests.
DEV.unityopens with no missing references.- 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.
Recommended prerequisite
Section titled βRecommended prerequisiteβ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.