@AGENTS.md
SKILL.md and CLAUDE.md MUST update on every code change. SKILL.md = agent-facing API reference (agents have NO source access). CLAUDE.md = engine internals for contributors. No line numbers — stale immediately. Use function/file names.
- Landing:
client/landing/index.html(webjsx + 247420 design system, at/spoint/); usesclient/colors_and_type.css(copied from design system) - Demo:
/spoint/demo.html(wasindex.html) - Content:
client/landing/content/— section JSONs merged by flatspace at CI time intocontent.json - CI order: build dist → build landing →
mv dist/index.html dist/demo.html→ copy landing asdist/index.html - Singleplayer auto-redirect sed targets
dist/demo.htmlonly — neverdist/index.html - World config path resolution:
?world=<name>parameter loads world config (e.g.,survivor.js) withplayerModel: './apps/tps-game/cleetus.vrm'. CI sed patches source literals but runtime path values need resolution againstdocument.baseURIinclient/app.jsto inherit/spoint/prefix. Without runtime resolution, relative paths 404 on gh-pages. Fix: wrap path-valued config fields inresolveWorldConfigPaths(wd)helper beforeBrowserServer.connect(wd).
- Server:
node server.js(port 3001, 64 TPS world config, 128 TPS SDK default) - World config:
apps/world/index.js - Apps:
apps/<name>/index.jswithserverandclientexports - Physics: Jolt via
src/physics/World.js - GLB extraction:
src/physics/GLBLoader.js - Load tester:
src/sdk/BotHarness.js - Terrain:
client/TerrainSystem.js— auto-init from world configterrainfield;terrain: falsedisables
createTerrainSystem(scene, args, sceneConfig) in client/TerrainSystem.js. Infinite chunked FBM terrain generated in a Web Worker Blob URL. Auto-initialized in client/app.js onWorldDef from world config terrain field. Call terrain.update(camera.position) every animate frame.
- TerrainSystem.js: chunk lifecycle, Worker management
- TerrainMaterial.js:
TERRAIN_DEFAULTS+createTerrainMaterial(sceneConfig)ShaderMaterial factory - TerrainDebug.js:
attachTerrainDebug/detachTerrainDebug— registerswindow.__debug.terrain - TerrainShaders.js: VERT_SHADER + FRAG_SHADER — biome blending, rock/grass/water/sand, simplex noise detail, fog
- TerrainWorkerSource.js: self-contained worker JS — FBM noise, geometry + normal generation, transferable ArrayBuffer output. Echoes
t0from postMessage so client can measure per-chunk build duration. - TerrainNoise.js: standalone
createFbmNoiseexport for non-worker use - TerrainBiomePalette.js:
BIOME_KINDS+BIOME_PALETTE. Worker reads only slots 0 (low) and 1 (high) per kind viapaletteColor(kind, slot) - Dependencies:
simplex-noiseandaleanpm packages
World config integration: add terrain: { seed, octaves, frequency, amplitude, renderDistance, chunkSize, resolution, strength, ... } to world config. terrain: false disables. Default values: seed:0, octaves:10, frequency:0.07, amplitude:0.5, renderDistance:4, chunkSize:10, resolution:96, strength:2.8. Defaults in TERRAIN_DEFAULTS.
Debug API: window.__debug.terrain exposes { chunks, pending, cfg, lodDist, timing, api } where chunks = loaded cache size, pending = generation queue size, cfg = current config, lodDist = {<resolution>: count} map, timing = { samples, avg, p50, p95, max, workers } rolling 60-chunk build duration in ms, api = direct system handle.
Biomes: desert (canyon-style with river cuts) blended with default (erosion, lakes, rivers) via low-frequency FBM. Blend controlled by biome vertex attribute passed to shader.
Worker: Blob URL worker (not module type) — all noise/geometry logic inlined in TerrainWorkerSource.js. Uses transferable ArrayBuffers for zero-copy geometry transfer.
setConfig(patch): hot-reloads terrain with new params, rebuilds all loaded chunks.
Terrain Chunk Performance Optimizations
Chunk build cost dominated by Inverse Distance Weighting (IDW) sampling of all anchors at every vertex (100 anchors × 96×96 grid = ~940k FBM evals per chunk). Two perf wins:
-
IDW early-exit (
client/TerrainWorkerSource.js): InbuildGeometry(), compute IDW weights first, then skip samplers with negligible contribution (weights[i] < 1e-3 * maxWeight). Single chunk drops from ~167ms to ~124ms (26% improvement). -
Worker pool (
client/TerrainSystem.js): Replace single Worker with pool ofmin(4, max(2, hardwareConcurrency - 2))workers, dispatch chunks round-robin viaworkers[_wNext]; _wNext = (_wNext + 1) % workers.length. Parallelizes 100+ chunks: serial ~12.4s → 4 workers ~3.1s. Pool cleanup onterrain.destroy(). Memory overhead ~8–12MB acceptable for first-load.
Both critical for first-load on large renderDistance worlds — single worker serializing 100+ chunks at 124ms each causes 10+ seconds of visible "slow chunk creation".
Terrain LOD T-junction Crack Fix (Skirt Geometry)
Chunks render at multiple LODs (LOD_RES = [192, 96, 48] in TerrainSystem.js). Adjacent chunks at different LODs share world-space edges but NOT vertex positions, creating T-junctions where light bleeds through gaps (white cracks visible between terrain and fog). Fix: emit edge skirts in buildGeometry() at TerrainWorkerSource.js:
- Duplicate the (resolution+1)×4 edge vertices (top, bottom, left, right) into skirt array
- Drop skirt vertices 50m below (constant
skirtDepth = 50) but keep same XZ and color as inner edge - Stitch with two triangles per edge segment (winding varies by edge orientation)
Skirt remains invisible behind far fog plane but prevents light leaks. Vertex count grows (R+1)² → (R+1)² + 4(R+1); index grows by 4R×2 triangles. If cracks reappear after config changes, verify skirtDepth large enough vs new strength parameter (height scale multiplier) — 50m handles ~30m height delta safely.
Procedural trees + ground cover. Off by default — opt in via vegetation: { ... } in world config. Requires terrain: { ... } in same world (reads same heightfield + biome anchors for deterministic placement).
Files:
src/terrain/VegetationPlacement.js— deterministic jittered-grid sampler.createVegetationPlacement({ field|sampleHeight, chunkSize, seed, cellSize, densityScale, slopeLimit, minY }).placementsForChunk(cx, cz)returns[{species, speciesId, x, y, z, rot, scale, seed}]. Species picks IDW-weighted againstTerrainField.anchorsbiome-kind map viaSPECIES_WEIGHTS.src/physics/VegetationPhysics.js— chunked Jolt capsule streaming mirroringTerrainPhysics. Only tree species (TREE_SPECIESset: oak/pine/fir/willow/palm) get capsules; bushes/grass visual only. Budget: 2 chunks/tick, 3ms wall.client/VegetationSystem.js— client rendering. Bakes all species once at init via ez-treeTree().generate(), splits into{trunk, foliage}parts, caps atMAX_INSTANCES_PER_CHUNK=64per species per chunk, buildsTHREE.InstancedMeshper part.update(cameraPosition)mirrors terrain chunk add/remove by renderDistance ring.
World config: vegetation: { seed, renderDistance, densityScale, cellSize, slopeLimit, minY } — defaults match placement defaults (cellSize=8, densityScale=0.35 → ~29 trees/chunk). Server and client both get config — server via AppRuntime.setVegetationConfig() (called from ServerAPI.js and WorkerEntry.js), client via onWorldDef in client/app.js. Sampler is pure math so both sides produce identical placements given same seed + terrain config.
Species bake: SPECIES_CONFIG in VegetationSystem.js maps species → ez-tree TreeType + trunk length + levels. Foliage material forced DoubleSide. Trunk casts shadow; foliage does not (perf).
Debug API: window.__debug.vegetation = { stats: { chunks, species }, species: Map, placement, update, dispose }.
gh-pages: .github/workflows/gh-pages.yml copies @dgreenheck/ez-tree into dist/node_modules. importmap entry added to client/index.html.
Running a specific world server-side: WORLD=survivor node server.js loads apps/world/survivor.js (resolved in src/sdk/server.js:boot()). Default is apps/world/index.js. Required for multiplayer terrain/vegetation — otherwise the server-side WORLD_DEF has no terrain/vegetation block and clients won't initialize either system. Singleplayer (?singleplayer&world=survivor) imports the world client-side and is unaffected by the env var.
Ground cover rendering: bushes and grass render as THREE.PlaneGeometry billboard cards (bushes use crossed double-planes for volume), not ez-tree meshes. Card texture is a procedural canvas gradient + vertical streak alpha (see makeCardTexture in VegetationSystem.js). Cards have alphaTest: 0.4, DoubleSide, no shadow cast (receive only). Up to 256 ground-cover instances per chunk vs 64 for trees (MAX_GROUND_COVER_PER_CHUNK / MAX_INSTANCES_PER_CHUNK).
Server-side terrain collision uses chunked Jolt HeightFieldShape streaming via src/physics/TerrainPhysics.js, separate from client rendering.
Key files:
src/terrain/TerrainHeight.js— pure ESM height sampler (server-side)src/physics/TerrainPhysics.js— chunked Jolt heightfield streamingsrc/physics/World.js—addHeightField(samples, sampleCount, scale, position)client/TerrainWorkerSource.js— client-side FBM generation (parity baseline)
Resolution parity:
- Visual: 96×96 (client-side, rendered)
- Physics: 32×32
COLLIDER_RES(intentionally lower — Jolt interpolates between samples) - Chunk origin:
[cx*chunkSize - chunkSize/2, 0, cz*chunkSize - chunkSize/2] - Height sample px conversion:
px = cx * chunkSize * 0.04 * 25(note* 25factor matches Worker internal scaling)
Height formula parity (CRITICAL): src/terrain/TerrainHeight.js MUST match client/TerrainWorkerSource.js FBM formula exactly. If client noise changes, server height queries diverge → collision mismatches, players fall through invisible geometry. Parity verified in test.js: TerrainHeight sampler matches Worker buildGeometry output. When updating noise: mirror changes in BOTH files, re-verify test.
AppRuntime integration:
AppRuntime._terrainfield holds activeTerrainPhysicsinstanceAppRuntime.setTerrainConfig(terrain)setter called fromServerAPI.loadWorld()(Node.js) andWorkerEntry.init()(singleplayer)AppRuntimeTick.jsticks terrain every_terrainInterval = tickRate/4ticks with player positions
gh-pages deployment: .github/workflows/gh-pages.yml copies src/terrain → dist/src/terrain — server-side code needed in singleplayer. renderDistance config shared between client rendering and server physics.
?singleplayer runs real server inside Dedicated Web Worker (src/sdk/WorkerEntry.js). Worker boots PhysicsWorld + AppRuntime + TickSystem from same server-side modules as Node.js. BrowserServer (client/BrowserServer.js) replaces PhysicsNetworkClient, communicates via transferable ArrayBuffer messages.
- WorkerEntry.js: polyfills
setImmediate;init()creates full server stack;WorkerTransportbridges Worker↔client viapostMessage - WorkerTransport.js:
TransportWrappersubclass;send()transfersArrayBufferownership (zero-copy); handles Uint8Array byteOffset - IDBAdapter.js:
StorageAdapterbacked by IndexedDB (no filesystem in Worker) - BrowserServer.js: extends
BaseClient;connect()imports/apps/world/index.jsand fetches app sources before spawning Worker.addPeer(offer, iceServers?)enables host-mode WebRTC — sendsINIT_PEERto Worker, returns{ answer, candidates } - Node.js portability:
GLBLoader.js,AppRuntime.js,EditorHandlers.js,src/sdk/TickHandler.jsguardawait import('node:fs')withtypeof process !== 'undefined' && process.versions?.node— Chrome Workers attempt fetch even inside try/catch, causing mixed-content errors on HTTPS. IMPORTANT: guard in source file — do NOT use CI sed. GNU sed double-substitution bug corrupts lines when replacement contains search string. - BrowserServer.js
_root:_root = _base.endsWith('/client/BrowserServer.js') ? new URL('../', _base).href : new URL('./', _base).href— dev serves fromclient/, gh-pages copies file to dist root._rootalways resolves to project root. All fetches useapps/,src/,singleplayer-world.jsonrelative to_root. Never use../apps/— resolves above/spoint/when file is at dist root. - importmap:
jolt-physics/wasm-compatmust be inclient/index.htmlimportmap — Chrome 89+ module Workers inherit page importmap, enablingWorld.jsto load Jolt - gh-pages: Worker needs full
src/tree. Copy ALL subdirs (sdk, connection, debug, netcode, physics, apps, stage, storage, transport, spatial) +src/math.js+jolt-physics. importmap sed:s|"/node_modules/|"/spoint/node_modules/| - App sources:
WorkerEntryreceives{ name, source }pairs, callsappLoader.loadFromString()— no filesystem needed AppLoader.loadFromString: usesURL.createObjectURL + import()— handles full ES module syntax. Apps via blob URL cannot use relative imports — dependencies must be inlined or viaengineCtx.BLOCKED_PATTERNSapplies to ALL sources (bothloadApp()andloadFromString()):import(,eval(,require(,process.exit,child_process,__proto__,Object.prototype,globalThis→ rejected silently. Environment app must NOT use dynamicimport()insetup()— use top-level init guarded byprocess.versions?.node."app"field on entities insingleplayer-world.jsonrequired — missing =setup()never runs, no collider created.- Editor in singleplayer: handlers work via
AppRuntimein-memory state;LIST_APPSreadsappRuntime._appDefs,SAVE_SOURCEcallsloadFromString(), broadcastsAPP_MODULE - Inspector intercept range:
Inspector.handleMessageinsrc/debug/Inspector.jsintercepts by type range.TRIMESH_DATA (0x92)must NOT fall in intercepted range — if it does, trimesh bodies never created, players fall through floor with no error. Verify exclusion range covers all non-inspector types when adding message type constants.
All three extend BaseClient (src/client/BaseClient.js). Identical public API: connect, disconnect, sendInput, send, sendFire, sendReload, getSmoothState, getRTT, getBufferHealth, getLocalState, getRemoteState, getAllStates, getEntity, getAllEntities.
- PhysicsNetworkClient — WebSocket to Node.js. Overrides
_handleSessionTokensfor reconnect token. Adds heartbeat +ReconnectManager. - BrowserServer — Worker
postMessage._handleSessionTokensis no-op.addPeer(offer, iceServers?)hosts WebRTC peers. - WebRTCClient — planned, not yet implemented. WebRTC peer connections handled by
BrowserServer.addPeer()+RTCDataChannelTransport.
BaseClient invariants: _handleSessionTokens = no-op hook, only PNC overrides. sendFire/sendReload in BaseClient, call this.send(). lastSnapshotTick/currentTick set in BaseClient._onSnapshot.
WebRTC relay: RTCPeerConnection NOT available in DedicatedWorkers in Chrome — BrowserServer.addPeer() runs it on main thread. On DataChannel open, posts PEER_CONNECT {peerId} to Worker; DataChannel messages relay as PEER_MESSAGE {peerId, data}; Worker replies PEER_SEND {peerId, data}. WorkerEntry.js uses _peerTransports Map of PeerTransport instances, handles PEER_CONNECT/PEER_MESSAGE/PEER_DISCONNECT. RTCWorkerBridge.js unused. _waitIce(pc) 3s timeout inlined — trickle ICE not supported.
- Physics world:
src/physics/World.js(coordinator, ≤200 lines) - Character physics:
src/physics/CharacterManager.js - Shape builder:
src/physics/ShapeBuilder.js - App context:
src/apps/AppContext.js - App runtime:
src/apps/AppRuntime.js - Physics mixin:
src/apps/AppRuntimePhysics.js - Tick mixin:
src/apps/AppRuntimeTick.js - Tick handler:
src/sdk/TickHandler.js - Player collision:
src/netcode/CollisionSystem.js(applyPlayerCollisions) - Snapshot encoder:
src/netcode/SnapshotEncoder.js - Snapshot processor:
src/client/SnapshotProcessor.js - Client interpolation:
src/client/interpolation.js(lerpScalar,slerpQuat,interpolateSnapshot) - Base client:
src/client/BaseClient.js - WebRTC transport:
src/transport/RTCDataChannelTransport.js - RTC bridge:
src/transport/RTCWorkerBridge.js - Edit panel DOM:
client/EditPanelDOM.js - Maps:
apps/maps/*.glb(Draco-compressed; CI strips viascripts/optimize-models.js)
client/SceneGraph.js — unified transform management. createSceneGraph(scene) returns sceneGraph. All entity/player THREE.Groups registered here, not added to scene directly.
addNode(id, group, opts)— registers group, adds to scene root.opts.isPlayer/opts.feetOffsetdistinguish types.removeNode(id)— removes from scene and map.setEntityTransforms(entities)— called once peronStateUpdate; writesx,y,z,vx,vy,vz,rx,ry,rz,rwdirectly intonode.targetplain object (NOT TransformLerp Map-based setters — those expect Map, not plain object).setPlayerTransforms(players, lid, getLocalState)— same; applies feetOffset and local state override for self.tick(frameDt, lerpFactor)— every animate frame; entity nodes lerp vialerpEntityTransform, player nodes apply viaapplyPlayerTransform. Returnstrueif any node moved.getNode(id)/getTarget(id)— used by animator system and editor raycasting.
TransformLerp.js — SceneGraph imports only lerpEntityTransform/applyPlayerTransform. setEntityTarget/setPlayerTarget expect Map as first arg — SceneGraph does NOT use them. Passing node.target plain object causes targets.get is not a function.
createRenderer(isMobile) in client/SceneSetup.js → THREE.WebGLRenderer (synchronous). No WebGPU — removed, persistent GPU OOM crashes.
Shadow maps: THREE.PCFShadowMap (PCFSoftShadowMap deprecated in Three.js 0.183). shadow.radius/shadow.blurSamples set for quality.
Pixel ratio: Mobile = devicePixelRatio * 0.5, desktop = native. No cap.
Loaders: createLoaders(renderer) → { gltfLoader, dracoLoader, ktx2Loader }. Single gltfLoader for map + entity loading. THREE.Cache.enabled = true. Draco workers = 4 with preload().
Shader warmup: warmupShaders disables frustumCulled, makes hidden objects visible, calls compileAsync with real camera, renders twice. Restores after. Cached in localStorage by entity count + sorted IDs.
AppRuntime.js applies two mixins at bottom of constructor — order matters:
mixinPhysics(runtime)fromAppRuntimePhysics.js—_syncDynamicBodies,_tickPhysicsLOD. Must be first.mixinTick(runtime)fromAppRuntimeTick.js—tick(),_tickTimers,_tickCollisions,_tickRespawn,_tickInteractables,_syncPlayerIndex,getNearbyPlayers.
entity.scale multiplies on top of GLB node hierarchy transforms, applied on both sides:
- Physics (
GLBLoader.js):buildNodeTransformscomputes world-space 4x4 matrices,applyTransformMatrixbakes into vertices, thenentity.scalemultiplied on top. - Visual (
client/app.js): Three.js GLTFLoader applies node transforms, thenentity.scaleviamodel.scale.set(entity.scale).
Collider methods in AppContext.js:
addBoxCollider: half-extents ×entity.scaleper-axisaddSphereCollider/addCapsuleCollider: radius ×max(entity.scale)— Jolt requires uniform scaleaddConvexFromModel/addConvexFromModelAsync: node hierarchy viaextractMeshFromGLB(Async), vertices ×entity.scaleper-axisaddTrimeshCollider: defers body creation — stores entity inruntime._pendingTrimeshEntities. Client extracts Three.js world-space geometry, sendsMSG.TRIMESH_DATA {entityId, vertices, indices}. Server callsphysics.addStaticTrimeshFromData(...)at origin with identity rotation — vertices frommesh.matrixWorldare already world-space. Physics = visual geometry by construction.
Non-uniform scale on capsules/spheres: not supported by Jolt. Use max(sx,sy,sz) or switch to box/convex.
Never set scale after collider creation — physics body won't update.
- Server:
entity.position,entity.rotation(quaternion [x,y,z,w]),entity.scale - Encoding:
encodeEntity()quantizes all three into fixed indices - Decoding:
SnapshotProcessor._parseEntityNew()decodes 17 fields; scale at indices 14-16 (default[1,1,1]) - Client load:
loadEntityModel()applies position, rotation, scale at load time - Dynamic updates: animate loop interpolates position/quaternion each frame; scale applied once at load only
Rotation always quaternion [x,y,z,w] — never euler.
Positions quantized to 2 decimal places (precision 100). Rotations: smallest-three quaternion packing — largest component dropped (2-bit index), three smallest mapped to 10-bit each, packed into uint32. Max angular error ~0.0014 rad (~0.16°).
- Player:
[id, px, py, pz, qPacked, vx, vy, vz, onGround, health, inputSeq, crouch, lookPitchYawByte]— 13 elements - Entity:
[id, model, px, py, pz, qPacked, vx, vy, vz, bodyType, custom, sx, sy, sz, sleeping]— 15 elements; sleeping=1 bit
qPacked uint32: bits 31-30 = largest component index, bits 29-20/19-10/9-0 = three smallest mapped to [0,1022].
Wrong field order breaks clients silently. SnapshotEncoder.decode (server) and SnapshotProcessor.fillPlayerArr/fillEntityArr (client) must stay in sync.
renderCtx(passed torender(ctx)):ctx.THREE,ctx.scene,ctx.camera,ctx.renderer,ctx.playerId,ctx.clock. Added inrenderAppUI()inclient/AppModuleSystem.js.engineCtx:engine.network.send(msg)— shorthand forclient.send(0x33, msg).onKeyDown/onKeyUpdispatch happens aftereditor.onKeyDown(e)viaams.dispatchKeyDown/dispatchKeyUp.
- No
client.renderunless app returnsui:field. - No
onEditorUpdatefor standard field changes —ServerHandlers.jsalready appliesposition,rotation,scale,custombefore firing hook. - Use
addColliderFromConfig(cfg)— handles motion type + shape. - Use
spawnChild(id, cfg)— auto-destroys children on teardown. - Helper functions must go OUTSIDE
export default {}—evaluateAppModulehoists only code-before-default; code after = unreachable. - Apps cannot use imports — all dependencies via
engineCtx.
box-static: visual box + static collider. Config:{ hx, hy, hz, color, roughness }.prop-static: static GLB + convex hull. Entity must havemodel.box-dynamic: dynamic physics box. Config:{ hx, hy, hz, color, roughness, mass }.
Set entity.model = null, populate entity.custom:
mesh:'box'|'sphere'|'cylinder';sx/sy/sz,r,h,segcolor,roughness,metalness,emissive,emissiveIntensityhover: Y oscillation amplitude;spin: rotation speed (rad/s)light: point light color;lightIntensity;lightRange
ctx.interactable({ prompt, radius, cooldown }) — top-level AppContext.js method (NOT ctx.physics). Writes ent.custom._interactable so snapshot carries config to client. _tickInteractables() fires onInteract(ctx, player) when within radius + E pressed.
ctx.physics.setInteractable(radius) exists for compat but does NOT write custom._interactable — client prompt won't appear. Prefer ctx.interactable().
ctx.state maps to entity._appState. On hot reload: new AppContext created, entity keeps _appState. State survives; timers and bus subscriptions destroyed and re-created.
_appModuleList = cached [...appModules.values()] — avoids Map iteration in hot onAppEvent handler. Rebuilt on every appModules change.
extractMeshFromGLB(filepath)— sync, throws on Draco/meshoptextractMeshFromGLBAsync(filepath)— async, handles Draco- Meshopt NOT supported. Decompress:
gltfpack -i in.glb -o out.glb -noq
GLBTransformer.js + GLBDraco.js (hasDraco, applyDraco) + GLBKtx2.js (imageToKtx2, encodeMode, applyKtx2) in src/static/. Applies Draco + KTX2, serves original immediately, caches to .glb-cache/.
- Draco skipped for VRM — gltf-transform NodeIO strips unknown extensions. Detected via
json.extensions?.VRM || json.extensions?.VRMC_vrm. - WebP-to-KTX2: builds
imageSlotHintsfrom material slots (normalTexture →uastc, others →basis-lz). Draco runs first, kept only if smaller. prewarm()scans.vrm+.glb.
KTX binary search order: bin/ktx.exe → bin/ktx → /usr/bin/ktx → /usr/local/bin/ktx. No binary → falls back to PNG downscale (≤1024px via sharp). Returns { buf, mimeType } where mimeType = 'image/ktx2' or 'image/png'. KHR_texture_basisu injected only when actual KTX2 produced.
prewarm() blocks server start: boot() in server.js awaits prewarm() before server.start().
scripts/optimize-models.js strips Draco + downscales textures at CI time — large Draco GLBs OOM during Three.js parse. EntityLoader.js implements _parsedGltfRefCount eviction to release parsed GLTF after all meshes instantiated.
gltf-transform pitfalls in scripts/optimize-models.js:
- Use
NodeIO.registerDependencies({...})— NOTKHRDracoMeshCompression.withConfig(doesn't exist in v4) - Dispose
KHR_draco_mesh_compressionfromdoc.getRoot().listExtensionsUsed()beforeio.writeBinary— otherwise gltf-transform re-encodes with Draco - Textures with no
sourcefield must be patched tosource: 0before read — null sampler lookup crashes NodeIO - Draco strip must happen before texture rewrite — bufferView indices change after strip
extractAllMeshesFromGLBAsync skips SKIP_MATS: aaatrigger, {invisible, playerclip, clip, nodraw, toolsclip, toolsplayerclip, toolsnodraw, toolsskybox, toolstrigger. Prevents phantom CS:GO collision walls. Client-side: loadEntityModel sets c.visible = false for same names.
client/ModelCache.js caches GLB/VRM ArrayBuffers in IndexedDB by URL. HEAD request checks ETag; 304 → cache; miss → full GET. With gzip, content-length NOT used as progress denominator (compressed ≠ decompressed).
LRU eviction: lru-manifest tracks { url, size, lastAccess }. Updated on every store/hit. After store, entries > 200MB pruned oldest-first to 150MB. Size = buffer.byteLength.
client/GeometryCache.js — two caches in IndexedDB store geometry-cache:
Draco decompression cache (getGeometry/storeGeometry): After gltfLoader.parseAsync() on Draco GLB, non-skinned geometries serialized (attributes as ArrayBuffers, index, drawRange, material props) keyed by URL. On next load with same ETag, reconstructGeometry(d) rebuilds THREE.BufferGeometry — Draco WASM decompression skipped.
LOD index cache (getLodIndex/storeLodIndex): After MeshoptSimplifier produces simplified index buffer in _simplifyObject, stored keyed by url:lod0/url:lod1. _scheduleLodUpgrades checks cache before calling MeshoptSimplifier.simplify.
_generateLODEager scale inheritance: copies model.position/quaternion/scale to LOD wrapper, resets model to identity before lod.addLevel(model, 0). Required — if model retains scale as child, THREE.js compounds scales (e.g. [3,3,3] × [3,3,3] = 9× size).
warmupShaders stores lastShaderWarmupKey in localStorage. Key = entity count + ID hash — unchanged world skips re-compilation.
Getters — destroy based on C++ return type:
BodyInterface::GetPosition/GetRotation/GetLinearVelocity→ by VALUE → MUSTJ.destroy(result)CharacterVirtual::GetPosition()→const RVec3&(internal ref) → do NOT destroy — crashesCharacterVirtual::GetLinearVelocity()→ by VALUE → MUST destroy
Setters: reuse _tmpVec3/_tmpRVec3 via .Set() — new Vec3/RVec3 per call leaks WASM memory.
Raycast: 7 temp Jolt objects — ALL must be destroyed.
Trimesh building: new J.Float3(x,y,z) in triangle loop leaks heap per vertex. Fix: reuse one J.Float3, set .x/.y/.z. Destroy J.TriangleList and J.MeshShapeSettings after shape creation.
Draco decompression: destroy all temp objects (Decoder, DecoderBuffer, Mesh, DracoFloat32Array, DracoUInt32Array).
Convex hull: addBody('convex', ...) accepts params as flat [x,y,z,...]. Destroy ConvexHullShapeSettings + VertexList after creation.
Capsule param order: CapsuleShape(halfHeight, radius) NOT (radius, halfHeight). addCapsuleCollider(r, h) passes [r, h/2]; World.js uses params[1] for halfHeight, params[0] for radius.
- Bodies only created in
setup(): settingentity.bodyType/entity.colliderdirectly has no effect. Body created only viactx.physics.addBoxCollider()etc. - CharacterVirtual gravity:
ExtendedUpdate()does NOT apply gravity.PhysicsIntegration.jsmanually appliesgravity[1] * dtto vy. Gravity vector toExtendedUpdatecontrols step-down/step-up only. - Physics substeps:
jolt.Step(dt, 2)— always 2. 1 substep causes tunneling at 64 TPS with gravity=-18 m/s². - TickHandler velocity override: after
updatePlayerPhysics(), XZ velocity written back over physics result. Only Y from physics. Changing breaks movement. - Movement: Quake-style air strafing.
groundAccelWITH friction,airAccelWITHOUT. World configmaxSpeedoverridesDEFAULT_MOVEMENT.maxSpeed— movement.js defaults NOT production. Current:maxSpeed: 7.0,sprintSpeed: 12.0.
physicsRadius in world config (default 0 = disabled). _tickPhysicsLOD(players) runs every tickRate/2 ticks, suspends sleeping bodies outside all players' combined AABB (~89% skip rate). Must be in config object passed to createServer().
- Suspend:
_physics.removeBody; position/rotation preserved in JS;entity._bodyActive = false; added to_suspendedEntityIds. - Restore:
_physics.addBodyre-creates at current position;_physicsBodyToEntityIdupdated with new id. entity._bodyDef: stored by collider methods whenbodyType === 'dynamic'. Contains{ shapeType, params, motionType, opts }. Static bodies never get_bodyDef.- Jolt body id stability: Jolt reuses sequence numbers after
DestroyBody. Restored bodies get new ids — always update_physicsBodyToEntityId. - destroyEntity:
_suspendedEntityIds.deletecleans up. NoremoveBodyneeded (already removed).
entityTickRate in world config sets app update() Hz (default = tickRate). entityDt = dt * divisor.
AppRuntime maintains _dynamicEntityIds (all dynamic) and _activeDynamicIds (awake only). _syncDynamicBodies() runs every tick, iterates _activeDynamicIds only. World.syncDynamicBody() → true when active, false when sleeping. Sleeping: e._dynSleeping = true — SnapshotEncoder skips re-encoding; Stage skips octree updates.
SpatialIndex skips re-insertion if entity moved < 1.0 unit (distance² < 1.0) — intentionally coarse for relevance radius=60.
snapGroups = Math.max(1, Math.ceil(playerCount / 50)) — sends to 1/N players per tick. 100p → 32 Hz. 200p → 16 Hz. Caps at ~50 sends/tick. Windows WebSocket I/O ~166μs per send.
Snap group rotation ALWAYS applied including keyframe ticks. On keyframe ticks, use encodeDelta(combined, new Map()) only — calling encode() AND encodeDelta() = double-encoding.
Static entities pre-encoded once per tick via encodeStaticEntities(), only when _staticVersion changes. encodeDelta receives staticEntries for new players, changedEntries on statics change, null otherwise.
buildDynamicCache() — cold-start (first tick, keyframe, spawn/destroy). refreshDynamicCache() — hot-path in-place via fillEntityEnc(), zero allocation. entry._dirty = true; key lazily rebuilt in applyEntry() only when sent.
encodeDeltaFromCache() iterates relevantIds instead of full dynCache. _updateList caches [entityId, server, ctx] tuples — rebuilt on _attachApp/detachApp.
When relevanceRadius > 0, getNearbyPlayers() filters by distance² vs radius² (no sqrt). _playerIndex updated every tick in _syncPlayerIndex().
encodeDelta stores [key, customRef, customStr] per entity. Unchanged entity.custom reference skips JSON.stringify. JSON.stringify used (not pack+hex) — 8-12x faster for change detection.
_priorityAccumulators (module-level Map in TickHandler.js) tracks per-client per-entity priority floats. Each tick accrues distScore + velScore + PRIORITY_DECAY (distScore = 1/(1 + distSq*0.001), velScore = min(1, |vel|*0.1)). When relevantIds.size > PRIORITY_ENTITY_BUDGET (64), top-64 selected, accumulators reset. New players always get full relevantIds. Accumulators cleaned on disconnect.
TickSystem measures avg tick duration over rolling 60-tick window. Load > 85% → dilationFactor decrements 0.05 (floor 0.1). Load < ~60% → recovers 0.05. Dilated dt passed to all callbacks. Server broadcasts MSG.TICK_DILATION (0x93) { factor } on change. Implement onDilation via MSG.TICK_DILATION in BaseClient.onMessage.
PHYSICS_PLAYER_DIVISOR = 3 in TickHandler.js — runs Jolt per player every 3rd tick. Staggered: (tick + player.id) % PHYSICS_PLAYER_DIVISOR — prevents thundering herd. Always runs on jump/airborne ticks. Uses fixed per-tick dt NOT accumulated — accumulated at divisor=3 (≈0.047s) exceeds Jolt's 2-substep threshold (≈0.018s), doubling CharacterVirtual cost.
Idle = no directional input + onGround=true + horizontal velocity < 0.01 m/s. After 1 settling tick, idle ticks skip updatePlayerPhysics(). Counter resets on movement.
_spatialCache (module-level, cleared each tick) groups players by floor(x/R)*65536+floor(z/R). Same cell → shared nearbyPlayerIds/relevantIds. _cellPackCache caches packed snapshots by cell key — ~43% hit rate.
Entity-entity: O(n²) brute-force < 100 entities, grid-based (cell=4 units, 9-neighbor) >= 100. Cells pruned every 64 ticks or when size > count * 4. Cooldown keys = e.id + ':' + p.id. _interactCooldowns prunes entries > 10s old every 256 ticks when size > 100.
cam.setEnvironment(meshes): non-skinned static meshes only — neverscene.children(includes VRM, causes CPU overhead).EntityLoader._animatedEntitiestracksfinalMesh(theTHREE.LODwrapper) —removeEntitylooks up viaentityMesheswhich storesfinalMesh; mismatch = animator leak.firstSnapshotEntityPending: tracks dynamic entities only. 5s timeout (_entityLoadTimeout) clears set so failed load can't block forever.
checkAllLoaded gates on five simultaneously: assetsLoaded (VRM + anim library), environmentLoaded (first entity mesh), firstSnapshotReceived, modelsPrefetched (set in onWorldDef), firstSnapshotEntityPending.size === 0. Pure-terrain worlds caveat: environmentLoaded is normally set by onFirstEntityLoaded when an entity mesh loads. Worlds with empty entities: [] never fire entity loads, so onWorldDef explicitly checks if (!wd.entities || wd.entities.length===0) { environmentLoaded=true; checkAllLoaded() } to unlock the gate. Without this, loading screen hangs at "Loading animations..." forever even though assets and snapshots arrived.
BrowserServer world config: connect() tries: this.config.worldDef → apps/world/index.js → singleplayer-world.json (gh-pages fallback) → {}. singleplayer-world.json critical — apps/world/index.js returns 404 on gh-pages. Entity app field required: missing = no setup(), no colliders.
Singleplayer VRM OOM invariants — violate any = heap spike:
createPlayerVRMMUST NOT be called beforeassetsLoaded=true— queues N concurrent VRM parses (300–400MB each), OOMs.group.userData.vrmPendingMUST be set synchronously before firstawait— guards re-entrant double-queuing.cacheClipsinAnimationLibrary.jsMUST remainawaited — fire-and-forget overlaps VRM loads with IndexedDB, spikes heap.loadEntityModelNOT gated onassetsLoaded— deferring breaks singleplayer raycasting (map BVH not ready → fall through floor).onStateUpdatecreates placeholder Groups immediately; callscreatePlayerVRMonly whenassetsLoaded && g.children.length===0 && !g.userData.vrmPending.
Cold path test: DevTools → Application → IndexedDB → delete spoint-anim-cache → reload ?singleplayer. Heap plateau < 1GB.
Three independent systems:
- ReloadManager — watches SDK source files.
swapInstance()replaces prototype/non-state properties, preserves state (e.g.playerBodiessurvives PhysicsIntegration reload). - AppLoader — watches
apps/. Reloads drain via_drainReloadQueue()at end of tick (never mid-tick)._resetHeartbeats()after each reload. - Client hot reload —
MSG.HOT_RELOAD (0x70)→location.reload(). Camera state preserved via sessionStorage.
AppLoader blocks even in comments: process.exit, child_process, require(, __proto__, Object.prototype, globalThis, eval(, import(.
After 3 consecutive failures, module stops auto-reloading until server restart. Exponential backoff: 100ms → 200ms → 400ms. Hot-reloaded imports use ?t=${Date.now()} to bust ESM cache.
All state machines MUST use xstate. 3+ states or non-trivial transitions → createMachine/createActor from xstate v5.
Use xstate when:
- 3+ states with defined transitions
- Guard against invalid transitions
- Reconnection/retry with backoff
- UI mode switching
- Per-instance state tracking
Boolean sufficient when:
- Single on/off, no transition logic
- Re-entrancy guard (
_inProgress) - Simple toggle, no guards
Import:
- Node.js:
import { createMachine, createActor } from 'xstate' - Browser:
import { createMachine, createActor } from '/node_modules/xstate/dist/xstate.esm.js' - Isomorphic:
const _isNode = typeof process !== 'undefined' && process.versions?.node; const { createMachine, createActor } = await import(_isNode ? 'xstate' : '/node_modules/xstate/dist/xstate.esm.js')
xstate-backed systems:
- App lifecycle (
apps/_lib/lifecycle.js): idle→setting_up→ready→error→destroyed - Animation (
client/AnimationStateMachine.js): IdleLoop/WalkLoop/JogFwdLoop/SprintLoop/CrouchIdleLoop/CrouchFwdLoop/JumpLoop/JumpLand/Death - Reconnect (
src/client/ReconnectManager.js): idle→connected→waiting→reconnecting→destroyed - Hot reload (
src/sdk/ReloadManager.js): watching→debouncing→reloading→disabled - Editor gizmo (
client/EditorMachine.js): translate/rotate/scale + drag sub-states - XR teleport (
client/XRWidgets.js): idle→fadeIn→delay→fadeOut - Tick system (
src/netcode/TickSystem.js): stopped→running→paused
- WORLD_DEF strips entities:
ServerHandlers.onClientConnect()removesentitiesbeforeMSG.WORLD_DEF. Pattern:const { entities: _ignored, ...worldDefForClient } = ctx.currentWorldDef. - Message types hex:
MessageTypes.js. Snapshot = 0x10, input = 0x11. - msgpack:
src/protocol/msgpack.jsre-exportspack/unpackfrommsgpackr. - TickSystem:
loop()max 4 ticks per iteration.setTimeout(1ms)when gap > 2ms,setImmediatewhen ≤ 2ms. - Entity hierarchy:
getWorldTransform()walks parent chain recursively. Destroying parent cascades. - EventBus: wildcard
*suffix (combat.*receivescombat.fire).system.*reserved.bus.scope(entityId)—destroyScope()unsubscribes all on destroy. Leaked subscriptions persist across hot reloads. - Debug globals: Server:
globalThis.__DEBUG__.server. Client:window.debug(scene, camera, renderer, client, mesh maps, input handler). Always set. - Static file serving priority:
/src/→/apps/→/node_modules/→/(client). Project-localapps/overrides SDKapps/. - Heartbeat: 3s timeout. ANY message resets timer. Client sends every 1000ms.
- Client input rate: 60Hz. Server uses only LAST buffered input per tick.
inputSequenceincrements for reconciliation. - Spatial grid player collision: cell size =
capsuleRadius * 8, 9-neighbor.other.id <= player.idprocesses each pair once.
Editor shell (client/EditorShell.js): Full-screen overlay. Exports createEditPanel. Layout: left sidebar 250px (scene hierarchy), right 300px (Inspector/Apps/HookFlow/Events tabs), top bar 40px, bottom 24px. Center pointer-events:none — canvas events pass through. Glassmorphism: rgba(5,12,10,0.82) + backdrop-filter:blur(18px) — GLASS constant at top.
Scene hierarchy (client/SceneHierarchy.js): webjsx entity tree. Filters by id/_appName. Emerald selection: rgba(16,185,129,0.14) bg, #a7f3d0 text. updateEntities(ents) re-renders via applyDiff.
Inspector panel (client/EditorInspector.js): Direct DOM (not webjsx) — avoids re-render conflicts during drag-input. Reuses drag/v3/propField from EditPanelDOM.js. Fully rebuilds DOM on showEntity(entity, eProps).
Apps panel (client/EditorApps.js): Apps list + Monaco. Calls renderEditorPane from EditPanelEditor.js when file open. openCode(app,file,code) switches to editor view.
HookFlow viewer (client/HookFlowViewer.js): SVG entity-app node graph. Pan via background drag, zoom via wheel. Nodes as raw SVG string via dangerouslySetInnerHTML on <g> wrapper. applyDiff manages outer SVG only.
Event log panel (client/EditorEventLog.js): Live server event table (tick/type/entity/app). Polls MSG.EVENT_LOG_QUERY (0x90) every 2s when active. EditorHandlers.js responds with last 60 events from ctx.eventLog. Empty in singleplayer. Auto-scrolls unless user scrolled up.
Gizmo modes (client/editor.js): [G] translate, [R] rotate, [S] scale, [F] focus, [Del] destroy. Mouseup sends EDITOR_UPDATE. setSnap(enabled, size) module-level export — _snapEnabled/_snapSize module-level so EditorShell calls via onSnapChange. onTransformCommit(cb) fires {entityId, before, after, kind} on mouseup.
Snap grid: SNAP toggle pill + 6 presets (0.1/0.25/0.5/1.0/2.0/5.0). State (_snapOn/_snapSz) in _buildTopBar closure. onSnapChange wires EditorShell → app.js → editor.setSnap().
Undo/redo (app.js): _undoStack/_redoStack capped at 20. Ctrl+Z → reverse EDITOR_UPDATE with before; Ctrl+Y/Ctrl+Shift+Z → resend after. Stack cleared on new transform commit. Before-state in _dragBeforeState at mousedown; after-state from mesh at mouseup.
Monaco offline (client/EditPanelEditor.js): Loaded from /node_modules/monaco-editor/min/vs/loader.js. Requires monaco-editor devDependency. Falls back to <textarea>.