Skip to content

Latest commit

 

History

History
524 lines (338 loc) · 43.7 KB

File metadata and controls

524 lines (338 loc) · 43.7 KB

Technical Caveats

@AGENTS.md

Documentation Sync Rule

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.

gh-pages Deployment Structure

  • Landing: client/landing/index.html (webjsx + 247420 design system, at /spoint/); uses client/colors_and_type.css (copied from design system)
  • Demo: /spoint/demo.html (was index.html)
  • Content: client/landing/content/ — section JSONs merged by flatspace at CI time into content.json
  • CI order: build dist → build landing → mv dist/index.html dist/demo.html → copy landing as dist/index.html
  • Singleplayer auto-redirect sed targets dist/demo.html only — never dist/index.html
  • World config path resolution: ?world=<name> parameter loads world config (e.g., survivor.js) with playerModel: './apps/tps-game/cleetus.vrm'. CI sed patches source literals but runtime path values need resolution against document.baseURI in client/app.js to inherit /spoint/ prefix. Without runtime resolution, relative paths 404 on gh-pages. Fix: wrap path-valued config fields in resolveWorldConfigPaths(wd) helper before BrowserServer.connect(wd).

Key Architecture

  • Server: node server.js (port 3001, 64 TPS world config, 128 TPS SDK default)
  • World config: apps/world/index.js
  • Apps: apps/<name>/index.js with server and client exports
  • 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 config terrain field; terrain: false disables

Terrain Engine

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 — registers window.__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 t0 from postMessage so client can measure per-chunk build duration.
  • TerrainNoise.js: standalone createFbmNoise export for non-worker use
  • TerrainBiomePalette.js: BIOME_KINDS + BIOME_PALETTE. Worker reads only slots 0 (low) and 1 (high) per kind via paletteColor(kind, slot)
  • Dependencies: simplex-noise and alea npm 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:

  1. IDW early-exit (client/TerrainWorkerSource.js): In buildGeometry(), compute IDW weights first, then skip samplers with negligible contribution (weights[i] < 1e-3 * maxWeight). Single chunk drops from ~167ms to ~124ms (26% improvement).

  2. Worker pool (client/TerrainSystem.js): Replace single Worker with pool of min(4, max(2, hardwareConcurrency - 2)) workers, dispatch chunks round-robin via workers[_wNext]; _wNext = (_wNext + 1) % workers.length. Parallelizes 100+ chunks: serial ~12.4s → 4 workers ~3.1s. Pool cleanup on terrain.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.

Vegetation Engine

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 against TerrainField.anchors biome-kind map via SPECIES_WEIGHTS.
  • src/physics/VegetationPhysics.js — chunked Jolt capsule streaming mirroring TerrainPhysics. Only tree species (TREE_SPECIES set: 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-tree Tree().generate(), splits into {trunk, foliage} parts, caps at MAX_INSTANCES_PER_CHUNK=64 per species per chunk, builds THREE.InstancedMesh per 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).

