La-Fabrik
An interactive 3D web experience for La Fabrik Durable — a low-tech repair and transformation service in Altera, a post-capitalist city rebuilt in 2039. Players step into the role of a newly onboarded technician and experience a day at the service: repairing an e-bike, fixing a power grid, and upgrading a vertical farm's irrigation system.
Built with React, Three.js, and Vite. Runs in the browser, no installation required.
📦 Tech Stack
Build & Language
| Package | Doc |
|---|---|
| TypeScript | https://www.typescriptlang.org/docs/ |
| React | https://react.dev/learn |
| Vite | https://vite.dev/guide/ |
| ESLint | https://eslint.org/docs/latest/ |
| Prettier | https://prettier.io/docs/ |
3D Engine
Performance & Effects
| Package | Doc |
|---|---|
| r3f-perf | https://github.com/utsuboco/r3f-perf |
| AnimationMixer | https://threejs.org/docs/#api/en/animation/AnimationMixer |
🗂 Project Structure
la-fabrik/
├── public/
│ ├── models/
│ │ ├── map/ # Base map — loaded once at start
│ │ ├── workshop/
│ │ ├── powerGrid/
│ │ └── farm/
│ ├── textures/
│ └── sounds/
│
└── src/
├── world/ # Single persistent 3D world
│ ├── Map.tsx # Base map, always mounted
│ ├── Environment.tsx # HDRI, fog, sky
│ ├── PostFX.tsx # Bloom, SSAO, chromatic aberration
│ ├── zones/ # Spatial zones — LOD per zone
│ │ ├── WorkshopZone.tsx
│ │ ├── PowerGridZone.tsx
│ │ ├── FarmZone.tsx
│ │ ├── SchoolZone.tsx
│ │ └── ResidentialZone.tsx
│ └── player/
│ ├── FPSController.tsx # PointerLockControls + Rapier movement
│ └── Crosshair.tsx
│
├── components/
│ ├── 3d/ # Shared reusable 3D elements
│ │ └── InteractiveObject.tsx # Raycasting + outline wrapper
│ └── ui/ # HTML overlays — outside Canvas
│ ├── NarrativeOverlay.tsx # Floating dialogues
│ ├── MissionHUD.tsx # Current objective
│ ├── MapHUD.tsx # Minimap
│ ├── CinematicBars.tsx # GSAP black bars
│ └── LoadingScreen.tsx # Asset progress
│
├── stateManager/ # All logic, state, orchestration
│ ├── GameManager.ts # Single source of truth: phase, zone, mission
│ ├── CinematicManager.ts # GSAP timelines, camera lock/unlock
│ ├── AudioManager.ts # Music, SFX, spatial audio
│ └── ZoneManager.ts # Zone detection, LOD triggers
│
├── hooks/ # React hooks — thin wrappers on managers
│ ├── useGameState.ts # Subscribes to GameManager
│ ├── useZoneDetection.ts
│ ├── useInteraction.ts
│ ├── useCinematic.ts
│ ├── useAudio.ts
│ └── useLOD.ts
│
├── data/
│ ├── zones.ts # { id, position, radius, missionId }
│ ├── dialogues.ts # Narrative scripts, PNJ states
│ └── missions.ts # Mission definitions, steps
│
├── shaders/
│ └── hologram/
│ ├── vertex.glsl
│ └── fragment.glsl
│
├── utils/
│ ├── Debug.ts # lil-gui panel
│ ├── EventEmitter.ts # Decoupled event bus between managers
│ └── Dispose.ts # traverse() + dispose() helper
│
├── App.tsx # Canvas + UI superimposed
└── main.tsx
🏗 Architecture Patterns
These patterns are mandatory across the entire codebase. Every class, every 3D object, every manager follows the same conventions. Consistency over cleverness.
1. Singleton Pattern
Every Manager and core utility uses the same singleton pattern. One instance, shared everywhere — no prop drilling, no context, no stray new calls
// stateManager/GameManager.ts
export class GameManager {
private static _instance: GameManager | null = null
cinematic!: CinematicManager
audio!: AudioManager
zone!: ZoneManager
npc!: NPCManager
static getInstance(): GameManager {
if (!GameManager._instance) {
GameManager._instance = new GameManager()
}
return GameManager._instance
}
private constructor() {
this.cinematic = CinematicManager.getInstance()
this.audio = AudioManager.getInstance()
this.zone = ZoneManager.getInstance()
this.npc = NPCManager.getInstance()
}
destroy(): void {
this.cinematic.destroy()
this.audio.destroy()
this.zone.destroy()
this.npc.destroy()
GameManager._instance = null
}
}
// Usage — anywhere in the codebase
const game = GameManager.getInstance()
game.startMission('workshop')
Apply the exact same pattern to every Manager and utility class (CinematicManager, AudioManager, Debug, Sizes, EventEmitter)
2. Class Interface — load / update / destroy
Every 3D class implements the same three-method lifecycle. No exceptions.
// Enforced interface for all world objects
interface WorldObject {
load(): Promise<void> // Load assets, build scene graph
update(delta: number): void // Per-frame logic (called from useFrame)
destroy(): void // Clean GPU memory — mandatory
}
// world/zones/WorkshopZone.tsx
export class WorkshopZone implements WorldObject {
private group: THREE.Group = new THREE.Group()
private mixer: THREE.AnimationMixer | null = null
async load(): Promise<void> {
const gltf = await loadGLTF('/models/workshop/ebike.glb')
this.group = gltf.scene
this.mixer = new THREE.AnimationMixer(this.group)
this.scene.add(this.group)
}
update(delta: number): void {
this.mixer?.update(delta)
}
destroy(): void {
this.mixer?.stopAllAction()
Dispose.object(this.group) // ← always traverse before remove
this.scene.remove(this.group)
}
}
3. State Management — Single Source of Truth
The project uses a single authoritative GameManager for durable gameplay state. React components subscribe to this state through thin custom hooks. High-frequency values such as movement, camera interpolation, or physics never go through React state and stay in refs or frame-based systems.
// GameManager.ts — single source of truth
type Phase = 'loading' | 'intro' | 'exploring' | 'cinematic' | 'outro'
type ZoneId = 'workshop' | 'powerGrid' | 'farm' | null
type GameSnapshot = {
phase: Phase
activeZone: ZoneId
missionId: string | null
missionStep: number
inputLocked: boolean
dialogueId: string | null
}
export class GameManager {
private static _instance: GameManager | null = null
private listeners = new Set<() => void>()
private state: GameSnapshot = {
phase: 'loading',
activeZone: null,
missionId: null,
missionStep: 0,
inputLocked: false,
dialogueId: null,
}
static getInstance(): GameManager {
if (!GameManager._instance) {
GameManager._instance = new GameManager()
}
return GameManager._instance
}
getState(): GameSnapshot {
return this.state
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private emit(): void {
this.listeners.forEach((cb) => cb())
}
setPhase(phase: Phase): void {
this.state.phase = phase
this.emit()
}
setActiveZone(zone: ZoneId): void {
this.state.activeZone = zone
this.emit()
}
startMission(id: string): void {
this.state.missionId = id
this.state.missionStep = 0
this.emit()
}
}
// hooks/useGameState.ts
import { useEffect, useState } from 'react'
import { GameManager } from '@/stateManager/GameManager'
export function useGameState() {
const game = GameManager.getInstance()
const [state, setState] = useState(game.getState())
useEffect(() => {
return game.subscribe(() => {
setState({ ...game.getState() })
})
}, [game])
return state
}
All other managers (Cinemactic, Audio, Zone) remain as side effects that communicate through GameManager
4. Memory Management — traverse() + dispose()
Every destroy() must call Dispose.object() before removing anything from the scene. Skipping this leaks GPU memory (VRAM) silently — no error thrown, just a crash after a few zone transitions. Rule: traverse first, remove second. Always.
// utils/Dispose.ts
import * as THREE from 'three'
export class Dispose {
/**
* Recursively disposes all geometries, materials, and textures
* from an Object3D and its entire subtree.
*
* Always call this before scene.remove() to prevent VRAM leaks.
*/
static object(obj: THREE.Object3D): void {
obj.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return
// 1. Dispose geometry buffers
child.geometry.dispose()
// 2. Handle single and multi-material meshes
const materials = Array.isArray(child.material)
? child.material
: [child.material]
for (const mat of materials) {
// 3. Dispose every texture referenced by the material
for (const value of Object.values(mat)) {
if (value instanceof THREE.Texture) {
value.dispose()
}
}
// 4. Dispose the material itself
mat.dispose()
}
})
}
/**
* Dispose a WebGL render target and its textures.
*/
static renderTarget(rt: THREE.WebGLRenderTarget): void {
rt.texture.dispose()
rt.dispose()
}
}
Usage pattern — identical in every destroy():
destroy(): void {
Dispose.object(this.mesh) // Frees VRAM: geometries, materials, textures
this.scene.remove(this.mesh) // Then removes from scene graph
}
5. Debug Utility
Activate the debug panel by appending ?debug to the URL (http://localhost:5173?debug). Never scatter if (isDev) blocks across files — all debug logic flows through Debug.ts.
// utils/Debug.ts
import GUI from 'lil-gui'
export class Debug {
private static _instance: Debug | null = null
readonly active: boolean
gui: GUI | null = null
static getInstance(): Debug {
if (!Debug._instance) Debug._instance = new Debug()
return Debug._instance
}
private constructor() {
this.active = new URLSearchParams(window.location.search).has('debug')
if (this.active) {
this.gui = new GUI({ title: 'La-Fabrik Debug' })
}
}
destroy(): void {
this.gui?.destroy()
Debug._instance = null
}
}
// Usage in any class
const debug = Debug.getInstance()
if (debug.active) {
debug.gui!.add(this.mesh.position, 'y', -5, 5).name('Height')
}
🚀 Getting Started
git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git
cd La-Fabrik
npm install
npm run dev
Open http://localhost:5173 — standard experience.
Open http://localhost:5173?debug — debug panel + r3f-perf overlay.
📜 License
See LICENSE file.