| name | fsd-ecs-architecture |
| description | Where every file goes in this project's src/ and how to work in it. The codebase is Feature-Sliced Design (FSD) layers + a Unity-style ECS. ECS mapping: an ENTITY is a React component that IS a game being (features/slice/entities/*.tsx); a COMPONENT is a React hook you attach to any entity (features/slice/components/use*.ts); a SYSTEM is pure framework-free logic (features/slice/systems/*.ts). MANDATORY before adding, moving, or renaming any file under src/ — new entity, hook, system, screen, scene, feature, shared util, asset, or route. Enforces: correct layer placement, relative imports (no path aliases), deep cross-slice imports (barrels only for a feature's public screen), the app/scenes/features/shared layering, and DEV-only debug routes for lab/dead code. Trigger on: "where do I put", "add a feature", "add an entity / component / system", "new screen", "new scene", "new route", "folder structure", "FSD", "ECS", "how is src organized", "import from another feature", "where does this hook go". |
FSD + ECS architecture — what goes where
src/ is Feature-Sliced Design layers on the outside, a Unity-style ECS
inside each feature.
The ECS mapping (memorize this)
| ECS term | In this repo | Lives in | Example |
|---|
| entity | a React component that IS a game being | features/<slice>/entities/*.tsx | PlaceholderCharacter, Tany, Relic, Water |
| component | a React hook you attach to any entity | feature-specific → features/<slice>/components/use*.ts; generic/reused → shared/lib/ | useSpin, useBob (in shared/lib) |
| system | pure logic, no React / no three | feature-specific → features/<slice>/systems/*.ts; generic → shared/lib/ | shared/lib/motion.ts (spinAngle, bobHeight) |
The Unity analogy: an entity is the GameObject, a component (hook) is a
MonoBehaviour you bolt on to give it behaviour, a system is the pure math the
component calls. Behaviour is composed by attaching hooks, never by
inheritance. A component/system starts inside its feature and is promoted to
shared/lib once a second feature needs it (the spin/bob motion did — the
character entity and the world Relic both use it):
shared/lib/
motion.ts # system: spinAngle()/bobHeight() — pure, unit-tested (motion.test.ts)
useSpin.ts # component: spins any Object3D ref via useFrame
useBob.ts # component: bobs any Object3D ref via useFrame
features/character/
entities/PlaceholderCharacter.tsx # entity: capsule being; attaches useSpin + useBob
entities/Tany.tsx # entity: real Tripo GLB character (useGLTF)
ui/CharacterDebugScreen.tsx # screen for the /character-debug route
features/world/
entities/Relic.tsx # entity: spinning prism; composes the SAME shared hooks
A component (hook) takes a ref + options and drives it every frame; it is
entity-agnostic — attach useSpin(ref) to a character, a prop, or a pickup:
export function useSpin(ref: RefObject<Object3D | null>, options?: SpinOptions) {
useFrame((state) => {
if (!ref.current) return
ref.current.rotation.y = spinAngle(state.clock.elapsedTime, options)
})
}
The FSD layers (outer structure)
src/
main.tsx # entry: gesture blockers + <BootstrapGate><App/></BootstrapGate>
App.tsx # renders <AppRouter/> — nothing else
app/
router/AppRouter.tsx # routes; "/" = main scene, DEV-only lazy routes for debug screens
scenes/
<scene-name>/ # a mounted world: <Canvas>, post-processing, the scene GRAPH.
# Deep-imports entities from features. This is the composition root.
features/
<slice>/ # a vertical slice of the game. Holds entities/ components/ systems/
# ui/ materials/ + optional index.ts (public API = its screen)
shared/
config/ # app-wide constants (vfxPipeline.ts)
lib/ # cross-feature primitives (ShadowGroup, ShadowThrottle, shadowLayers)
<owner>/assets/ # assets of a true shared primitive; the owner imports them
vendor/ # third-party code we vendored (realism-effects)
vite-env.d.ts # ambient types; stays at src root
Dependency direction (never violate): app → scenes → features → shared.
A feature must not import a scene or the router. shared/ imports nothing above
it. Scenes may deep-import feature entities/systems (they are the composition
root); features do not import other features except through a shared/-style
public boundary.
Decision table — "I want to add X"
| You are adding… | Put it in | Notes |
|---|
| A thing that exists in the world (character, prop, building, water) | features/<slice>/entities/X.tsx | A React component. Behaviour comes from attached hooks. |
| Reusable per-frame behaviour (spin, bob, follow, health-drain) | features/<slice>/components/useX.ts, or shared/lib/ if used by 2+ features | A hook taking a ref/target. Keep it entity-agnostic. |
| Pure game/math logic (layout, damage, pathing, RNG-free math) | features/<slice>/systems/X.ts, or shared/lib/ if generic | No React, no three. This is the layer you unit-test. |
| A feature-owned screen (HUD + canvas mount, a debug lab) | features/<slice>/ui/XScreen.tsx | Screens mount scenes; they don't contain the scene graph. |
| Visible DOM overlay for the starter scene | features/ui-kit/components/DemoSceneHud/ | Presentation-only: canvas child, labels and callbacks come from the screen. |
| An app route screen with no vertical domain (for example main menu) | app/ui/<screen>/XScreen.tsx | Compose feature public UI, keep routing here. |
The <Canvas> + post + scene graph | scenes/<scene-name>/ | Deep-imports feature entities. One folder per distinct world. |
| three.js Material/Geometry registries | features/<slice>/materials/ | e.g. world/materials/materials.ts. |
| A private asset (png/mp3/glb) | Next to the narrowest consumer, e.g. components/X/assets/ | The importing component owns it; never use a detached asset bucket. |
| A true common asset used across independent features | shared/<owner>/assets/ | The shared primitive imports it directly; never create shared/assets/ without an owner. |
| A primitive used by ≥2 features | shared/lib/ | Only promote to shared once a second consumer exists (YAGNI). |
| An app-wide constant | shared/config/ | e.g. vfxPipeline.ts. |
| A new route | app/router/AppRouter.tsx | See "Adding a route" below. |
Import rules
- Relative imports only — there are no path aliases (
@/…).
- Cross-slice imports are deep and explicit: a scene imports
../../features/world/entities/Blockout, not from a barrel. This is the
established convention.
- Barrels (
index.ts) are optional and minimal — only a feature's public
screen goes in one (e.g. features/world/index.ts re-exports GameScreen,
consumed by the router). Do not re-export entities/hooks/systems you only use
inside the feature — knip will flag them.
- Never re-export an asset-bearing visual module from a feature barrel. An
effect, render entity, or material that imports images, audio, models, or
shaders must be imported directly from its owning file. Pure systems, types,
and code-only helpers may use a barrel. A DEV/lab screen must never share a
barrel with a production visual consumer: that makes its assets
production-reachable.
Adding a new feature (recipe)
features/<slice>/ with the subfolders you actually need
(entities/ components/ systems/ ui/; add materials/ if used). Put an
asset in the directory of the specific component/screen that imports it.
- Write systems first (pure, testable) → then components (hooks that
call systems) → then entities (components that attach hooks) → then a
ui/ screen if it needs its own route.
- Add
characterMotion.test.ts-style tests for the systems.
- If a scene needs the entities, deep-import them from the scene.
- Add
index.ts exporting only the public screen if the router/another
slice consumes it.
Adding a scene + route
- Scene:
scenes/<name>/<Name>Scene.tsx — owns the <Canvas>, lights, post, and
the scene graph; deep-imports entities from features.
- Screen:
features/<slice>/ui/<Name>Screen.tsx — mounts the scene, adds HTML
overlay, and must call useReportInitialRenderReady() (from
../../bootstrap) so the BootstrapGate overlay dismisses on a direct load —
unless it mounts GameCanvas, which already reports readiness.
- Route in
app/router/AppRouter.tsx: main route / is eager; debug/lab routes
are import.meta.env.DEV-gated and lazy()-loaded so they tree-shake out of
production. Pattern:
const CharacterDebugScreen = ENABLE_DEBUG_ROUTES
? lazy(async () => ({ default: (await import('../../features/character/ui/CharacterDebugScreen')).CharacterDebugScreen }))
: null
Dead code & debug routes
Do not delete unused/WIP render code (see memory never-delete-without-permission).
Give it a home instead: preserve passes/entities in place unwired, and turn
salvageable labs into DEV-only routes (the character lab lives at
/character-debug). This keeps knip honest without losing work.
Verify
Never trust a typecheck alone (AGENTS.md #1). After structural changes run
npm run test + npm run knip, then load the affected routes in a headed
browser (headless: false) and look at the frame. npm run build /
npm run lint may be pre-existing red from untracked render WIP — confirm any
error is not yours (unchanged file body ⇒ pre-existing) before acting on it.