Terrain Collision (Server-Side)

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 streaming
  • src/physics/World.jsaddHeightField(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 * 25 factor 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._terrain field holds active TerrainPhysics instance
  • AppRuntime.setTerrainConfig(terrain) setter called from ServerAPI.loadWorld() (Node.js) and WorkerEntry.init() (singleplayer)
  • AppRuntimeTick.js ticks terrain every _terrainInterval = tickRate/4 ticks with player positions

gh-pages deployment: .github/workflows/gh-pages.yml copies src/terraindist/src/terrain — server-side code needed in singleplayer. renderDistance config shared between client rendering and server physics.

Singleplayer (Browser Worker Server)

?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; WorkerTransport bridges Worker↔client via postMessage
  • WorkerTransport.js: TransportWrapper subclass; send() transfers ArrayBuffer ownership (zero-copy); handles Uint8Array byteOffset
  • IDBAdapter.js: StorageAdapter backed by IndexedDB (no filesystem in Worker)
  • BrowserServer.js: extends BaseClient; connect() imports /apps/world/index.js and fetches app sources before spawning Worker. addPeer(offer, iceServers?) enables host-mode WebRTC — sends INIT_PEER to Worker, returns { answer, candidates }
  • Node.js portability: GLBLoader.js, AppRuntime.js, EditorHandlers.js, src/sdk/TickHandler.js guard await import('node:fs') with typeof 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 from client/, gh-pages copies file to dist root. _root always resolves to project root. All fetches use apps/, src/, singleplayer-world.json relative to _root. Never use ../apps/ — resolves above /spoint/ when file is at dist root.
  • importmap: jolt-physics/wasm-compat must be in client/index.html importmap — Chrome 89+ module Workers inherit page importmap, enabling World.js to 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: WorkerEntry receives { name, source } pairs, calls appLoader.loadFromString() — no filesystem needed
  • AppLoader.loadFromString: uses URL.createObjectURL + import() — handles full ES module syntax. Apps via blob URL cannot use relative imports — dependencies must be inlined or via engineCtx. BLOCKED_PATTERNS applies to ALL sources (both loadApp() and loadFromString()): import(, eval(, require(, process.exit, child_process, __proto__, Object.prototype, globalThis → rejected silently. Environment app must NOT use dynamic import() in setup() — use top-level init guarded by process.versions?.node. "app" field on entities in singleplayer-world.json required — missing = setup() never runs, no collider created.
  • Editor in singleplayer: handlers work via AppRuntime in-memory state; LIST_APPS reads appRuntime._appDefs, SAVE_SOURCE calls loadFromString(), broadcasts APP_MODULE
  • Inspector intercept range: Inspector.handleMessage in src/debug/Inspector.js intercepts 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.

Client Architecture — Three Modes, One Base

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 _handleSessionTokens for reconnect token. Adds heartbeat + ReconnectManager.
  • BrowserServer — Worker postMessage. _handleSessionTokens is 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.

Key File Locations

  • 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 via scripts/optimize-models.js)

Client SceneGraph

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.feetOffset distinguish types.
  • removeNode(id) — removes from scene and map.
  • setEntityTransforms(entities) — called once per onStateUpdate; writes x,y,z,vx,vy,vz,rx,ry,rz,rw directly into node.target plain 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 via lerpEntityTransform, player nodes apply via applyPlayerTransform. Returns true if 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.

Renderer

createRenderer(isMobile) in client/SceneSetup.jsTHREE.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 Mixin Pattern

AppRuntime.js applies two mixins at bottom of constructor — order matters:

  1. mixinPhysics(runtime) from AppRuntimePhysics.js_syncDynamicBodies, _tickPhysicsLOD. Must be first.
  2. mixinTick(runtime) from AppRuntimeTick.jstick(), _tickTimers, _tickCollisions, _tickRespawn, _tickInteractables, _syncPlayerIndex, getNearbyPlayers.

Entity Scale: Physics + Graphics Parity

entity.scale multiplies on top of GLB node hierarchy transforms, applied on both sides:

  • Physics (GLBLoader.js): buildNodeTransforms computes world-space 4x4 matrices, applyTransformMatrix bakes into vertices, then entity.scale multiplied on top.
  • Visual (client/app.js): Three.js GLTFLoader applies node transforms, then entity.scale via model.scale.set(entity.scale).

Collider methods in AppContext.js:

  • addBoxCollider: half-extents × entity.scale per-axis
  • addSphereCollider/addCapsuleCollider: radius × max(entity.scale) — Jolt requires uniform scale
  • addConvexFromModel/addConvexFromModelAsync: node hierarchy via extractMeshFromGLB(Async), vertices × entity.scale per-axis
  • addTrimeshCollider: defers body creation — stores entity in runtime._pendingTrimeshEntities. Client extracts Three.js world-space geometry, sends MSG.TRIMESH_DATA {entityId, vertices, indices}. Server calls physics.addStaticTrimeshFromData(...) at origin with identity rotation — vertices from mesh.matrixWorld are 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.

Entity Transform Pipeline

  1. Server: entity.position, entity.rotation (quaternion [x,y,z,w]), entity.scale
  2. Encoding: encodeEntity() quantizes all three into fixed indices
  3. Decoding: SnapshotProcessor._parseEntityNew() decodes 17 fields; scale at indices 14-16 (default [1,1,1])
  4. Client load: loadEntityModel() applies position, rotation, scale at load time
  5. Dynamic updates: animate loop interpolates position/quaternion each frame; scale applied once at load only

Rotation always quaternion [x,y,z,w] — never euler.

Snapshot Encoding Format

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.

App API

renderCtx + engineCtx

  • renderCtx (passed to render(ctx)): ctx.THREE, ctx.scene, ctx.camera, ctx.renderer, ctx.playerId, ctx.clock. Added in renderAppUI() in client/AppModuleSystem.js.
  • engineCtx: engine.network.send(msg) — shorthand for client.send(0x33, msg).
  • onKeyDown/onKeyUp dispatch happens after editor.onKeyDown(e) via ams.dispatchKeyDown/dispatchKeyUp.

Design Principle: Apps Are Config, Engine Is Code

  • No client.render unless app returns ui: field.
  • No onEditorUpdate for standard field changes — ServerHandlers.js already applies position, rotation, scale, custom before 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 {}evaluateAppModule hoists only code-before-default; code after = unreachable.
  • Apps cannot use imports — all dependencies via engineCtx.

Reusable Apps

  • box-static: visual box + static collider. Config: { hx, hy, hz, color, roughness }.
  • prop-static: static GLB + convex hull. Entity must have model.
  • box-dynamic: dynamic physics box. Config: { hx, hy, hz, color, roughness, mass }.

Primitive Rendering (No GLB)

Set entity.model = null, populate entity.custom:

  • mesh: 'box'|'sphere'|'cylinder'; sx/sy/sz, r, h, seg
  • color, roughness, metalness, emissive, emissiveIntensity
  • hover: Y oscillation amplitude; spin: rotation speed (rad/s)
  • light: point light color; lightIntensity; lightRange

Interactable System

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().

App State Survival

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.

App Module List Cache

_appModuleList = cached [...appModules.values()] — avoids Map iteration in hot onAppEvent handler. Rebuilt on every appModules change.

GLB / Model Loading

Draco Support

  • extractMeshFromGLB(filepath) — sync, throws on Draco/meshopt
  • extractMeshFromGLBAsync(filepath) — async, handles Draco
  • Meshopt NOT supported. Decompress: gltfpack -i in.glb -o out.glb -noq

GLBTransformer (KTX2 + Draco on first request)

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 imageSlotHints from material slots (normalTexture → uastc, others → basis-lz). Draco runs first, kept only if smaller.
  • prewarm() scans .vrm + .glb.

KTX binary search order: bin/ktx.exebin/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().

Build-Time Draco Stripping (gh-pages)

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({...}) — NOT KHRDracoMeshCompression.withConfig (doesn't exist in v4)
  • Dispose KHR_draco_mesh_compression from doc.getRoot().listExtensionsUsed() before io.writeBinary — otherwise gltf-transform re-encodes with Draco
  • Textures with no source field must be patched to source: 0 before read — null sampler lookup crashes NodeIO
  • Draco strip must happen before texture rewrite — bufferView indices change after strip

Invisible/Trigger Material Filtering

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.

IndexedDB Model Cache

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-Side Geometry Cache

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).

Shader Warmup Persistence

warmupShaders stores lastShaderWarmupKey in localStorage. Key = entity count + ID hash — unchanged world skips re-compilation.

Jolt Physics WASM Memory

Getters — destroy based on C++ return type:

  • BodyInterface::GetPosition/GetRotation/GetLinearVelocity → by VALUE → MUST J.destroy(result)
  • CharacterVirtual::GetPosition()const RVec3& (internal ref) → do NOT destroy — crashes
  • CharacterVirtual::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.

Physics Rules

  • Bodies only created in setup(): setting entity.bodyType/entity.collider directly has no effect. Body created only via ctx.physics.addBoxCollider() etc.
  • CharacterVirtual gravity: ExtendedUpdate() does NOT apply gravity. PhysicsIntegration.js manually applies gravity[1] * dt to vy. Gravity vector to ExtendedUpdate controls 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. groundAccel WITH friction, airAccel WITHOUT. World config maxSpeed overrides DEFAULT_MOVEMENT.maxSpeed — movement.js defaults NOT production. Current: maxSpeed: 7.0, sprintSpeed: 12.0.

Spatial Physics LOD

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.addBody re-creates at current position; _physicsBodyToEntityId updated with new id.
  • entity._bodyDef: stored by collider methods when bodyType === '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.delete cleans up. No removeBody needed (already removed).

entityTickRate in world config sets app update() Hz (default = tickRate). entityDt = dt * divisor.

Active Dynamic Body Tracking

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.

Snapshot Delivery

SNAP_GROUPS Rotation

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 vs Dynamic Entity 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.

Spatial Player Culling

When relevanceRadius > 0, getNearbyPlayers() filters by distance² vs radius² (no sqrt). _playerIndex updated every tick in _syncPlayerIndex().

Entity Key Caching

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.

Priority Accumulator (Interest Management)

_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.

Tick Dilation (EVE-style)

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.

Performance Optimizations

Physics Player Divisor

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 Player Physics Skip

Idle = no directional input + onGround=true + horizontal velocity < 0.01 m/s. After 1 settling tick, idle ticks skip updatePlayerPhysics(). Counter resets on movement.

Snap Phase Spatial Cache

_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.

Collision Grid Pruning

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.

Client-Side Optimizations

  • cam.setEnvironment(meshes): non-skinned static meshes only — never scene.children (includes VRM, causes CPU overhead).
  • EntityLoader._animatedEntities tracks finalMesh (the THREE.LOD wrapper) — removeEntity looks up via entityMeshes which stores finalMesh; mismatch = animator leak.
  • firstSnapshotEntityPending: tracks dynamic entities only. 5s timeout (_entityLoadTimeout) clears set so failed load can't block forever.

Client Loading Pipeline

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.worldDefapps/world/index.jssingleplayer-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:

  • createPlayerVRM MUST NOT be called before assetsLoaded=true — queues N concurrent VRM parses (300–400MB each), OOMs.
  • group.userData.vrmPending MUST be set synchronously before first await — guards re-entrant double-queuing.
  • cacheClips in AnimationLibrary.js MUST remain awaited — fire-and-forget overlaps VRM loads with IndexedDB, spikes heap.
  • loadEntityModel NOT gated on assetsLoaded — deferring breaks singleplayer raycasting (map BVH not ready → fall through floor).
  • onStateUpdate creates placeholder Groups immediately; calls createPlayerVRM only when assetsLoaded && g.children.length===0 && !g.userData.vrmPending.

Cold path test: DevTools → Application → IndexedDB → delete spoint-anim-cache → reload ?singleplayer. Heap plateau < 1GB.

Hot Reload Architecture

Three independent systems:

  1. ReloadManager — watches SDK source files. swapInstance() replaces prototype/non-state properties, preserves state (e.g. playerBodies survives PhysicsIntegration reload).
  2. AppLoader — watches apps/. Reloads drain via _drainReloadQueue() at end of tick (never mid-tick). _resetHeartbeats() after each reload.
  3. Client hot reloadMSG.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.

xstate Preference

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

Misc Engine Details

  • WORLD_DEF strips entities: ServerHandlers.onClientConnect() removes entities before MSG.WORLD_DEF. Pattern: const { entities: _ignored, ...worldDefForClient } = ctx.currentWorldDef.
  • Message types hex: MessageTypes.js. Snapshot = 0x10, input = 0x11.
  • msgpack: src/protocol/msgpack.js re-exports pack/unpack from msgpackr.
  • TickSystem: loop() max 4 ticks per iteration. setTimeout(1ms) when gap > 2ms, setImmediate when ≤ 2ms.
  • Entity hierarchy: getWorldTransform() walks parent chain recursively. Destroying parent cascades.
  • EventBus: wildcard * suffix (combat.* receives combat.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-local apps/ overrides SDK apps/.
  • Heartbeat: 3s timeout. ANY message resets timer. Client sends every 1000ms.
  • Client input rate: 60Hz. Server uses only LAST buffered input per tick. inputSequence increments for reconciliation.
  • Spatial grid player collision: cell size = capsuleRadius * 8, 9-neighbor. other.id <= player.id processes each pair once.

Editor DX

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>.