Imported from yoanmarchal/villagecraft (
AGENTS.md). Install upstream withnpx skills add yoanmarchal/villagecraft. Copyright stays with the author.
Project Guidelines
VillageCraft — a Vite + React + TypeScript app that procedurally generates a 3D voxel village using Three.js (@react-three/fiber, @react-three/drei, @react-three/csg). See spec.md for the original functional spec (French).
Build and Test
npm run dev— start Vite dev servernpm run build—tsc -b && vite build(build fails on type errors)npm run typecheck—tsc -b --pretty falsenpm test— Vitest (vitest run); tests live next to the code as*.test.ts(grid logic, cell utils,buildVillage).npm run test:watchfor watch mode.npm run lint— ESLint 10 flat config (eslint.config.js):@eslint/js+typescript-eslintrecommended +eslint-plugin-react-hooksrecommended (includes the React Compiler rules such asset-state-in-effect). Fix findings rather than disabling rules; a justifiedeslint-disable-next-linewith a comment is acceptable (seerenderTickinApp.tsx).- CI: .github/workflows/ci.yml runs lint, typecheck, tests and build on pushes to
devand on PRs; the Pages deploy (deploy.yml, onmain) runs lint + tests before building. Node 22.
Architecture
- Grid state & procedural generation: src/villageGrid.ts owns the 3D
GridCell[][][]andrecomputeProceduralLogic(), which assigns each cell'sBlockType(Empty,Foundation,Wall,WallWithWindow,Roof,Arch) by inspecting neighbours. Always go through this recompute after any grid mutation — never patchcell.typedirectly. The grid instance lives inApp'suseState, so a hot update would keep running the old rules:villageGrid.tsends withimport.meta.hot?.accept(() => window.location.reload())— keep it. - Neighbour/shape queries: src/utils/cellUtils.ts — always query neighbours via
getCell()/hasOccupiedCell()on aCellLookup, never index the raw grid array directly. The lookup is rebuilt per recompute; don't cache it across mutations. - Grid mutations go through the controller: after mutating the
VillageGrid(add/remove/clear/generate), calluseGridControllerStore.getState().commit()— it records an undo snapshot (Historyin src/history.ts, states =exportBlocks()) and triggers the scene refresh. Don't call the refresh callback directly. Undo/redo restore viagrid.replaceBlocks(), which keeps unchanged cells from replaying their spawn animation. Resizing the grid (newVillageGrid) resets the history. - Share links: src/store/shareLink.ts encodes
{ gridSize, blocks }as#v=<base64url>(1 byte per value, format version first — bump it if the layout changes).consumeSharedVillageLink()runs inmain.tsxbefore the first render. Grid bounds live in src/config/gridConfig.ts. - Grid is pure occupancy + shape:
VillageGridknows nothing about colors or the UI store. It persists viaexportBlocks()/importBlocks()(real blocks in placement order, auto-roof caps excluded) — used by src/store/villageStorage.ts (localStorage) and by grid resizing inApp.tsx. - Render settings: every style value the geometry depends on (colors, roughness, roof/tower/corner shapes) is a
RenderSettingsobject passed tobuildVillage()and exposed to builders asctx.settings. Builders andcellUtilsmust never readuseControlStore— to add a setting, add it to the store slice, toRenderSettingsand topickRenderSettings()(the compiler flags a missing key). - Rendering pipeline (static merge): cells are NOT rendered as individual React meshes. Each
BlockTypehas a pure parts builder insrc/render/cells/(standardCellParts,wallWindowCellParts,roofCellParts,archCellParts) that returnsPart[]— { cached geometry, local matrix, color, material spec } (see src/render/parts.ts). src/render/buildVillage.ts computes oneCellContextper cell (exposed faces, corner radii, isolation), dispatches to the builder, groups parts by material key and merges each group into a single vertex-colored BufferGeometry → ~10 draw calls for the whole village. src/components/VillageMeshes.tsx memoizes the merge oncellsand disposes replaced geometries. Rebuild happens only on grid mutations, never per frame. - Sky, ground & window glow: the background is a screen-space gradient (src/components/GradientBackground.tsx,
skyTopColor→skyHorizonColor) — drei's physicalSkywas removed because, seen from above, it only ever showed its grey below-horizon half. The village sits on a diorama base (src/components/GroundTile.tsx: grass slab flush with y = 0 over an earth block); clicks are caught by an invisible plane (colorWrite={false}). Parts withglow: truein theirMaterialSpec(window glass, arrow slits) form their own merged group whose emissive is driven bywindowGlowinVillageMeshes— imperatively, theninvalidate(). Bloom's threshold (0.85) is set so that only those lit windows bloom. - Changing a persisted default:
persiststores the whole control state, so a new default never reaches existing users on its own. Bump the storeversionand extendmigrateControlState()in src/store/controlStore.ts: replace a key only if it still equals the old default, delete removed keys (tested incontrolStore.test.ts). - Ambient occlusion: N8AO via src/components/AmbientOcclusion.tsx, imported dynamically by
VoxelSceneonceaoEnabledis on, and mounted as a regularEffectComposerchild (noSuspense— the composer only rebuilds its passes when its children change).n8aohas its own chunk group invite.config.ts(priority,includeDependenciesRecursively: false); otherwise rolldown folds it (~77 kB gzip) into the startupr3fchunk. Types forn8aoare declared insrc/n8ao.d.ts. - On-demand rendering: the
Canvasusesframeloop="demand"— a frame is drawn only when R3F props change, OrbitControls moves (drei invalidates) or something callsinvalidate(). Anything animated per frame must keep callinginvalidate()while it runs (see the spawn animation inVillageMeshes.tsx), otherwise it freezes after one frame. Exception: when the Debug perf monitor is on, the loop switches to"always"(r3f-perf samples through R3F's global loop callbacks, which only run on rendered frames). - Geometry cache: src/render/geometryCache.ts — every geometry (box, rounded box, cylinder, extruded cell contour, window frame-with-hole, gable…) is built once per parameter combination and shared. Never mutate a cached geometry; the merge copies its attributes. The cache is an LRU bounded to 1500 entries (a full village uses ~350); cached geometries are never rendered directly, so eviction needs no
dispose(). Vertex budget:roundedBoxGeocaps its segments by radius (1 segment for r ≤ 0.015, max 2 otherwise) — a smoothness-4 rounded box is ~2 900 vertices, and these tiny parts once made up ~94 % of the village's vertices for no visible gain. Keep new small decorative parts cheap (a village cell is ~4 k vertices today). Window frames use a shape-with-hole extrusion, not runtime CSG. - Scene/interaction: src/components/VoxelScene.tsx (R3F canvas, OrbitControls, click-to-add/remove on the ground plane), reading scene/lighting/postFX values from src/store/controlStore.ts.
App.tsxmemoizescellson the render tick so pointer-move re-renders never trigger a merge rebuild. - Toolbar & overlays: src/components/Toolbar.tsx (tools, history, generate/clear, ambience picker, share, screenshot, help), src/components/HintCard.tsx (first-run help, touch-aware). Their UI state (active tool, hint dismissed, screenshot capture callback) lives in src/store/uiStore.ts. Ambience presets are plain data in src/config/ambiencePresets.ts, applied with
useControlStore.setState(preset.settings). The screenshot uses R3Fadvance()+toDataURL()in the same task (seeScreenshotBridgeinVoxelScene.tsx) — don't enablepreserveDrawingBufferfor it. UI colors are CSS tokens (--ui-*) instyles.css, which also themes Tweakpane through its--tp-*variables. - Control panel: src/components/TweakpanePanel.tsx owns the Tweakpane
Panelifecycle and composes oneregister*Controls(pane)module per domain fromsrc/controls/(grid, lighting, sky/fog, camera, post-FX, debug, actions) — each is a plain function returning a disposer, built withregisterStoreFolder(pane, { title, fields })from src/controls/storeFolder.ts: a declarative list of[storeKey, tweakpaneBindingParams]written straight to the store ([x, y, z]tuples are edited as Tweakpane 3D points), and a folder only refreshes when one of its own keys changes. Add a setting by adding its key to the store (+ default, + migration if persisted users need it) and one line in the relevant module; add a new domain with one new module file + one line inTweakpanePanel.tsx'sdisposersarray. Village actions live in the toolbar, which reaches the imperativeVillageGridthrough src/store/gridControllerStore.ts (non-persisted) rather than prop-drilling. - Decorations & protected zones: src/config/protectedAreasConfig.ts defines window/door/arrow-slit exclusion zones; src/render/cells/stoneParts.ts and src/render/cells/decorations.ts must respect these zones when placing decorative stone/quoins — the
isInProtectedZonecallback stays per-cell-type, don't centralize that logic.
Conventions
- Adding a new
BlockType: add the enum value in src/types.ts, create a parts builder undersrc/render/cells/(returnPart[]with cell-local matrices; usegeometryCachehelpers), dispatch it inbuildVillage.ts, and extendrecomputeProceduralLogic()invillageGrid.tsto assign it. - Defensive tops (towers, curtain walls):
roofCellPartspicks the top of a column from its context, without newBlockTypes. Isolated roof → tower: slate roof following the tower's own contour (spireBandGeo/towerSoffitGeo: flared eave then steep cone, colourspireColor), or, for ~1 tower in 3 chosen by a hash of its column, a crenellated top with no roof.isRampart()incellUtils.tsdetects curtain walls (1-cell-thick straight run whose both ends butt against taller columns) → wall-walk + crenellated parapets with arrow-slit merlons instead of a gable roof;CellContext.isRampartis also true for the walls below, which get arrow slits and no door. Merlons (merlonCountfor towers — rounded to a multiple of 4 and laid out symmetrically from the middle of each side, never on the rounded corners where they read as 45° chevrons;merlonR,merlonH) are rectangular stone, never on a roofed tower — that's how they're used on real enceintes. - Arches, paving, walkways: an arch is a block with nothing below it, which a click can never create (clicks stack on top of the column), so arches are automatic, like roof caps:
syncAutoArches()spans a street 1 orMAX_ARCH_SPAN(2) cells wide — never more, a wider street stays open, open at ground level, between two facing buildings with at least two visible levels (occupied at y = 0 and y = 1, roof caps included — two one-click houses are enough), one arch per street stretch (consecutive rows with the same gap), in the middle; at a crossroads the X span wins.recomputeProceduralLogicruns roofs → arches → roofs: arches lean on the caps of one-storey houses, and an arch that disappears (passage built) must leave room for that column's cap in the same pass — otherwise results depend on click order (a test builds the same shape in 50 random orders).isSimpleArch(the type rule) only requires one opposite pair and nothing below. Auto arches (isAutoArch) are not user blocks: not exported/saved, skipped bygetTopOccupiedY/getTopRealOccupiedY(a click in the lane builds on the ground, demolition ignores them), and they vanish when their supports go. Test helpers note:addBlockInColumnfills gaps from the bottom; the UI path isgetNextPlacementY+addBlock. AnArchcell is a solid masonry block whose underside is cut by a segmental arch, with voussoirs + keystone on both faces — no pillars. A multi-cell arch is one arc computed for the whole span (rise 0.42 / 0.66 for 1 / 2 cells): each cell draws its slice (archBodyGeo(rise, span, slice)) and the voussoirs whose middle falls in it (archVoussoirMidX); the span axis follows the neighbours on each side, the span length the contiguousArchcells, and if nothing is built above, its top depends on the columns around all its cells: crenellated walkway like curtain walls (crenellatedWalkwayParts()) if one is a tower column (isTowerColumn); else a gable roof, ridge from support to support (gableRoofParts(), exported fromroofCellParts,baseY: 1), if at least two of those columns are topped by a gable roof (not a tower, not a rampart); else a plain flagstone deck (walkwayDeckParts()). Walkways useflagstoneParts()(src/render/cells/walkwayParts.ts). Ground:buildVillage(…, ground)paves every empty ground cell that touches a building, diagonals included (src/render/cells/pavingParts.ts) — lanes, streets and passages under arches — plus any cell under an arch, with one merged cobble tile per cell (cobbleTileGeo, 3 variants × 4 rotations picked from the cell position); further out the grass shows. - Tower propagation is intentional:
isTowerColumn()incellUtils.tspropagates upward — if a block is isolated (no horizontal neighbours), all blocks below it in the column become cylindrical too. Don't simplify this to a per-block check; it's what makes towers look right.isIsolatedBlock()(used for roofs) is a separate, non-propagating check — this asymmetry is deliberate. - Colors: base colors come from
settings.wallBaseColor/settings.roofBaseColor; derived tones useshades()/varyColorBrightness()fromsrc/colorPalettes.ts. Any per-cell variation must be deterministic (hash of coordinates) — neverMath.random()in a builder. - Windows: the "one window per floor between adjacent blocks" rule in
recomputeProceduralLogic()only looks at neighbours already typed in the current pass (x-1, z-1), so the result depends on shape only, not click order. Keep it that way (there's a test for it). - Terrain generation (
generateTerrain(gridSize, seed?)invillageGrid.ts) uses seeded value noise (src/utils/noise.ts) for occupancy/height (clusters of buildings, ≥ 40 % of lots filled), empty rows as streets on grids ≥ 6, and rare +2 landmark columns. Same seed → same village (tested). - Grid size & scene scale: bounds live in src/config/gridConfig.ts (max 12). Camera/fog distances were tuned for a 5-grid;
sceneScale()stretches fog andmaxDistance,initialCameraScale()the starting camera, andshadowCameraHalfSize()sizes the sun's orthographic shadow frustum (three's default ±5 only covered ~5 cells).
