
Building Mechanics
- 67 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
Building-mechanics is a Claude skill for building a Three.js 3D construction system with spatial indexing, structural physics, and multiplayer sync.
About
Building-mechanics is a Claude skill providing a Three.js 3D building system with spatial indexing, structural physics, and multiplayer networking. It is used when creating survival games, sandbox builders, or any game with player-constructed structures. It covers performance optimization, structural validation modes, and multiplayer sync techniques like delta compression and client prediction.
- Three.js 3D building system with spatial indexing, structural physics, and multiplayer networking
- Spatial hash grids and octrees for fast queries at scale
- Structural validation across arcade, heuristic, and realistic physics modes
Building Mechanics by the numbers
- 67 all-time installs (skills.sh)
- Ranked #140 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
building-mechanics capabilities & compatibility
- Capabilities
- spatial indexing · structural physics · multiplayer sync · chunk loading
- Use cases
- frontend
What building-mechanics says it does
Three.js 3D building system with spatial indexing, structural physics, and multiplayer networking.
Complete building system for Three.js games with performance optimization, structural physics, and multiplayer networking.
const validator = new HeuristicValidator({ mode: 'heuristic' });
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill building-mechanicsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 67 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Build a performant Three.js building system with physics and multiplayer for a survival or sandbox game.
Who is it for?
Survival games, sandbox builders, and any game with player-constructed structures.
Skip if: Non-game apps or non-Three.js stacks.
When should I use this skill?
Creating survival games, sandbox builders, or any game with player-constructed structures.
What you get
A performant, physics-validated, multiplayer-capable Three.js building system.
- SpatialHashGrid
- HeuristicValidator
- Multiplayer sync layer
By the numbers
- 3 physics modes (arcade/heuristic/realistic)
- SpatialHashGrid cell size 10 example
Files
3D Building Mechanics
Complete building system for Three.js games with performance optimization, structural physics, and multiplayer networking.
Quick Start
import { SpatialHashGrid } from './scripts/spatial-hash-grid.js';
import { HeuristicValidator } from './scripts/heuristic-validator.js';
// Spatial indexing for fast queries
const spatialIndex = new SpatialHashGrid(10);
spatialIndex.insert(piece, piece.position);
const nearby = spatialIndex.queryRadius(position, 15);
// Structural validation (Rust/Valheim style)
const validator = new HeuristicValidator({ mode: 'heuristic' });
validator.addPiece(piece);
const canPlace = validator.validatePlacement(newPiece);Reference Files
Read these for detailed implementation guidance:
references/performance-at-scale.md- Spatial partitioning, chunk loading, instancing, LODreferences/structural-physics-advanced.md- Arcade vs heuristic vs realistic physicsreferences/multiplayer-networking.md- Authority models, delta sync, conflict resolution
Scripts
Performance (references/performance-at-scale.md)
scripts/spatial-hash-grid.js- O(1) queries for uniform distributionscripts/octree.js- Adaptive queries for clustered basesscripts/chunk-manager.js- World streaming for large mapsscripts/performance-profiler.js- Benchmarking utilities
Structural Physics (references/structural-physics-advanced.md)
scripts/heuristic-validator.js- Fast validation (Fortnite/Rust/Valheim modes)scripts/stability-optimizer.js- Caching and batch updatesscripts/damage-propagation.js- Damage states, cascading collapsescripts/physics-engine-lite.js- Optional realistic physics
Multiplayer (references/multiplayer-networking.md)
scripts/delta-compression.js- Only send changed statescripts/client-prediction.js- Optimistic placement with rollbackscripts/conflict-resolver.js- Handle simultaneous buildsscripts/building-network-manager.js- Complete server/client system
Key Patterns
Spatial Indexing Selection
| Pieces | Distribution | Use |
|---|---|---|
| <1,000 | Any | Array |
| 1-5k | Uniform | SpatialHashGrid |
| 1-5k | Clustered | Octree |
| 5k+ | Any | ChunkManager + Octree |
Physics Mode Selection
- Arcade (Fortnite): Connectivity only, instant collapse, best for combat
- Heuristic (Rust/Valheim): Stability %, predictable, best for survival
- Realistic: Full stress/strain, expensive, best for engineering sims
Multiplayer Pattern
Server-authoritative with client prediction. Use delta compression for sync.
{
"name": "3d-building-advanced",
"description": "Three.js 3D building system with spatial indexing, structural physics, and multiplayer networking for survival games, sandbox builders, or construction-focused games.",
"tags": [
"building-game",
"3d",
"three.js",
"javascript",
"code-generation"
],
"sub_skills": [
{
"name": "multiplayer-networking",
"file": "references/multiplayer-networking.md",
"triggers": [
"localPiece",
"tempId",
"Delta Compression",
"multiplayer-networking",
"The Networking Challenge",
"BuildingPermissionSystem",
"UpdateBatcher",
"Authority Models",
"updatePredictions",
"pending"
]
},
{
"name": "performance-at-scale",
"file": "references/performance-at-scale.md",
"triggers": [
"Spatial Data Structures",
"cy",
"GPU Instancing",
"performance-at-scale",
"createBuildingLOD",
"SpatialHashGrid",
"cx",
"The Scale Problem",
"ChunkManager",
"Occlusion Culling"
]
},
{
"name": "structural-physics-advanced",
"file": "references/structural-physics-advanced.md",
"triggers": [
"supportStability",
"Visual Feedback",
"disconnected",
"SupportGraph",
"supports",
"structural-physics-advanced",
"onPieceDestroyed",
"The Physics Spectrum",
"StabilitySystem",
"Choosing Your Approach"
]
}
],
"source": "claude-user",
"type": "template",
"depends_on": [
"r3f-fundamentals"
],
"enhances": [
"structural-physics",
"terrain-integration"
],
"last_reviewed_at": "2026-05-17",
"review_score": 75,
"relevance_tier": "A"
}
Multiplayer Networking for Building Systems
Networked building introduces challenges beyond typical game networking. Structures persist, players modify them simultaneously, and the combinatorial state space explodes. This reference covers authority models, synchronization strategies, and conflict resolution patterns used by production multiplayer building games.
The Networking Challenge
Building systems create unique networking problems:
1. State explosion: A base with 1,000 pieces has 1,000+ entities to sync, each with position, rotation, material, health, and relationships.
2. Simultaneous modification: Multiple players editing the same structure at once creates race conditions and conflicts.
3. Persistence: Unlike transient game state (player positions), buildings must survive server restarts and player disconnections.
4. Ownership complexity: Who can modify what? Rust's Tool Cupboard system exists specifically to solve building permission conflicts.
5. Bandwidth constraints: Fortnite runs at 30Hz tick rate (not 60Hz) specifically because building generates so much state change.
Authority Models
Server-Authoritative (Recommended)
The server is the single source of truth. Clients send requests; server validates and broadcasts results.
Flow:
Client: "I want to place a wall at (10, 0, 15)"
Server: Validates position, collision, permissions, resources
Server: "Wall placed with ID 4827 at (10, 0, 15)"
Server: Broadcasts to all clients
All Clients: Create wall locallyAdvantages:
- Cheat-resistant (server validates everything)
- Consistent state across all clients
- Clear conflict resolution (server decides)
Disadvantages:
- Latency visible to placing player
- Server CPU load scales with player count
- Requires client prediction for responsiveness
When to use: Any competitive or persistent game. This is Rust's and Fortnite's model.
Client-Authoritative (Avoid for Building)
Clients make changes locally and inform server. Server trusts clients.
Why it fails for building:
- Trivial to cheat (spawn free resources, clip through walls)
- Conflict resolution becomes impossible
- State divergence across clients
Only viable for: Single-player with cloud save, or fully trusted clients (same-room co-op).
Hybrid Authority
Different systems use different authority. Common pattern:
- Server-authoritative: Building placement, destruction, permissions
- Client-authoritative: Camera, UI state, local preview
- Predicted: Movement, some interactions
This is the practical approach. Clients predict placement visually while server validates.
Client Prediction for Building
Players expect immediate feedback when placing. With 100ms latency, waiting for server confirmation feels sluggish. Solution: predict locally, reconcile with server.
Optimistic Placement
// Client-side
function onPlaceAttempt(piece, position, rotation) {
// Generate temporary local ID
const tempId = generateTempId();
// Create ghost/preview immediately
const localPiece = createLocalPiece(piece, position, rotation, tempId);
localPiece.isPredicted = true;
localPiece.confirmedByServer = false;
// Send request to server
sendToServer({
type: 'place_request',
tempId,
pieceType: piece.type,
position: serializeVector(position),
rotation: serializeRotation(rotation),
timestamp: Date.now()
});
// Add to pending predictions
pendingPlacements.set(tempId, {
localPiece,
requestTime: Date.now(),
timeout: 5000
});
return localPiece;
}Server Confirmation
// Client receives server response
function onPlaceConfirmed(message) {
const { tempId, serverId, success, position, rotation, reason } = message;
const pending = pendingPlacements.get(tempId);
if (!pending) return; // Already timed out
pendingPlacements.delete(tempId);
if (success) {
// Replace predicted piece with confirmed
const confirmedPiece = pending.localPiece;
confirmedPiece.id = serverId;
confirmedPiece.isPredicted = false;
confirmedPiece.confirmedByServer = true;
// Correct position if server adjusted it (snapping, etc.)
if (position) {
confirmedPiece.position.copy(deserializeVector(position));
}
buildingSystem.registerPiece(confirmedPiece);
} else {
// Rollback: remove predicted piece
buildingSystem.removePiece(pending.localPiece);
showError(reason); // "Cannot place: overlapping existing structure"
}
}Prediction Timeout
Predictions can't wait forever. Handle network issues gracefully.
function updatePredictions() {
const now = Date.now();
for (const [tempId, pending] of pendingPlacements) {
if (now - pending.requestTime > pending.timeout) {
// Assume failure after timeout
buildingSystem.removePiece(pending.localPiece);
pendingPlacements.delete(tempId);
showError("Placement timed out - please try again");
}
}
}Delta Compression
Sending full structure state every tick is prohibitive. Rust structures can have thousands of pieces. Solution: only send what changed.
The Source Engine Pattern
Valve's approach (used in CS, TF2, etc.):
1. Server tracks what state each client has acknowledged 2. Server computes delta (difference) from last acknowledged state 3. Server sends only the delta 4. If heavy packet loss detected, send full state to resync
class DeltaCompressor {
constructor() {
this.clientStates = new Map(); // clientId -> acknowledged state version
this.stateHistory = []; // Ring buffer of recent states
this.maxHistory = 64; // Keep ~1 second at 60Hz
}
recordState(state, version) {
this.stateHistory.push({ state: this.cloneState(state), version });
if (this.stateHistory.length > this.maxHistory) {
this.stateHistory.shift();
}
}
getDeltaForClient(clientId, currentState, currentVersion) {
const lastAcked = this.clientStates.get(clientId) ?? 0;
const baseState = this.findState(lastAcked);
if (!baseState) {
// Client too far behind, send full state
return { full: true, state: currentState };
}
// Compute delta
const delta = this.computeDelta(baseState, currentState);
return { full: false, delta, baseVersion: lastAcked, version: currentVersion };
}
computeDelta(oldState, newState) {
const delta = {
added: [],
removed: [],
modified: []
};
// Find added pieces
for (const [id, piece] of newState.pieces) {
if (!oldState.pieces.has(id)) {
delta.added.push(this.serializePiece(piece));
} else {
// Check for modifications
const oldPiece = oldState.pieces.get(id);
if (this.pieceChanged(oldPiece, piece)) {
delta.modified.push(this.serializePieceChanges(oldPiece, piece));
}
}
}
// Find removed pieces
for (const [id] of oldState.pieces) {
if (!newState.pieces.has(id)) {
delta.removed.push(id);
}
}
return delta;
}
pieceChanged(oldPiece, newPiece) {
// Quick checks for common changes
if (oldPiece.health !== newPiece.health) return true;
if (oldPiece.material !== newPiece.material) return true;
if (!oldPiece.position.equals(newPiece.position)) return true;
return false;
}
}Building-Specific Delta Optimization
Building pieces don't move often. Optimize for common cases:
function serializePieceChanges(oldPiece, newPiece) {
const changes = { id: newPiece.id };
// Only include changed fields
if (oldPiece.health !== newPiece.health) {
changes.health = newPiece.health;
}
if (oldPiece.material !== newPiece.material) {
changes.material = newPiece.material;
}
// Position changes are rare for buildings - flag separately
if (!oldPiece.position.equals(newPiece.position)) {
changes.position = serializeVector(newPiece.position);
}
return changes;
}Batching Updates
Group multiple changes into single packets:
class UpdateBatcher {
constructor(maxBatchSize = 50, maxDelay = 50) {
this.pending = [];
this.maxBatchSize = maxBatchSize;
this.maxDelay = maxDelay;
this.lastFlush = Date.now();
}
add(update) {
this.pending.push(update);
if (this.pending.length >= this.maxBatchSize) {
this.flush();
}
}
update() {
if (this.pending.length > 0 && Date.now() - this.lastFlush > this.maxDelay) {
this.flush();
}
}
flush() {
if (this.pending.length === 0) return;
const batch = {
type: 'building_update_batch',
updates: this.pending,
timestamp: Date.now()
};
this.pending = [];
this.lastFlush = Date.now();
broadcast(batch);
}
}Conflict Resolution
Two players place at the same spot at the same time. Who wins?
First-Write-Wins
Simplest approach: first request to reach server succeeds.
// Server-side
function handlePlaceRequest(request, client) {
const position = deserializeVector(request.position);
// Check if position is already occupied
if (buildingSystem.isOccupied(position)) {
return { success: false, reason: 'Position occupied' };
}
// Atomically place and register
const piece = buildingSystem.place(request.pieceType, position, request.rotation);
return { success: true, serverId: piece.id };
}Problem: With network latency, "first" is ambiguous. Player A clicks first but has higher latency; Player B's request arrives first.
Timestamp-Based Resolution
Include client timestamp, server decides based on who clicked first.
function handlePlaceRequest(request, client) {
const position = deserializeVector(request.position);
const clientTime = request.timestamp;
// Check pending requests for same position
const conflicting = pendingRequests.find(r =>
vectorsEqual(r.position, position) &&
r.clientTime < clientTime
);
if (conflicting) {
// Earlier request wins
return { success: false, reason: 'Position claimed by another player' };
}
// Hold request briefly to allow conflicts to arrive
pendingRequests.push({
request,
client,
position,
clientTime,
serverTime: Date.now()
});
// Process after conflict window
setTimeout(() => processRequest(request, client), CONFLICT_WINDOW_MS);
}Problem: Requires trusting client timestamps (cheatable) or complex clock synchronization.
Lock-Based Resolution (Rust's Tool Cupboard)
Players claim regions. Only the owner (or authorized players) can modify.
class BuildingPermissionSystem {
constructor() {
this.regions = new Map(); // regionId -> { owner, authorized: Set, bounds }
}
claimRegion(player, position, radius) {
const regionId = this.generateRegionId();
// Check for overlapping claims
for (const [id, region] of this.regions) {
if (this.regionsOverlap(region.bounds, position, radius)) {
return { success: false, reason: 'Overlaps existing claim' };
}
}
this.regions.set(regionId, {
owner: player.id,
authorized: new Set([player.id]),
bounds: { center: position, radius }
});
return { success: true, regionId };
}
canModify(player, position) {
for (const region of this.regions.values()) {
if (this.isInRegion(position, region.bounds)) {
return region.authorized.has(player.id);
}
}
// Outside all regions - allow (or deny, depending on game rules)
return true;
}
authorize(owner, playerToAuth, regionId) {
const region = this.regions.get(regionId);
if (!region || region.owner !== owner.id) {
return { success: false, reason: 'Not region owner' };
}
region.authorized.add(playerToAuth.id);
return { success: true };
}
}Optimistic Locking
Clients include version number. Server rejects if version is stale.
// Client request includes last known version
function placeRequest(piece, position, structureVersion) {
return {
type: 'place_request',
pieceType: piece.type,
position: serializeVector(position),
structureVersion // Version of structure when client made decision
};
}
// Server checks version
function handlePlaceRequest(request, client) {
const structure = getStructureAt(request.position);
if (structure && structure.version !== request.structureVersion) {
// Structure changed since client's view - reject
return {
success: false,
reason: 'Structure modified by another player',
currentVersion: structure.version,
resync: getStructureState(structure)
};
}
// Proceed with placement, increment version
structure.version++;
// ... place piece ...
}Large Structure Synchronization
A 500-piece base can't be sent every tick. Strategies for initial sync and ongoing updates.
Chunked Initial Sync
When player approaches a large structure, stream it in chunks:
class StructureStreamer {
constructor() {
this.chunkSize = 50; // Pieces per chunk
this.streamDelay = 50; // ms between chunks
}
async streamStructureToClient(client, structure) {
const pieces = Array.from(structure.pieces.values());
const chunks = this.chunkArray(pieces, this.chunkSize);
// Send metadata first
client.send({
type: 'structure_stream_start',
structureId: structure.id,
totalPieces: pieces.length,
chunkCount: chunks.length
});
// Stream chunks
for (let i = 0; i < chunks.length; i++) {
await this.delay(this.streamDelay);
client.send({
type: 'structure_chunk',
structureId: structure.id,
chunkIndex: i,
pieces: chunks[i].map(p => this.serializePiece(p))
});
}
client.send({
type: 'structure_stream_complete',
structureId: structure.id
});
}
chunkArray(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
}Priority-Based Updates
Not all pieces are equally important. Prioritize visible/nearby pieces.
class PrioritySync {
getPriority(piece, player) {
let priority = 0;
// Distance factor (closer = higher priority)
const distance = piece.position.distanceTo(player.position);
priority += Math.max(0, 100 - distance);
// Visibility factor
if (this.isInPlayerFOV(piece, player)) {
priority += 50;
}
// Recent change factor
const timeSinceChange = Date.now() - piece.lastModified;
if (timeSinceChange < 1000) {
priority += 100; // Recently changed pieces are critical
}
// Structural importance
if (piece.type === 'foundation') {
priority += 20;
}
return priority;
}
getUpdatesForClient(client, allUpdates, maxUpdates = 20) {
return allUpdates
.map(update => ({
update,
priority: this.getPriority(update.piece, client.player)
}))
.sort((a, b) => b.priority - a.priority)
.slice(0, maxUpdates)
.map(({ update }) => update);
}
}Structure IDs (Rust's Approach)
Group pieces by building. Query and sync at building level, not piece level.
class BuildingIdSystem {
constructor() {
this.buildings = new Map(); // buildingId -> { pieces: Set, bounds, version }
this.pieceToBuilding = new Map(); // pieceId -> buildingId
}
addPiece(piece, nearbyPiece = null) {
let buildingId;
if (nearbyPiece) {
// Join existing building
buildingId = this.pieceToBuilding.get(nearbyPiece.id);
}
if (!buildingId) {
// Create new building
buildingId = this.generateBuildingId();
this.buildings.set(buildingId, {
pieces: new Set(),
bounds: null,
version: 1
});
}
const building = this.buildings.get(buildingId);
building.pieces.add(piece.id);
building.version++;
this.pieceToBuilding.set(piece.id, buildingId);
this.updateBounds(building);
return buildingId;
}
getBuildingPieces(buildingId) {
const building = this.buildings.get(buildingId);
return building ? Array.from(building.pieces) : [];
}
getBuildingsInRange(position, range) {
const results = [];
for (const [id, building] of this.buildings) {
if (this.boundsInRange(building.bounds, position, range)) {
results.push(id);
}
}
return results;
}
}Server Performance
Building operations can strain servers. Patterns for scalability.
Rate Limiting
Prevent spam and DoS via excessive building:
class BuildingRateLimiter {
constructor(options = {}) {
this.maxPlacementsPerSecond = options.maxPlacementsPerSecond ?? 5;
this.maxDestructionsPerSecond = options.maxDestructionsPerSecond ?? 10;
this.clientBuckets = new Map();
}
checkLimit(clientId, action) {
let bucket = this.clientBuckets.get(clientId);
if (!bucket) {
bucket = {
placements: { count: 0, resetTime: Date.now() + 1000 },
destructions: { count: 0, resetTime: Date.now() + 1000 }
};
this.clientBuckets.set(clientId, bucket);
}
const now = Date.now();
const limit = action === 'place'
? { bucket: bucket.placements, max: this.maxPlacementsPerSecond }
: { bucket: bucket.destructions, max: this.maxDestructionsPerSecond };
// Reset bucket if window passed
if (now > limit.bucket.resetTime) {
limit.bucket.count = 0;
limit.bucket.resetTime = now + 1000;
}
if (limit.bucket.count >= limit.max) {
return { allowed: false, retryAfter: limit.bucket.resetTime - now };
}
limit.bucket.count++;
return { allowed: true };
}
}Async Processing
Don't block the main tick for building operations:
class AsyncBuildingProcessor {
constructor() {
this.queue = [];
this.processing = false;
this.maxProcessTimePerTick = 5; // ms
}
enqueue(operation) {
return new Promise((resolve, reject) => {
this.queue.push({ operation, resolve, reject });
});
}
processTick() {
if (this.queue.length === 0) return;
const startTime = performance.now();
while (this.queue.length > 0) {
if (performance.now() - startTime > this.maxProcessTimePerTick) {
break; // Yield to other systems
}
const { operation, resolve, reject } = this.queue.shift();
try {
const result = operation();
resolve(result);
} catch (error) {
reject(error);
}
}
}
}Spatial Partitioning for Network
Only send updates to clients who can see them:
class NetworkSpatialPartition {
constructor(cellSize = 100) {
this.cellSize = cellSize;
this.clientCells = new Map(); // clientId -> Set of cell keys
this.cellClients = new Map(); // cellKey -> Set of clientIds
}
updateClientPosition(clientId, position) {
// Remove from old cells
const oldCells = this.clientCells.get(clientId) || new Set();
for (const cell of oldCells) {
this.cellClients.get(cell)?.delete(clientId);
}
// Add to new cells (with some range)
const newCells = this.getCellsInRange(position, 200);
this.clientCells.set(clientId, newCells);
for (const cell of newCells) {
if (!this.cellClients.has(cell)) {
this.cellClients.set(cell, new Set());
}
this.cellClients.get(cell).add(clientId);
}
}
getClientsForPosition(position) {
const cell = this.getCellKey(position);
return Array.from(this.cellClients.get(cell) || []);
}
broadcastToNearby(position, message, excludeClient = null) {
const clients = this.getClientsForPosition(position);
for (const clientId of clients) {
if (clientId !== excludeClient) {
sendToClient(clientId, message);
}
}
}
}Message Protocol
Efficient wire format for building messages.
Message Types
const BuildingMessageType = {
// Client -> Server
PLACE_REQUEST: 0x01,
DESTROY_REQUEST: 0x02,
UPGRADE_REQUEST: 0x03,
ROTATE_REQUEST: 0x04,
// Server -> Client
PLACE_CONFIRMED: 0x10,
PLACE_REJECTED: 0x11,
PIECE_DESTROYED: 0x12,
PIECE_UPDATED: 0x13,
STRUCTURE_SYNC: 0x14,
DELTA_UPDATE: 0x15,
// Bidirectional
PING: 0xF0,
PONG: 0xF1
};Binary Encoding
JSON is convenient but verbose. For high-frequency updates, binary is better:
class BuildingMessageEncoder {
encodePlace(tempId, pieceType, position, rotation) {
const buffer = new ArrayBuffer(26);
const view = new DataView(buffer);
let offset = 0;
view.setUint8(offset++, BuildingMessageType.PLACE_REQUEST);
view.setUint32(offset, tempId); offset += 4;
view.setUint8(offset++, pieceType);
view.setFloat32(offset, position.x); offset += 4;
view.setFloat32(offset, position.y); offset += 4;
view.setFloat32(offset, position.z); offset += 4;
view.setFloat32(offset, rotation.y); offset += 4; // Only Y rotation usually needed
return buffer;
}
decodePlace(buffer) {
const view = new DataView(buffer);
let offset = 1; // Skip type byte
return {
tempId: view.getUint32(offset), offset += 4,
pieceType: view.getUint8(offset++),
position: {
x: view.getFloat32(offset), offset += 4,
y: view.getFloat32(offset), offset += 4,
z: view.getFloat32(offset), offset += 4
},
rotation: view.getFloat32(offset)
};
}
}Integration Checklist
When implementing networked building:
- [ ] Choose authority model (server-authoritative recommended)
- [ ] Implement client prediction with rollback
- [ ] Add delta compression for ongoing updates
- [ ] Handle conflicts (first-write, timestamp, or lock-based)
- [ ] Implement chunked sync for large structures
- [ ] Add rate limiting to prevent abuse
- [ ] Use spatial partitioning for targeted broadcasts
- [ ] Add permission system if needed (tool cupboard style)
- [ ] Test with simulated latency and packet loss
- [ ] Profile server CPU usage under load
Related References
building-network-manager.js- Complete networking systemclient-prediction.js- Optimistic placement and rollbackdelta-compression.js- State delta computationconflict-resolver.js- Conflict detection and resolutionperformance-at-scale.md- Spatial partitioning for network queries
Performance at Scale
Building systems face exponential complexity as component counts grow. A naive approach that works for 100 pieces will collapse at 1,000 and become unplayable at 10,000. This reference covers the spatial data structures, chunking strategies, and optimization patterns used by production games to handle massive player-built structures.
The Scale Problem
Every building operation involves spatial queries: "What's near this position?", "Does this overlap anything?", "What supports this piece?" Without optimization, these queries scan every object—O(n) per query. With n queries per frame, you get O(n²) complexity.
Real-world limits from production games:
- Rust: ~150,000-200,000 colliders practical limit (Unity physics "goes nuts" beyond ~150k)
- Fortnite: 30Hz server tick to manage build/destruction load for 100 players
- Minecraft: 16×16×256 chunk system enabling effectively infinite worlds
- Valheim: New terrain system in patch 0.150.3 specifically to reduce network instances
The solution is spatial partitioning—organizing objects by location so queries only examine nearby candidates.
Spatial Data Structures
Spatial Hash Grid
The simplest spatial structure. Divide world space into a uniform grid of cells. Each cell stores references to objects within it. Query time becomes O(1) for cell lookup plus O(k) for objects in that cell.
Best for:
- Uniform object distribution
- 2D or 2.5D games (terrain-based building)
- Simple implementation needs
- Component counts under 5,000
Implementation pattern:
class SpatialHashGrid {
constructor(cellSize = 10) {
this.cellSize = cellSize;
this.cells = new Map();
}
_hash(x, y, z) {
const cx = Math.floor(x / this.cellSize);
const cy = Math.floor(y / this.cellSize);
const cz = Math.floor(z / this.cellSize);
return `${cx},${cy},${cz}`;
}
insert(object, position) {
const key = this._hash(position.x, position.y, position.z);
if (!this.cells.has(key)) {
this.cells.set(key, new Set());
}
this.cells.get(key).add(object);
object._spatialKey = key;
}
query(position, radius) {
const results = [];
const cellRadius = Math.ceil(radius / this.cellSize);
const cx = Math.floor(position.x / this.cellSize);
const cy = Math.floor(position.y / this.cellSize);
const cz = Math.floor(position.z / this.cellSize);
for (let dx = -cellRadius; dx <= cellRadius; dx++) {
for (let dy = -cellRadius; dy <= cellRadius; dy++) {
for (let dz = -cellRadius; dz <= cellRadius; dz++) {
const key = `${cx + dx},${cy + dy},${cz + dz}`;
const cell = this.cells.get(key);
if (cell) {
results.push(...cell);
}
}
}
}
return results;
}
}Tradeoffs:
- Fixed cell size means wasted memory in sparse areas, overcrowded cells in dense areas
- Cell size tuning is critical: too small = many cells to check, too large = many objects per cell
- Rule of thumb: cell size ≈ 2-4× typical object size
Octree
A tree structure that recursively subdivides 3D space into eight octants. Adapts to object density—sparse regions stay as large nodes while dense regions subdivide further.
Best for:
- Non-uniform object distribution (bases clustered in certain areas)
- True 3D queries (tall structures, flying/underwater building)
- Component counts 5,000-10,000+
- When memory efficiency matters
Key insight from research: Octrees are "more performant for large differences in spatial density" due to adaptive scaling.
Implementation pattern:
class OctreeNode {
constructor(bounds, depth = 0, maxDepth = 8, maxObjects = 8) {
this.bounds = bounds; // { min: Vector3, max: Vector3 }
this.depth = depth;
this.maxDepth = maxDepth;
this.maxObjects = maxObjects;
this.objects = [];
this.children = null; // Array of 8 OctreeNodes when subdivided
}
insert(object, position) {
if (!this._containsPoint(position)) return false;
if (this.children) {
for (const child of this.children) {
if (child.insert(object, position)) return true;
}
return false;
}
this.objects.push({ object, position });
if (this.objects.length > this.maxObjects && this.depth < this.maxDepth) {
this._subdivide();
}
return true;
}
queryRadius(center, radius, results = []) {
if (!this._intersectsSphere(center, radius)) return results;
for (const { object, position } of this.objects) {
if (center.distanceTo(position) <= radius) {
results.push(object);
}
}
if (this.children) {
for (const child of this.children) {
child.queryRadius(center, radius, results);
}
}
return results;
}
_subdivide() {
const { min, max } = this.bounds;
const mid = new THREE.Vector3().addVectors(min, max).multiplyScalar(0.5);
this.children = [];
const corners = [
[min.x, min.y, min.z], [mid.x, min.y, min.z],
[min.x, mid.y, min.z], [mid.x, mid.y, min.z],
[min.x, min.y, mid.z], [mid.x, min.y, mid.z],
[min.x, mid.y, mid.z], [mid.x, mid.y, mid.z]
];
for (let i = 0; i < 8; i++) {
const [x, y, z] = corners[i];
const childMin = new THREE.Vector3(x, y, z);
const childMax = new THREE.Vector3(
x + (max.x - min.x) / 2,
y + (max.y - min.y) / 2,
z + (max.z - min.z) / 2
);
this.children.push(new OctreeNode(
{ min: childMin, max: childMax },
this.depth + 1,
this.maxDepth,
this.maxObjects
));
}
// Redistribute existing objects
for (const { object, position } of this.objects) {
for (const child of this.children) {
if (child.insert(object, position)) break;
}
}
this.objects = [];
}
}Decision Framework
| Component Count | Distribution | Recommendation |
|---|---|---|
| < 1,000 | Any | Simple array with distance checks |
| 1,000 - 5,000 | Uniform | Spatial hash grid |
| 1,000 - 5,000 | Clustered | Octree |
| 5,000 - 10,000 | Any | Octree |
| 10,000+ | Any | Chunk system + octree per chunk |
Chunk-Based Loading
For open worlds, keep the entire structure in memory is impossible. Chunk systems divide the world into discrete regions that load/unload based on player proximity.
Minecraft's approach: 16×16×256 block chunks. Only chunks within render distance are loaded. This enables infinite horizontal worlds while keeping memory bounded.
Key implementation concerns:
1. Chunk boundaries: Objects spanning multiple chunks need special handling. Either assign to primary chunk or duplicate references.
2. Loading priority: Not all chunks are equal. Prioritize:
- Chunks player is moving toward
- Chunks containing player-owned structures
- Chunks with recent activity
3. Async loading: Never block the main thread. Load chunk data in workers, then integrate on main thread.
class ChunkManager {
constructor(chunkSize = 64, loadDistance = 3) {
this.chunkSize = chunkSize;
this.loadDistance = loadDistance;
this.chunks = new Map();
this.loadQueue = [];
}
update(playerPosition) {
const playerChunk = this._worldToChunk(playerPosition);
// Queue chunks that should be loaded
for (let dx = -this.loadDistance; dx <= this.loadDistance; dx++) {
for (let dz = -this.loadDistance; dz <= this.loadDistance; dz++) {
const key = `${playerChunk.x + dx},${playerChunk.z + dz}`;
if (!this.chunks.has(key) && !this.loadQueue.includes(key)) {
this.loadQueue.push(key);
}
}
}
// Unload distant chunks
for (const [key, chunk] of this.chunks) {
const [cx, cz] = key.split(',').map(Number);
const dist = Math.max(
Math.abs(cx - playerChunk.x),
Math.abs(cz - playerChunk.z)
);
if (dist > this.loadDistance + 1) {
this._unloadChunk(key);
}
}
// Process load queue (limit per frame)
this._processLoadQueue(2);
}
}GPU Instancing
When thousands of building pieces use the same mesh (walls, floors, etc.), GPU instancing renders them in a single draw call. Instead of sending geometry repeatedly, send it once with a list of transformation matrices.
Three.js implementation:
class InstancedBuildingRenderer {
constructor(maxInstances = 10000) {
this.maxInstances = maxInstances;
this.instancedMeshes = new Map(); // meshType -> InstancedMesh
}
createInstancedMesh(type, geometry, material) {
const mesh = new THREE.InstancedMesh(geometry, material, this.maxInstances);
mesh.count = 0;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.instancedMeshes.set(type, mesh);
return mesh;
}
addInstance(type, position, rotation, scale) {
const mesh = this.instancedMeshes.get(type);
if (!mesh || mesh.count >= this.maxInstances) return -1;
const matrix = new THREE.Matrix4();
matrix.compose(position, rotation, scale);
mesh.setMatrixAt(mesh.count, matrix);
mesh.instanceMatrix.needsUpdate = true;
return mesh.count++;
}
updateInstance(type, index, position, rotation, scale) {
const mesh = this.instancedMeshes.get(type);
if (!mesh || index >= mesh.count) return;
const matrix = new THREE.Matrix4();
matrix.compose(position, rotation, scale);
mesh.setMatrixAt(index, matrix);
mesh.instanceMatrix.needsUpdate = true;
}
}Performance impact: Fortnite's "Performance Mode" uses ultra-simplified "bubble wrap" meshes combined with aggressive instancing to maintain framerate during intense build battles.
Occlusion Culling
Don't render what the camera can't see. For building interiors, this is critical—a large base might have 1,000 pieces but only 50 visible from inside a room.
Rust's approach: Dynamic occlusion system treating all world geometry as potential occluders. Trades one frame of latency for significant CPU savings on visibility calculations. The developers noted it cut a "good chunk of CPU-side overhead."
Implementation strategies:
1. Frustum culling: Built into Three.js. Objects outside camera view aren't rendered.
2. Distance culling: Simple but effective. Don't render objects beyond a threshold.
3. Portal-based: Define portals (doorways, windows) between rooms. Only render rooms visible through portals from camera position.
class OcclusionSystem {
constructor(camera) {
this.camera = camera;
this.frustum = new THREE.Frustum();
this.projMatrix = new THREE.Matrix4();
}
update() {
this.projMatrix.multiplyMatrices(
this.camera.projectionMatrix,
this.camera.matrixWorldInverse
);
this.frustum.setFromProjectionMatrix(this.projMatrix);
}
isVisible(object, maxDistance = 500) {
// Distance check
const distance = this.camera.position.distanceTo(object.position);
if (distance > maxDistance) return false;
// Frustum check
if (object.geometry?.boundingSphere) {
const sphere = object.geometry.boundingSphere.clone();
sphere.applyMatrix4(object.matrixWorld);
return this.frustum.intersectsSphere(sphere);
}
return this.frustum.containsPoint(object.position);
}
}Level of Detail (LOD)
Replace detailed meshes with simpler versions at distance. A wall might have 500 triangles up close, 50 at medium range, and 8 at far range.
Three.js LOD setup:
function createBuildingLOD(detailedGeo, mediumGeo, simpleGeo, material) {
const lod = new THREE.LOD();
lod.addLevel(new THREE.Mesh(detailedGeo, material), 0); // 0-20 units
lod.addLevel(new THREE.Mesh(mediumGeo, material), 20); // 20-50 units
lod.addLevel(new THREE.Mesh(simpleGeo, material), 50); // 50+ units
return lod;
}Combining with instancing: At distance, switch from individual LOD objects to instanced batches of the simplest mesh. This gives you the best of both worlds—detail up close, massive throughput at distance.
Memory Management
Building systems can consume gigabytes if not careful. Key strategies:
1. Object pooling: Reuse removed pieces instead of garbage collecting them.
2. Geometry sharing: Never duplicate geometry. Store one copy per piece type.
3. Lazy loading: Don't load textures/materials until a piece type is actually placed.
4. Building IDs: Rust uses building IDs for fast queries—all pieces in a structure share an ID, enabling efficient ownership checks and grouped operations.
class BuildingPool {
constructor() {
this.pools = new Map(); // type -> array of inactive objects
}
acquire(type, createFn) {
const pool = this.pools.get(type);
if (pool && pool.length > 0) {
const obj = pool.pop();
obj.visible = true;
return obj;
}
return createFn();
}
release(type, object) {
object.visible = false;
if (!this.pools.has(type)) {
this.pools.set(type, []);
}
this.pools.get(type).push(object);
}
}Performance Targets
Based on industry benchmarks:
| Platform | Target FPS | Max Active Colliders | Draw Calls |
|---|---|---|---|
| Desktop (RTX 3060) | 60 | 10,000 | < 500 |
| Desktop (integrated) | 30 | 2,000 | < 200 |
| Mobile (iPhone 12) | 30-60 | 1,000 | < 100 |
| Mobile (older) | 30 | 500 | < 50 |
Measuring in Three.js:
const stats = new Stats();
document.body.appendChild(stats.dom);
// In render loop
stats.begin();
renderer.render(scene, camera);
stats.end();
// Log renderer info periodically
console.log('Draw calls:', renderer.info.render.calls);
console.log('Triangles:', renderer.info.render.triangles);
console.log('Geometries:', renderer.info.memory.geometries);Integration Checklist
When implementing performance systems in your building mechanics:
- [ ] Choose spatial structure based on expected component count and distribution
- [ ] Implement chunk loading for open-world games
- [ ] Use instancing for repeated building pieces (walls, floors, etc.)
- [ ] Add distance-based culling as minimum optimization
- [ ] Pool frequently created/destroyed objects
- [ ] Profile regularly with target hardware
- [ ] Set hard limits and degrade gracefully when exceeded
Related References
octree.js- Complete octree implementationspatial-hash-grid.js- Spatial hash grid implementationchunk-manager.js- Chunk loading/unloading systemperformance-profiler.js- Benchmarking utilitiesstructural-validation.md- How spatial queries support structural checks
Structural Physics Advanced
Building games face a fundamental tension: realistic physics create satisfying, believable structures but demand enormous computation and frustrate players with unexpected collapses. This reference covers the spectrum from arcade simplicity to full simulation, with focus on the heuristic middle ground used by most successful building games.
The Physics Spectrum
Arcade Style (Fortnite, Minecraft)
No structural simulation at all. Pieces exist or don't exist. The only physics rule: if a structure becomes disconnected from any grounded piece, it collapses entirely.
Characteristics:
- Binary stability: connected = stable, disconnected = collapse
- No stress, no load distribution, no material strength
- Pieces can float if connected to something grounded
- Collapse is instant and total
Why it works: Speed. Fortnite players build during combat—checking "is this connected?" is O(1) with proper data structures. Full physics would make build battles impossible.
Implementation:
// Arcade: Just check connectivity to ground
function isStable(piece, buildingGraph) {
return buildingGraph.hasPathToGround(piece);
}
function onPieceDestroyed(piece, buildingGraph) {
buildingGraph.remove(piece);
// Find all pieces no longer connected to ground
const disconnected = buildingGraph.findDisconnected();
// Instant collapse - no physics, just removal
for (const p of disconnected) {
destroyPiece(p);
}
}Heuristic Style (Rust, Valheim, 7 Days to Die)
Simplified stability rules that feel physics-like without simulating actual forces. Each piece has a "stability value" based on its support chain, not real stress calculations.
Rust's approach: Every piece has stability 0-100%. Ground pieces = 100%. Each piece above inherits stability minus a penalty. When stability hits 0%, the piece can't be placed or collapses.
Valheim's insight: The developers explicitly stated their system "does not work like real materials – it's more like pressure in a plumbing system, a magic force from the ground." This framing helps players build mental models.
Why it works:
- Predictable: players learn the rules and plan around them
- Fast: O(n) worst case for stability recalculation, usually O(log n) with caching
- Tunable: designers control exactly how tall/wide structures can be
The stability formula:
// Heuristic stability calculation
function calculateStability(piece, graph) {
// Ground pieces are always 100%
if (piece.isFoundation && piece.touchesGround) {
return 1.0;
}
// Find supporting pieces
const supports = graph.getSupports(piece);
if (supports.length === 0) {
return 0; // No support = unstable
}
// Inherit best supporter's stability minus decay
let maxSupportStability = 0;
for (const support of supports) {
const supportStability = support.cachedStability ?? calculateStability(support, graph);
maxSupportStability = Math.max(maxSupportStability, supportStability);
}
// Apply material-specific decay
const decay = piece.material.stabilityDecay; // e.g., wood: 0.15, stone: 0.10
return Math.max(0, maxSupportStability - decay);
}Realistic Style (Medieval Engineers, Space Engineers)
Full physics simulation with stress propagation, material deformation, and dynamic fracture. Every piece has mass, every connection has strength limits.
Characteristics:
- Real load distribution through structures
- Material fatigue and breaking points
- Dynamic collapse with pieces falling realistically
- Computationally expensive
Why it's rare: The Medieval Engineers developers found players spent more time fighting physics than building. Structures collapsed unexpectedly. The "fun" ceiling was low despite technical impressiveness.
When to use: Engineering sandboxes, educational simulations, or games where structural failure IS the gameplay (bridge builders, demolition games).
Choosing Your Approach
| Factor | Arcade | Heuristic | Realistic |
|---|---|---|---|
| Computation | O(1) per query | O(n) worst case | O(n²) or worse |
| Player learning curve | Minutes | Hours | Days |
| Emergent structures | Limited | Moderate | High |
| Frustration potential | Low | Medium | High |
| Multiplayer friendly | Excellent | Good | Difficult |
| Best for | Action games, combat building | Survival, base building | Engineering sims |
Rule of thumb: If building happens during combat or time pressure, use arcade. If building is a primary activity players spend hours on, use heuristic. Only use realistic if structural engineering IS your game.
Implementing Heuristic Physics
The Support Graph
Model structures as directed graphs where edges represent "supports" relationships.
class SupportGraph {
constructor() {
this.nodes = new Map(); // pieceId -> piece
this.supports = new Map(); // pieceId -> Set of pieces this supports
this.supportedBy = new Map(); // pieceId -> Set of pieces supporting this
}
addPiece(piece) {
this.nodes.set(piece.id, piece);
this.supports.set(piece.id, new Set());
this.supportedBy.set(piece.id, new Set());
}
addSupport(supporter, supported) {
this.supports.get(supporter.id).add(supported);
this.supportedBy.get(supported.id).add(supporter);
}
getSupports(piece) {
return Array.from(this.supportedBy.get(piece.id) || []);
}
getSupportedPieces(piece) {
return Array.from(this.supports.get(piece.id) || []);
}
removePiece(piece) {
// Remove all support relationships
for (const supported of this.supports.get(piece.id) || []) {
this.supportedBy.get(supported.id)?.delete(piece);
}
for (const supporter of this.supportedBy.get(piece.id) || []) {
this.supports.get(supporter.id)?.delete(piece);
}
this.nodes.delete(piece.id);
this.supports.delete(piece.id);
this.supportedBy.delete(piece.id);
}
}Stability Propagation
When a piece is destroyed, stability changes ripple upward through dependent pieces.
class StabilitySystem {
constructor(graph) {
this.graph = graph;
this.stabilityCache = new Map();
this.minStability = 0.05; // Below this = collapse
}
/**
* Recalculate stability for affected pieces after destruction
*/
onPieceDestroyed(piece) {
const affected = this.getAffectedPieces(piece);
this.graph.removePiece(piece);
// Clear cache for affected pieces
for (const p of affected) {
this.stabilityCache.delete(p.id);
}
// Recalculate and find collapses
const toCollapse = [];
for (const p of affected) {
const stability = this.calculateStability(p);
if (stability < this.minStability) {
toCollapse.push(p);
}
}
return toCollapse;
}
/**
* Get all pieces that depend on this piece for support
*/
getAffectedPieces(piece) {
const affected = new Set();
const queue = [piece];
while (queue.length > 0) {
const current = queue.shift();
const supported = this.graph.getSupportedPieces(current);
for (const p of supported) {
if (!affected.has(p)) {
affected.add(p);
queue.push(p);
}
}
}
return affected;
}
/**
* Calculate stability with caching
*/
calculateStability(piece) {
if (this.stabilityCache.has(piece.id)) {
return this.stabilityCache.get(piece.id);
}
const stability = this._computeStability(piece);
this.stabilityCache.set(piece.id, stability);
return stability;
}
_computeStability(piece) {
// Foundations touching ground are 100% stable
if (piece.type === 'foundation' && piece.onGround) {
return 1.0;
}
const supports = this.graph.getSupports(piece);
if (supports.length === 0) {
return 0;
}
// Find maximum stability from supporters
let maxStability = 0;
for (const supporter of supports) {
const supporterStability = this.calculateStability(supporter);
maxStability = Math.max(maxStability, supporterStability);
}
// Apply decay based on piece type and material
const decay = this.getDecayRate(piece);
return Math.max(0, maxStability - decay);
}
getDecayRate(piece) {
// Vertical pieces (walls, pillars) decay less than horizontal (floors, roofs)
const baseDecay = piece.material?.stabilityDecay ?? 0.1;
const orientationMultiplier = piece.isVertical ? 0.5 : 1.0;
return baseDecay * orientationMultiplier;
}
}Support Detection
Determining which pieces support which requires geometric analysis.
class SupportDetector {
constructor(options = {}) {
this.snapTolerance = options.snapTolerance ?? 0.1;
}
/**
* Find all support relationships for a piece
*/
findSupports(piece, allPieces) {
const supports = [];
for (const other of allPieces) {
if (other === piece) continue;
if (this.canSupport(other, piece)) {
supports.push(other);
}
}
return supports;
}
/**
* Check if 'supporter' can support 'piece'
*/
canSupport(supporter, piece) {
// Supporter must be below or at same level
if (supporter.bounds.max.y < piece.bounds.min.y - this.snapTolerance) {
return false;
}
// Must be within snap tolerance vertically
const verticalGap = piece.bounds.min.y - supporter.bounds.max.y;
if (verticalGap > this.snapTolerance) {
return false;
}
// Check horizontal overlap
return this.hasHorizontalOverlap(supporter, piece);
}
hasHorizontalOverlap(a, b) {
const overlapX = a.bounds.max.x > b.bounds.min.x && a.bounds.min.x < b.bounds.max.x;
const overlapZ = a.bounds.max.z > b.bounds.min.z && a.bounds.min.z < b.bounds.max.z;
return overlapX && overlapZ;
}
/**
* Determine support type for visual feedback
*/
getSupportType(supporter, piece) {
const centerDist = Math.abs(supporter.position.x - piece.position.x) +
Math.abs(supporter.position.z - piece.position.z);
if (supporter.type === 'foundation' && supporter.onGround) {
return 'ground';
} else if (supporter.type === 'pillar' || supporter.type === 'wall') {
return 'vertical';
} else if (centerDist < 0.5) {
return 'direct'; // Directly above
} else {
return 'cantilever'; // Offset support
}
}
}Damage and Partial Destruction
Real structures don't collapse all at once. Implement damage states for more interesting gameplay.
Damage States
const DamageState = {
PRISTINE: 'pristine', // Full health
DAMAGED: 'damaged', // Visible damage, reduced stability
CRITICAL: 'critical', // Near collapse, major stability penalty
DESTROYED: 'destroyed' // Gone
};
class DamageableBuilding {
constructor(piece) {
this.piece = piece;
this.maxHealth = piece.material.health;
this.health = this.maxHealth;
this.damageState = DamageState.PRISTINE;
}
takeDamage(amount) {
this.health = Math.max(0, this.health - amount);
this.updateDamageState();
return this.damageState;
}
updateDamageState() {
const healthPercent = this.health / this.maxHealth;
if (healthPercent <= 0) {
this.damageState = DamageState.DESTROYED;
} else if (healthPercent <= 0.25) {
this.damageState = DamageState.CRITICAL;
} else if (healthPercent <= 0.6) {
this.damageState = DamageState.DAMAGED;
} else {
this.damageState = DamageState.PRISTINE;
}
}
getStabilityModifier() {
switch (this.damageState) {
case DamageState.PRISTINE: return 1.0;
case DamageState.DAMAGED: return 0.8;
case DamageState.CRITICAL: return 0.5;
default: return 0;
}
}
}Cascading Damage
When a piece is destroyed, adjacent pieces may take damage from the collapse.
class DamageSystem {
constructor(stabilitySystem) {
this.stability = stabilitySystem;
this.damagePropagation = 0.3; // Collapse does 30% damage to neighbors
}
destroyPiece(piece) {
const results = {
destroyed: [piece],
damaged: [],
collapsed: []
};
// Find pieces that will collapse due to stability loss
const unstable = this.stability.onPieceDestroyed(piece);
// Process collapses in order (bottom to top)
unstable.sort((a, b) => a.position.y - b.position.y);
for (const p of unstable) {
results.collapsed.push(p);
// Propagate damage to neighbors
const neighbors = this.getAdjacentPieces(p);
for (const neighbor of neighbors) {
if (!results.destroyed.includes(neighbor) &&
!results.collapsed.includes(neighbor)) {
const damage = p.mass * this.damagePropagation;
const newState = neighbor.takeDamage(damage);
if (newState === DamageState.DESTROYED) {
results.destroyed.push(neighbor);
} else if (newState !== DamageState.PRISTINE) {
results.damaged.push(neighbor);
}
}
}
}
return results;
}
}Visual Feedback
Players need to understand stability without studying numbers.
Stability Visualization (Valheim Style)
Valheim uses color coding: blue/green = strong, yellow = moderate, red = weak.
class StabilityVisualizer {
constructor() {
this.colors = {
excellent: new THREE.Color(0x4488ff), // Blue - grounded
good: new THREE.Color(0x44ff44), // Green - stable
moderate: new THREE.Color(0xffff44), // Yellow - getting weak
weak: new THREE.Color(0xff8844), // Orange - danger
critical: new THREE.Color(0xff4444) // Red - about to collapse
};
}
getStabilityColor(stability) {
if (stability >= 0.9) return this.colors.excellent;
if (stability >= 0.7) return this.colors.good;
if (stability >= 0.5) return this.colors.moderate;
if (stability >= 0.25) return this.colors.weak;
return this.colors.critical;
}
/**
* Apply stability coloring to building pieces
*/
visualizeStability(pieces, stabilitySystem, enabled = true) {
for (const piece of pieces) {
if (!enabled) {
piece.mesh.material.color.setHex(piece.originalColor);
continue;
}
const stability = stabilitySystem.calculateStability(piece);
const color = this.getStabilityColor(stability);
piece.mesh.material.color.copy(color);
}
}
/**
* Create stability indicator UI element
*/
createStabilityIndicator(piece, stabilitySystem) {
const stability = stabilitySystem.calculateStability(piece);
const percent = Math.round(stability * 100);
const color = this.getStabilityColor(stability);
return {
text: `${percent}%`,
color: `#${color.getHexString()}`,
position: piece.position.clone().add(new THREE.Vector3(0, 2, 0))
};
}
}Collapse Animation
Instant disappearance feels wrong. Animate collapses for feedback.
class CollapseAnimator {
constructor(scene) {
this.scene = scene;
this.activeCollapses = [];
}
/**
* Animate a piece collapsing
*/
collapse(piece, delay = 0) {
const animation = {
piece,
startTime: performance.now() + delay,
duration: 800 + Math.random() * 400,
startPosition: piece.position.clone(),
startRotation: piece.rotation.clone(),
velocity: new THREE.Vector3(
(Math.random() - 0.5) * 2,
-5,
(Math.random() - 0.5) * 2
),
angularVelocity: new THREE.Vector3(
(Math.random() - 0.5) * 3,
(Math.random() - 0.5) * 3,
(Math.random() - 0.5) * 3
),
phase: 'waiting'
};
this.activeCollapses.push(animation);
return animation;
}
/**
* Animate multiple pieces with cascading delay
*/
collapseMultiple(pieces) {
// Sort by height (top pieces fall last for visual effect)
pieces.sort((a, b) => b.position.y - a.position.y);
pieces.forEach((piece, index) => {
this.collapse(piece, index * 50); // 50ms stagger
});
}
/**
* Update all active collapse animations
*/
update(deltaTime) {
const now = performance.now();
const gravity = -20;
for (let i = this.activeCollapses.length - 1; i >= 0; i--) {
const anim = this.activeCollapses[i];
if (anim.phase === 'waiting') {
if (now >= anim.startTime) {
anim.phase = 'falling';
anim.fallStart = now;
}
continue;
}
const elapsed = now - anim.fallStart;
const t = Math.min(elapsed / anim.duration, 1);
// Apply physics
const piece = anim.piece;
piece.position.x = anim.startPosition.x + anim.velocity.x * t;
piece.position.y = anim.startPosition.y + anim.velocity.y * t + 0.5 * gravity * t * t;
piece.position.z = anim.startPosition.z + anim.velocity.z * t;
piece.rotation.x = anim.startRotation.x + anim.angularVelocity.x * t;
piece.rotation.y = anim.startRotation.y + anim.angularVelocity.y * t;
piece.rotation.z = anim.startRotation.z + anim.angularVelocity.z * t;
// Fade out
if (piece.mesh.material.opacity !== undefined) {
piece.mesh.material.transparent = true;
piece.mesh.material.opacity = 1 - t;
}
// Remove when done
if (t >= 1) {
this.scene.remove(piece.mesh);
this.activeCollapses.splice(i, 1);
}
}
}
}Performance Considerations
Caching Strategy
Stability calculations can be expensive. Cache aggressively.
class CachedStabilitySystem {
constructor(graph) {
this.graph = graph;
this.cache = new Map();
this.dirty = new Set(); // Pieces needing recalculation
}
invalidate(piece) {
// Mark this piece and all dependent pieces as dirty
this.dirty.add(piece.id);
const dependents = this.graph.getAffectedPieces(piece);
for (const dep of dependents) {
this.dirty.add(dep.id);
this.cache.delete(dep.id);
}
}
getStability(piece) {
if (!this.dirty.has(piece.id) && this.cache.has(piece.id)) {
return this.cache.get(piece.id);
}
const stability = this.calculateStability(piece);
this.cache.set(piece.id, stability);
this.dirty.delete(piece.id);
return stability;
}
/**
* Batch recalculate all dirty pieces
* Call this once per frame, not per piece
*/
recalculateDirty() {
// Sort dirty pieces by support order (bottom to top)
const sorted = Array.from(this.dirty)
.map(id => this.graph.nodes.get(id))
.filter(p => p)
.sort((a, b) => a.position.y - b.position.y);
for (const piece of sorted) {
this.getStability(piece);
}
this.dirty.clear();
}
}Event-Driven Updates
Only recalculate when something changes.
class EventDrivenStability {
constructor() {
this.listeners = new Set();
}
// Called when piece is placed
onPiecePlaced(piece) {
this.invalidateAffected(piece);
this.scheduleRecalculation();
}
// Called when piece is destroyed
onPieceDestroyed(piece) {
this.invalidateAffected(piece);
this.scheduleRecalculation();
}
// Debounce recalculations
scheduleRecalculation() {
if (this.recalcTimer) return;
this.recalcTimer = requestAnimationFrame(() => {
this.recalcTimer = null;
this.recalculateAll();
this.notifyListeners();
});
}
}Material Properties
Different materials create different building constraints.
const Materials = {
WOOD: {
name: 'Wood',
health: 100,
stabilityDecay: 0.15, // Loses 15% per piece
maxStackHeight: 6, // Can stack ~6 pieces high
buildTime: 1.0,
upgradeTo: 'STONE'
},
STONE: {
name: 'Stone',
health: 300,
stabilityDecay: 0.10, // Loses 10% per piece
maxStackHeight: 10,
buildTime: 2.0,
upgradeTo: 'METAL'
},
METAL: {
name: 'Metal',
health: 500,
stabilityDecay: 0.05, // Loses 5% per piece
maxStackHeight: 20,
buildTime: 3.0,
upgradeTo: null
},
THATCH: {
name: 'Thatch',
health: 50,
stabilityDecay: 0.20,
maxStackHeight: 4,
buildTime: 0.5,
upgradeTo: 'WOOD'
}
};
// Helper to check if placement is valid
function canPlace(piece, stabilitySystem) {
const stability = stabilitySystem.calculateStability(piece);
return stability >= 0.05; // Minimum 5% to place
}Integration Checklist
When implementing structural physics in your building system:
- [ ] Choose physics approach based on game type (arcade/heuristic/realistic)
- [ ] Implement support graph for tracking piece relationships
- [ ] Add stability calculation with appropriate decay rates
- [ ] Cache stability values and invalidate on changes
- [ ] Implement visual feedback (colors, indicators)
- [ ] Add collapse animations for destroyed pieces
- [ ] Handle cascading damage for connected pieces
- [ ] Tune material properties for desired building limits
- [ ] Test with maximum expected structure sizes
- [ ] Profile stability recalculation performance
Related References
heuristic-validator.js- Fast arcade/heuristic stability checkingstability-optimizer.js- Caching and batch recalculationdamage-propagation.js- Damage states and cascading destructionphysics-engine-lite.js- Optional realistic physics modestructural-validation.md- Original structural validation reference
/**
* BuildingNetworkManager - Complete networking system for multiplayer building
*
* Integrates delta compression, client prediction, and conflict resolution
* into a unified networking layer for building mechanics.
*
* Server-authoritative model with client prediction for responsiveness.
*
* Usage:
* // Server
* const server = new BuildingNetworkServer(buildingSystem);
* server.onClientMessage(clientId, message);
*
* // Client
* const client = new BuildingNetworkClient(buildingSystem);
* client.connect(serverUrl);
* client.placeRequest(pieceType, position, rotation);
*/
import { DeltaCompressor, DeltaReceiver } from './delta-compression.js';
import { ClientPrediction } from './client-prediction.js';
import { ConflictResolver, BuildingPermissionSystem } from './conflict-resolver.js';
/**
* Message types for building network protocol
*/
export const MessageType = {
// Client -> Server
PLACE_REQUEST: 'place_request',
DESTROY_REQUEST: 'destroy_request',
UPGRADE_REQUEST: 'upgrade_request',
ROTATE_REQUEST: 'rotate_request',
CLAIM_REGION: 'claim_region',
AUTHORIZE_PLAYER: 'authorize_player',
// Server -> Client
PLACE_CONFIRMED: 'place_confirmed',
PLACE_REJECTED: 'place_rejected',
PIECE_DESTROYED: 'piece_destroyed',
PIECE_UPDATED: 'piece_updated',
FULL_SYNC: 'full_sync',
DELTA_UPDATE: 'delta_update',
REGION_UPDATE: 'region_update',
// Bidirectional
PING: 'ping',
PONG: 'pong',
ACK: 'ack',
ERROR: 'error'
};
/**
* Rate limiter for building operations
*/
class RateLimiter {
constructor(options = {}) {
this.limits = {
place: options.placePerSecond ?? 5,
destroy: options.destroyPerSecond ?? 10,
upgrade: options.upgradePerSecond ?? 3
};
this.buckets = new Map(); // clientId -> { action -> { count, resetTime } }
}
check(clientId, action) {
const limit = this.limits[action] ?? 10;
let clientBuckets = this.buckets.get(clientId);
if (!clientBuckets) {
clientBuckets = {};
this.buckets.set(clientId, clientBuckets);
}
const now = Date.now();
let bucket = clientBuckets[action];
if (!bucket || now > bucket.resetTime) {
bucket = { count: 0, resetTime: now + 1000 };
clientBuckets[action] = bucket;
}
if (bucket.count >= limit) {
return {
allowed: false,
retryAfter: bucket.resetTime - now
};
}
bucket.count++;
return { allowed: true };
}
reset(clientId) {
this.buckets.delete(clientId);
}
}
/**
* Server-side building network manager
*/
export class BuildingNetworkServer {
constructor(buildingSystem, options = {}) {
this.buildingSystem = buildingSystem;
// Configuration
this.tickRate = options.tickRate ?? 20; // Updates per second
this.maxClientsPerUpdate = options.maxClientsPerUpdate ?? 50;
// Subsystems
this.deltaCompressor = new DeltaCompressor({
maxHistorySize: options.deltaHistorySize ?? 64,
compactMode: true
});
this.conflictResolver = new ConflictResolver({
strategy: options.conflictStrategy ?? 'first_write',
conflictWindow: options.conflictWindow ?? 100,
enableLocking: options.enableLocking ?? false
});
this.permissions = new BuildingPermissionSystem({
defaultAllow: options.defaultAllowBuilding ?? true,
regionRadius: options.regionRadius ?? 50
});
this.rateLimiter = new RateLimiter(options.rateLimits);
// Client management
this.clients = new Map(); // clientId -> ClientState
this.clientPositions = new Map(); // clientId -> position (for spatial broadcasts)
// State
this.stateVersion = 0;
this.lastTickTime = 0;
this.tickInterval = null;
// Callbacks (implement these to send messages)
this.sendToClient = options.sendToClient ?? null;
this.broadcastToAll = options.broadcastToAll ?? null;
this.broadcastToNearby = options.broadcastToNearby ?? null;
// Set up conflict resolver callbacks
this.conflictResolver.onRequestProcessed = (pending, success, reason) => {
this.handleRequestProcessed(pending, success, reason);
};
// Statistics
this.stats = {
messagesReceived: 0,
messagesSent: 0,
placementsProcessed: 0,
destructionsProcessed: 0,
conflictsResolved: 0
};
}
/**
* Start the server tick loop
*/
start() {
if (this.tickInterval) return;
const tickMs = 1000 / this.tickRate;
this.tickInterval = setInterval(() => this.tick(), tickMs);
this.lastTickTime = Date.now();
}
/**
* Stop the server tick loop
*/
stop() {
if (this.tickInterval) {
clearInterval(this.tickInterval);
this.tickInterval = null;
}
}
/**
* Server tick - process updates and send deltas
*/
tick() {
const now = Date.now();
const deltaTime = now - this.lastTickTime;
this.lastTickTime = now;
// Record current state
this.stateVersion++;
this.deltaCompressor.recordState(this.buildingSystem);
// Send updates to clients
this.sendUpdatesToClients();
// Clean up
this.conflictResolver.cleanupExpiredLocks();
}
/**
* Send delta updates to all clients
*/
sendUpdatesToClients() {
if (!this.sendToClient) return;
let clientsProcessed = 0;
for (const [clientId, clientState] of this.clients) {
if (clientsProcessed >= this.maxClientsPerUpdate) break;
const delta = this.deltaCompressor.getDeltaForClient(clientId);
if (delta && !delta.isEmpty) {
const message = {
type: delta.isFull ? MessageType.FULL_SYNC : MessageType.DELTA_UPDATE,
data: delta.serialize(true),
version: this.stateVersion
};
this.sendToClient(clientId, message);
this.stats.messagesSent++;
}
clientsProcessed++;
}
}
/**
* Handle incoming message from client
*/
onClientMessage(clientId, message) {
this.stats.messagesReceived++;
switch (message.type) {
case MessageType.PLACE_REQUEST:
this.handlePlaceRequest(clientId, message);
break;
case MessageType.DESTROY_REQUEST:
this.handleDestroyRequest(clientId, message);
break;
case MessageType.UPGRADE_REQUEST:
this.handleUpgradeRequest(clientId, message);
break;
case MessageType.CLAIM_REGION:
this.handleClaimRegion(clientId, message);
break;
case MessageType.AUTHORIZE_PLAYER:
this.handleAuthorizePlayer(clientId, message);
break;
case MessageType.ACK:
this.handleAck(clientId, message);
break;
case MessageType.PING:
this.handlePing(clientId, message);
break;
default:
console.warn(`Unknown message type: ${message.type}`);
}
}
/**
* Handle place request
*/
handlePlaceRequest(clientId, message) {
// Rate limit check
const rateCheck = this.rateLimiter.check(clientId, 'place');
if (!rateCheck.allowed) {
this.sendReject(clientId, message.tempId, `Rate limited. Retry in ${rateCheck.retryAfter}ms`);
return;
}
// Permission check
const position = message.position;
if (!this.permissions.canBuild(clientId, position)) {
this.sendReject(clientId, message.tempId, 'No permission to build in this area');
return;
}
// Validate placement (collision, resources, etc.)
const validation = this.validatePlacement(message, clientId);
if (!validation.valid) {
this.sendReject(clientId, message.tempId, validation.reason);
return;
}
// Submit to conflict resolver
const client = this.clients.get(clientId);
this.conflictResolver.submitPlaceRequest(message, { id: clientId, ...client });
}
/**
* Handle destroy request
*/
handleDestroyRequest(clientId, message) {
const rateCheck = this.rateLimiter.check(clientId, 'destroy');
if (!rateCheck.allowed) {
this.sendReject(clientId, message.tempId, 'Rate limited');
return;
}
const piece = this.buildingSystem.getPieceById(message.pieceId);
if (!piece) {
this.sendReject(clientId, message.tempId, 'Piece not found');
return;
}
// Permission check
if (!this.permissions.canBuild(clientId, piece.position)) {
this.sendReject(clientId, message.tempId, 'No permission to destroy in this area');
return;
}
// Destroy piece
const destroyed = this.buildingSystem.destroyPiece(piece);
this.stats.destructionsProcessed++;
// Broadcast destruction
this.broadcastPieceDestroyed(message.pieceId, destroyed);
// Confirm to requester
this.sendConfirm(clientId, message.tempId, message.pieceId, { destroyed: destroyed.length });
}
/**
* Handle upgrade request
*/
handleUpgradeRequest(clientId, message) {
const rateCheck = this.rateLimiter.check(clientId, 'upgrade');
if (!rateCheck.allowed) {
this.sendReject(clientId, message.tempId, 'Rate limited');
return;
}
const piece = this.buildingSystem.getPieceById(message.pieceId);
if (!piece) {
this.sendReject(clientId, message.tempId, 'Piece not found');
return;
}
// Permission check
if (!this.permissions.canBuild(clientId, piece.position)) {
this.sendReject(clientId, message.tempId, 'No permission');
return;
}
// Upgrade piece
const upgraded = this.buildingSystem.upgradePiece(piece, message.material);
if (!upgraded) {
this.sendReject(clientId, message.tempId, 'Cannot upgrade');
return;
}
// Broadcast update
this.broadcastPieceUpdated(piece);
// Confirm
this.sendConfirm(clientId, message.tempId, piece.id, { material: message.material });
}
/**
* Handle region claim request
*/
handleClaimRegion(clientId, message) {
const result = this.permissions.claimRegion(clientId, message.position);
if (result.success) {
this.send(clientId, {
type: MessageType.REGION_UPDATE,
action: 'claimed',
regionId: result.regionId,
position: message.position
});
} else {
this.send(clientId, {
type: MessageType.ERROR,
error: result.reason
});
}
}
/**
* Handle authorize player request
*/
handleAuthorizePlayer(clientId, message) {
const result = this.permissions.authorize(clientId, message.playerId, message.regionId);
this.send(clientId, {
type: MessageType.REGION_UPDATE,
action: result.success ? 'authorized' : 'error',
playerId: message.playerId,
error: result.reason
});
}
/**
* Handle acknowledgment
*/
handleAck(clientId, message) {
this.deltaCompressor.acknowledgeVersion(clientId, message.version);
}
/**
* Handle ping
*/
handlePing(clientId, message) {
this.send(clientId, {
type: MessageType.PONG,
clientTime: message.clientTime,
serverTime: Date.now()
});
}
/**
* Handle processed request from conflict resolver
*/
handleRequestProcessed(pending, success, reason) {
const clientId = pending.client.id;
const message = pending.request;
if (success) {
// Actually place the piece
const piece = this.buildingSystem.place(
message.pieceType,
message.position,
message.rotation,
message.material
);
this.stats.placementsProcessed++;
// Send confirmation
this.sendConfirm(clientId, message.tempId, piece.id, {
position: piece.position,
rotation: piece.rotation
});
// Broadcast to nearby players
if (this.broadcastToNearby) {
this.broadcastToNearby(message.position, {
type: MessageType.PIECE_UPDATED,
piece: this.serializePiece(piece)
}, clientId);
}
} else {
this.sendReject(clientId, message.tempId, reason);
}
}
/**
* Validate placement request
*/
validatePlacement(message, clientId) {
// Check collision
if (this.buildingSystem.checkCollision(message.position, message.pieceType)) {
return { valid: false, reason: 'Position occupied' };
}
// Check structural validity
if (!this.buildingSystem.isValidPlacement(message.pieceType, message.position)) {
return { valid: false, reason: 'Invalid placement - needs support' };
}
// Additional validation (resources, limits, etc.) would go here
return { valid: true };
}
/**
* Send confirmation to client
*/
sendConfirm(clientId, tempId, serverId, data = {}) {
this.send(clientId, {
type: MessageType.PLACE_CONFIRMED,
tempId,
serverId,
...data
});
}
/**
* Send rejection to client
*/
sendReject(clientId, tempId, reason) {
this.send(clientId, {
type: MessageType.PLACE_REJECTED,
tempId,
reason
});
}
/**
* Broadcast piece destroyed
*/
broadcastPieceDestroyed(pieceId, cascadeDestroyed = []) {
const message = {
type: MessageType.PIECE_DESTROYED,
pieceId,
cascade: cascadeDestroyed.map(p => p.id)
};
if (this.broadcastToAll) {
this.broadcastToAll(message);
}
}
/**
* Broadcast piece updated
*/
broadcastPieceUpdated(piece) {
const message = {
type: MessageType.PIECE_UPDATED,
piece: this.serializePiece(piece)
};
if (this.broadcastToAll) {
this.broadcastToAll(message);
}
}
/**
* Send message to client
*/
send(clientId, message) {
if (this.sendToClient) {
this.sendToClient(clientId, message);
this.stats.messagesSent++;
}
}
/**
* Serialize piece for network
*/
serializePiece(piece) {
return {
id: piece.id,
type: piece.type,
position: { x: piece.position.x, y: piece.position.y, z: piece.position.z },
rotation: piece.rotation?.y ?? 0,
material: piece.material?.name ?? 'default',
health: piece.health
};
}
// ==================== Client Management ====================
/**
* Register new client
*/
addClient(clientId, playerData = {}) {
this.clients.set(clientId, {
id: clientId,
joinedAt: Date.now(),
...playerData
});
// Send full state sync
const fullSync = this.deltaCompressor.getDeltaForClient(clientId);
this.send(clientId, {
type: MessageType.FULL_SYNC,
data: fullSync?.serialize(true),
version: this.stateVersion
});
}
/**
* Remove client
*/
removeClient(clientId) {
this.clients.delete(clientId);
this.clientPositions.delete(clientId);
this.deltaCompressor.removeClient(clientId);
this.rateLimiter.reset(clientId);
}
/**
* Update client position (for spatial queries)
*/
updateClientPosition(clientId, position) {
this.clientPositions.set(clientId, position);
}
/**
* Get statistics
*/
getStats() {
return {
...this.stats,
clients: this.clients.size,
stateVersion: this.stateVersion,
deltaCompressor: this.deltaCompressor.getStats(),
conflictResolver: this.conflictResolver.getStats()
};
}
}
/**
* Client-side building network manager
*/
export class BuildingNetworkClient {
constructor(buildingSystem, options = {}) {
this.buildingSystem = buildingSystem;
// Subsystems
this.prediction = new ClientPrediction(buildingSystem, {
predictionTimeout: options.predictionTimeout ?? 5000,
enableGhostVisuals: options.enableGhostVisuals ?? true,
sendToServer: (msg) => this.send(msg)
});
this.deltaReceiver = new DeltaReceiver();
// Connection state
this.connected = false;
this.socket = null;
this.serverUrl = null;
// Latency tracking
this.latency = 0;
this.pingInterval = null;
this.lastPingTime = 0;
// Callbacks
this.onConnected = options.onConnected ?? null;
this.onDisconnected = options.onDisconnected ?? null;
this.onError = options.onError ?? null;
this.onPlaceConfirmed = options.onPlaceConfirmed ?? null;
this.onPlaceRejected = options.onPlaceRejected ?? null;
// Statistics
this.stats = {
messagesSent: 0,
messagesReceived: 0,
bytesReceived: 0
};
}
/**
* Connect to server
*/
connect(serverUrl) {
this.serverUrl = serverUrl;
// WebSocket connection (implement based on your networking layer)
this.socket = new WebSocket(serverUrl);
this.socket.onopen = () => {
this.connected = true;
this.startPing();
if (this.onConnected) this.onConnected();
};
this.socket.onclose = () => {
this.connected = false;
this.stopPing();
if (this.onDisconnected) this.onDisconnected();
};
this.socket.onerror = (error) => {
if (this.onError) this.onError(error);
};
this.socket.onmessage = (event) => {
this.onMessage(JSON.parse(event.data));
};
}
/**
* Disconnect from server
*/
disconnect() {
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.connected = false;
this.stopPing();
}
/**
* Send message to server
*/
send(message) {
if (!this.connected || !this.socket) return false;
this.socket.send(JSON.stringify(message));
this.stats.messagesSent++;
return true;
}
/**
* Handle incoming message
*/
onMessage(message) {
this.stats.messagesReceived++;
switch (message.type) {
case MessageType.PLACE_CONFIRMED:
this.handlePlaceConfirmed(message);
break;
case MessageType.PLACE_REJECTED:
this.handlePlaceRejected(message);
break;
case MessageType.FULL_SYNC:
case MessageType.DELTA_UPDATE:
this.handleStateUpdate(message);
break;
case MessageType.PIECE_DESTROYED:
this.handlePieceDestroyed(message);
break;
case MessageType.PIECE_UPDATED:
this.handlePieceUpdated(message);
break;
case MessageType.PONG:
this.handlePong(message);
break;
case MessageType.ERROR:
this.handleError(message);
break;
}
}
/**
* Handle place confirmed
*/
handlePlaceConfirmed(message) {
this.prediction.onServerConfirm(message.tempId, message.serverId, message);
if (this.onPlaceConfirmed) this.onPlaceConfirmed(message);
}
/**
* Handle place rejected
*/
handlePlaceRejected(message) {
this.prediction.onServerReject(message.tempId, message.reason);
if (this.onPlaceRejected) this.onPlaceRejected(message);
}
/**
* Handle state update (full sync or delta)
*/
handleStateUpdate(message) {
const result = this.deltaReceiver.apply(message.data, this.buildingSystem);
// Acknowledge receipt
this.send({
type: MessageType.ACK,
version: message.version
});
// Reconcile predictions with server state
if (result.type === 'full_sync') {
this.prediction.reconcileWithServerState(message.data.pieces);
}
}
/**
* Handle piece destroyed
*/
handlePieceDestroyed(message) {
this.buildingSystem.removePieceById(message.pieceId);
// Also remove cascade destroyed pieces
for (const pieceId of (message.cascade || [])) {
this.buildingSystem.removePieceById(pieceId);
}
}
/**
* Handle piece updated
*/
handlePieceUpdated(message) {
const piece = this.buildingSystem.getPieceById(message.piece.id);
if (piece) {
this.buildingSystem.updatePieceFromNetwork(piece, message.piece);
} else {
this.buildingSystem.addPieceFromNetwork(message.piece);
}
}
/**
* Handle pong
*/
handlePong(message) {
this.latency = Date.now() - message.clientTime;
}
/**
* Handle error
*/
handleError(message) {
console.error('Server error:', message.error);
if (this.onError) this.onError(message);
}
// ==================== Building Operations ====================
/**
* Request piece placement
*/
placeRequest(pieceType, position, rotation, options = {}) {
return this.prediction.predictPlace(pieceType, position, rotation, options);
}
/**
* Request piece destruction
*/
destroyRequest(piece) {
return this.prediction.predictDestroy(piece);
}
/**
* Request piece upgrade
*/
upgradeRequest(piece, material) {
const tempId = this.prediction.generateTempId();
this.send({
type: MessageType.UPGRADE_REQUEST,
tempId,
pieceId: piece.id,
material
});
return tempId;
}
// ==================== Utilities ====================
/**
* Start ping interval
*/
startPing() {
this.pingInterval = setInterval(() => {
this.lastPingTime = Date.now();
this.send({
type: MessageType.PING,
clientTime: this.lastPingTime
});
}, 1000);
}
/**
* Stop ping interval
*/
stopPing() {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
/**
* Update - call every frame
*/
update(deltaTime) {
this.prediction.update(deltaTime);
}
/**
* Get latency
*/
getLatency() {
return this.latency;
}
/**
* Get statistics
*/
getStats() {
return {
...this.stats,
connected: this.connected,
latency: this.latency,
prediction: this.prediction.getStats(),
deltaReceiver: {
currentVersion: this.deltaReceiver.currentVersion,
pendingDeltas: this.deltaReceiver.pendingDeltas.length
}
};
}
}
export default { BuildingNetworkServer, BuildingNetworkClient };
/**
* ChunkManager - World streaming for large-scale building systems
*
* Divides the world into chunks that load/unload based on player proximity.
* Enables effectively infinite worlds while keeping memory bounded.
*
* Based on: Minecraft's 16x16x256 chunk system, Rust's streaming architecture
*
* Usage:
* const chunkManager = new ChunkManager({ chunkSize: 64, loadDistance: 3 });
* chunkManager.onChunkLoad = async (chunkKey) => loadChunkData(chunkKey);
* chunkManager.onChunkUnload = (chunkKey, chunk) => saveChunkData(chunkKey, chunk);
* // In game loop:
* chunkManager.update(playerPosition);
*/
import * as THREE from 'three';
import { Octree } from './octree.js';
export class Chunk {
constructor(key, bounds, chunkSize) {
this.key = key;
this.bounds = bounds;
this.chunkSize = chunkSize;
this.objects = new Map(); // objectId -> object
this.spatialIndex = new Octree(bounds, { maxDepth: 4, maxObjects: 16 });
this.meshGroup = new THREE.Group();
this.meshGroup.name = `Chunk_${key}`;
this.state = 'unloaded'; // unloaded, loading, loaded, unloading
this.lastAccess = Date.now();
this.isDirty = false;
this.metadata = {};
}
/**
* Add object to chunk
*/
addObject(id, object, position) {
this.objects.set(id, { object, position: position.clone() });
this.spatialIndex.insert(object, position);
if (object.mesh) {
this.meshGroup.add(object.mesh);
} else if (object instanceof THREE.Object3D) {
this.meshGroup.add(object);
}
this.isDirty = true;
this.lastAccess = Date.now();
}
/**
* Remove object from chunk
*/
removeObject(id) {
const entry = this.objects.get(id);
if (!entry) return false;
this.spatialIndex.remove(entry.object);
if (entry.object.mesh) {
this.meshGroup.remove(entry.object.mesh);
} else if (entry.object instanceof THREE.Object3D) {
this.meshGroup.remove(entry.object);
}
this.objects.delete(id);
this.isDirty = true;
this.lastAccess = Date.now();
return true;
}
/**
* Query objects within radius
*/
queryRadius(position, radius) {
this.lastAccess = Date.now();
return this.spatialIndex.queryRadius(position, radius);
}
/**
* Get all objects in chunk
*/
getAllObjects() {
return Array.from(this.objects.values());
}
/**
* Get object count
*/
get objectCount() {
return this.objects.size;
}
/**
* Serialize chunk data for saving
*/
serialize() {
const objects = [];
for (const [id, { object, position }] of this.objects) {
objects.push({
id,
type: object.type || object.constructor.name,
position: { x: position.x, y: position.y, z: position.z },
data: object.serialize ? object.serialize() : {}
});
}
return {
key: this.key,
metadata: this.metadata,
objects
};
}
/**
* Clear chunk data
*/
clear() {
this.objects.clear();
this.spatialIndex.clear();
this.meshGroup.clear();
this.isDirty = false;
}
}
export class ChunkManager {
constructor(options = {}) {
this.chunkSize = options.chunkSize ?? 64;
this.loadDistance = options.loadDistance ?? 3;
this.unloadDistance = options.unloadDistance ?? this.loadDistance + 2;
this.maxLoadedChunks = options.maxLoadedChunks ?? 100;
this.loadBatchSize = options.loadBatchSize ?? 2; // Chunks to load per frame
this.worldHeight = options.worldHeight ?? 256;
this.chunks = new Map(); // chunkKey -> Chunk
this.loadQueue = [];
this.unloadQueue = [];
this.scene = options.scene || null;
// Callbacks
this.onChunkLoad = options.onChunkLoad || null; // async (chunkKey) => chunkData
this.onChunkUnload = options.onChunkUnload || null; // (chunkKey, chunk) => void
this.onChunkCreate = options.onChunkCreate || null; // (chunk) => void
// Stats
this.stats = {
loadedChunks: 0,
loadingChunks: 0,
queuedLoads: 0,
totalObjects: 0
};
// Internal state
this._lastPlayerChunk = null;
this._isProcessing = false;
}
/**
* Convert world position to chunk key
*/
worldToChunkKey(position) {
const cx = Math.floor(position.x / this.chunkSize);
const cz = Math.floor(position.z / this.chunkSize);
return `${cx},${cz}`;
}
/**
* Parse chunk key to chunk coordinates
*/
parseChunkKey(key) {
const [x, z] = key.split(',').map(Number);
return { x, z };
}
/**
* Get chunk bounds from key
*/
getChunkBounds(key) {
const { x, z } = this.parseChunkKey(key);
return {
min: new THREE.Vector3(
x * this.chunkSize,
0,
z * this.chunkSize
),
max: new THREE.Vector3(
(x + 1) * this.chunkSize,
this.worldHeight,
(z + 1) * this.chunkSize
)
};
}
/**
* Main update - call every frame with player position
*/
async update(playerPosition) {
const playerChunkKey = this.worldToChunkKey(playerPosition);
const playerChunk = this.parseChunkKey(playerChunkKey);
// Only recalculate if player moved to new chunk
if (this._lastPlayerChunk !== playerChunkKey) {
this._lastPlayerChunk = playerChunkKey;
this._updateChunkQueues(playerChunk);
}
// Process queues
await this._processLoadQueue();
this._processUnloadQueue();
this._updateStats();
}
/**
* Determine which chunks to load/unload
*/
_updateChunkQueues(playerChunk) {
const chunksToLoad = new Set();
const chunksToKeep = new Set();
// Determine chunks that should be loaded
for (let dx = -this.loadDistance; dx <= this.loadDistance; dx++) {
for (let dz = -this.loadDistance; dz <= this.loadDistance; dz++) {
const key = `${playerChunk.x + dx},${playerChunk.z + dz}`;
chunksToKeep.add(key);
if (!this.chunks.has(key)) {
chunksToLoad.add(key);
}
}
}
// Sort load queue by distance (closest first)
this.loadQueue = Array.from(chunksToLoad).sort((a, b) => {
const aCoords = this.parseChunkKey(a);
const bCoords = this.parseChunkKey(b);
const aDist = Math.abs(aCoords.x - playerChunk.x) + Math.abs(aCoords.z - playerChunk.z);
const bDist = Math.abs(bCoords.x - playerChunk.x) + Math.abs(bCoords.z - playerChunk.z);
return aDist - bDist;
});
// Determine chunks to unload
for (const [key, chunk] of this.chunks) {
if (chunk.state === 'loaded' || chunk.state === 'loading') {
const coords = this.parseChunkKey(key);
const dist = Math.max(
Math.abs(coords.x - playerChunk.x),
Math.abs(coords.z - playerChunk.z)
);
if (dist > this.unloadDistance) {
this.unloadQueue.push(key);
}
}
}
}
/**
* Process chunk load queue
*/
async _processLoadQueue() {
if (this._isProcessing) return;
this._isProcessing = true;
const toLoad = this.loadQueue.splice(0, this.loadBatchSize);
for (const key of toLoad) {
if (this.chunks.has(key)) continue;
// Create chunk
const bounds = this.getChunkBounds(key);
const chunk = new Chunk(key, bounds, this.chunkSize);
chunk.state = 'loading';
this.chunks.set(key, chunk);
// Add to scene
if (this.scene) {
this.scene.add(chunk.meshGroup);
}
// Load data
try {
if (this.onChunkLoad) {
const data = await this.onChunkLoad(key);
if (data) {
this._populateChunk(chunk, data);
}
}
chunk.state = 'loaded';
if (this.onChunkCreate) {
this.onChunkCreate(chunk);
}
} catch (error) {
console.error(`Failed to load chunk ${key}:`, error);
chunk.state = 'loaded'; // Mark as loaded even if empty
}
}
this._isProcessing = false;
}
/**
* Process chunk unload queue
*/
_processUnloadQueue() {
// Limit unloads per frame
const toUnload = this.unloadQueue.splice(0, 1);
for (const key of toUnload) {
const chunk = this.chunks.get(key);
if (!chunk) continue;
chunk.state = 'unloading';
// Save if dirty
if (this.onChunkUnload && chunk.isDirty) {
this.onChunkUnload(key, chunk);
}
// Remove from scene
if (this.scene) {
this.scene.remove(chunk.meshGroup);
}
// Dispose resources
chunk.clear();
this.chunks.delete(key);
}
// Force unload if over limit
if (this.chunks.size > this.maxLoadedChunks) {
this._forceTrimChunks();
}
}
/**
* Populate chunk with loaded data
*/
_populateChunk(chunk, data) {
if (data.metadata) {
chunk.metadata = data.metadata;
}
if (data.objects && Array.isArray(data.objects)) {
for (const objData of data.objects) {
// Objects need to be created by the game - we just store position data
const position = new THREE.Vector3(
objData.position.x,
objData.position.y,
objData.position.z
);
// Store raw data for later object creation
chunk.objects.set(objData.id, {
object: objData,
position
});
}
}
}
/**
* Force trim chunks when over limit
*/
_forceTrimChunks() {
const sortedChunks = Array.from(this.chunks.entries())
.filter(([_, chunk]) => chunk.state === 'loaded')
.sort((a, b) => a[1].lastAccess - b[1].lastAccess);
const toRemove = sortedChunks.slice(0, this.chunks.size - this.maxLoadedChunks);
for (const [key] of toRemove) {
if (!this.unloadQueue.includes(key)) {
this.unloadQueue.push(key);
}
}
}
/**
* Update stats
*/
_updateStats() {
let loaded = 0;
let loading = 0;
let totalObjects = 0;
for (const chunk of this.chunks.values()) {
if (chunk.state === 'loaded') loaded++;
if (chunk.state === 'loading') loading++;
totalObjects += chunk.objectCount;
}
this.stats = {
loadedChunks: loaded,
loadingChunks: loading,
queuedLoads: this.loadQueue.length,
totalObjects
};
}
/**
* Get chunk at position (creates if needed)
*/
getChunkAt(position) {
const key = this.worldToChunkKey(position);
return this.chunks.get(key);
}
/**
* Get or create chunk at position
*/
getOrCreateChunk(position) {
const key = this.worldToChunkKey(position);
let chunk = this.chunks.get(key);
if (!chunk) {
const bounds = this.getChunkBounds(key);
chunk = new Chunk(key, bounds, this.chunkSize);
chunk.state = 'loaded';
this.chunks.set(key, chunk);
if (this.scene) {
this.scene.add(chunk.meshGroup);
}
}
return chunk;
}
/**
* Add object to appropriate chunk
*/
addObject(id, object, position) {
const chunk = this.getOrCreateChunk(position);
chunk.addObject(id, object, position);
return chunk;
}
/**
* Remove object from its chunk
*/
removeObject(id, position) {
const chunk = this.getChunkAt(position);
if (chunk) {
return chunk.removeObject(id);
}
return false;
}
/**
* Query objects near position across chunks
*/
queryRadius(position, radius) {
const results = [];
const chunkRadius = Math.ceil(radius / this.chunkSize) + 1;
const centerChunk = this.parseChunkKey(this.worldToChunkKey(position));
for (let dx = -chunkRadius; dx <= chunkRadius; dx++) {
for (let dz = -chunkRadius; dz <= chunkRadius; dz++) {
const key = `${centerChunk.x + dx},${centerChunk.z + dz}`;
const chunk = this.chunks.get(key);
if (chunk && chunk.state === 'loaded') {
const chunkResults = chunk.queryRadius(position, radius);
results.push(...chunkResults);
}
}
}
return results;
}
/**
* Force load a specific chunk
*/
async forceLoadChunk(key) {
if (this.chunks.has(key)) {
return this.chunks.get(key);
}
const bounds = this.getChunkBounds(key);
const chunk = new Chunk(key, bounds, this.chunkSize);
chunk.state = 'loading';
this.chunks.set(key, chunk);
if (this.scene) {
this.scene.add(chunk.meshGroup);
}
if (this.onChunkLoad) {
const data = await this.onChunkLoad(key);
if (data) {
this._populateChunk(chunk, data);
}
}
chunk.state = 'loaded';
return chunk;
}
/**
* Mark chunk as dirty (needs saving)
*/
markDirty(position) {
const chunk = this.getChunkAt(position);
if (chunk) {
chunk.isDirty = true;
}
}
/**
* Save all dirty chunks
*/
saveAllDirty() {
const saved = [];
for (const [key, chunk] of this.chunks) {
if (chunk.isDirty && this.onChunkUnload) {
this.onChunkUnload(key, chunk);
chunk.isDirty = false;
saved.push(key);
}
}
return saved;
}
/**
* Get chunk grid for debug rendering
*/
createDebugGrid(scene, color = 0x4444ff) {
const group = new THREE.Group();
group.name = 'ChunkDebugGrid';
const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.3 });
for (const [key] of this.chunks) {
const bounds = this.getChunkBounds(key);
const geometry = new THREE.BoxGeometry(
this.chunkSize,
this.worldHeight,
this.chunkSize
);
const edges = new THREE.EdgesGeometry(geometry);
const line = new THREE.LineSegments(edges, material);
line.position.set(
(bounds.min.x + bounds.max.x) / 2,
this.worldHeight / 2,
(bounds.min.z + bounds.max.z) / 2
);
group.add(line);
}
scene.add(group);
return group;
}
/**
* Clear all chunks
*/
clear() {
for (const [key, chunk] of this.chunks) {
if (this.scene) {
this.scene.remove(chunk.meshGroup);
}
chunk.clear();
}
this.chunks.clear();
this.loadQueue = [];
this.unloadQueue = [];
this._lastPlayerChunk = null;
}
}
export default ChunkManager;
/**
* ClientPrediction - Optimistic placement with rollback for responsive building
*
* Players expect immediate feedback when placing. With network latency,
* waiting for server confirmation feels sluggish. This system predicts
* placement locally and reconciles with server responses.
*
* Usage:
* const prediction = new ClientPrediction(buildingSystem, networkManager);
* const localPiece = prediction.predictPlace(pieceType, position, rotation);
* // When server responds:
* prediction.onServerConfirm(tempId, serverId, success);
*/
import * as THREE from 'three';
/**
* Prediction states
*/
export const PredictionState = {
PENDING: 'pending', // Waiting for server response
CONFIRMED: 'confirmed', // Server accepted
REJECTED: 'rejected', // Server rejected
TIMEOUT: 'timeout', // No response in time
CORRECTED: 'corrected' // Accepted but position adjusted
};
/**
* Individual prediction record
*/
export class PredictionRecord {
constructor(tempId, pieceData, options = {}) {
this.tempId = tempId;
this.pieceData = pieceData;
this.localPiece = null;
this.state = PredictionState.PENDING;
this.serverId = null;
this.serverData = null;
this.error = null;
this.createdAt = Date.now();
this.resolvedAt = null;
this.timeout = options.timeout ?? 5000;
this.retryCount = 0;
this.maxRetries = options.maxRetries ?? 2;
// Callbacks
this.onConfirm = options.onConfirm ?? null;
this.onReject = options.onReject ?? null;
this.onTimeout = options.onTimeout ?? null;
}
/**
* Check if prediction has timed out
*/
isTimedOut() {
return this.state === PredictionState.PENDING &&
Date.now() - this.createdAt > this.timeout;
}
/**
* Mark as confirmed
*/
confirm(serverId, serverData = null) {
this.state = serverData?.corrected
? PredictionState.CORRECTED
: PredictionState.CONFIRMED;
this.serverId = serverId;
this.serverData = serverData;
this.resolvedAt = Date.now();
if (this.onConfirm) {
this.onConfirm(this);
}
}
/**
* Mark as rejected
*/
reject(error) {
this.state = PredictionState.REJECTED;
this.error = error;
this.resolvedAt = Date.now();
if (this.onReject) {
this.onReject(this);
}
}
/**
* Mark as timed out
*/
markTimeout() {
this.state = PredictionState.TIMEOUT;
this.resolvedAt = Date.now();
if (this.onTimeout) {
this.onTimeout(this);
}
}
/**
* Get latency (request to response time)
*/
getLatency() {
if (!this.resolvedAt) return null;
return this.resolvedAt - this.createdAt;
}
}
/**
* Ghost piece for visual prediction
*/
export class GhostPiece {
constructor(piece, options = {}) {
this.piece = piece;
this.isPredicted = true;
this.opacity = options.opacity ?? 0.7;
this.pulseSpeed = options.pulseSpeed ?? 2;
this.pulseAmount = options.pulseAmount ?? 0.2;
// Visual state
this.baseOpacity = this.opacity;
this.currentOpacity = this.opacity;
this.time = 0;
// Apply ghost appearance
this.applyGhostMaterial();
}
/**
* Make piece look like a ghost/prediction
*/
applyGhostMaterial() {
const mesh = this.piece.mesh || this.piece;
if (!mesh.material) return;
// Clone material to avoid affecting others
if (!mesh._originalMaterial) {
mesh._originalMaterial = mesh.material;
mesh.material = mesh.material.clone();
}
mesh.material.transparent = true;
mesh.material.opacity = this.opacity;
// Slight color tint to indicate prediction
if (mesh.material.color) {
mesh.material.color.multiplyScalar(1.1);
}
}
/**
* Update ghost animation
*/
update(deltaTime) {
this.time += deltaTime;
// Pulse opacity
const pulse = Math.sin(this.time * this.pulseSpeed) * this.pulseAmount;
this.currentOpacity = this.baseOpacity + pulse;
const mesh = this.piece.mesh || this.piece;
if (mesh.material) {
mesh.material.opacity = Math.max(0.3, Math.min(0.9, this.currentOpacity));
}
}
/**
* Convert to solid (confirmed) piece
*/
solidify() {
const mesh = this.piece.mesh || this.piece;
if (mesh._originalMaterial) {
mesh.material = mesh._originalMaterial;
delete mesh._originalMaterial;
} else if (mesh.material) {
mesh.material.transparent = false;
mesh.material.opacity = 1.0;
}
this.isPredicted = false;
}
/**
* Fade out and remove
*/
fadeOut(duration = 300) {
return new Promise((resolve) => {
const mesh = this.piece.mesh || this.piece;
const startOpacity = mesh.material?.opacity ?? 1;
const startTime = performance.now();
const animate = () => {
const elapsed = performance.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
if (mesh.material) {
mesh.material.opacity = startOpacity * (1 - progress);
}
if (progress < 1) {
requestAnimationFrame(animate);
} else {
resolve();
}
};
animate();
});
}
}
/**
* Main client prediction system
*/
export class ClientPrediction {
constructor(buildingSystem, options = {}) {
this.buildingSystem = buildingSystem;
// Configuration
this.predictionTimeout = options.predictionTimeout ?? 5000;
this.maxPendingPredictions = options.maxPendingPredictions ?? 10;
this.enableGhostVisuals = options.enableGhostVisuals ?? true;
this.autoRetry = options.autoRetry ?? true;
this.maxRetries = options.maxRetries ?? 2;
// State
this.predictions = new Map(); // tempId -> PredictionRecord
this.ghostPieces = new Map(); // tempId -> GhostPiece
this.tempIdCounter = 0;
this.serverIdMap = new Map(); // tempId -> serverId (for reconciliation)
// Network interface (set by network manager)
this.sendToServer = options.sendToServer ?? null;
// Callbacks
this.onPredictionConfirmed = options.onPredictionConfirmed ?? null;
this.onPredictionRejected = options.onPredictionRejected ?? null;
this.onPredictionTimeout = options.onPredictionTimeout ?? null;
// Statistics
this.stats = {
totalPredictions: 0,
confirmed: 0,
rejected: 0,
timeouts: 0,
corrections: 0,
avgLatency: 0,
latencySum: 0
};
}
/**
* Generate unique temporary ID
*/
generateTempId() {
return `pred_${Date.now()}_${++this.tempIdCounter}`;
}
/**
* Predict a piece placement
*/
predictPlace(pieceType, position, rotation, options = {}) {
// Check prediction limit
if (this.predictions.size >= this.maxPendingPredictions) {
console.warn('Too many pending predictions');
return null;
}
const tempId = this.generateTempId();
// Create piece data
const pieceData = {
type: pieceType,
position: position.clone(),
rotation: rotation?.clone() ?? new THREE.Euler(),
material: options.material ?? 'default',
tempId
};
// Create local piece immediately
const localPiece = this.buildingSystem.createPiece(pieceData);
localPiece.id = tempId;
localPiece.isPredicted = true;
// Add to building system (but mark as predicted)
this.buildingSystem.addPredictedPiece(localPiece);
// Create prediction record
const record = new PredictionRecord(tempId, pieceData, {
timeout: this.predictionTimeout,
maxRetries: this.maxRetries,
onConfirm: (r) => this.handleConfirm(r),
onReject: (r) => this.handleReject(r),
onTimeout: (r) => this.handleTimeout(r)
});
record.localPiece = localPiece;
this.predictions.set(tempId, record);
this.stats.totalPredictions++;
// Create ghost visual
if (this.enableGhostVisuals) {
const ghost = new GhostPiece(localPiece);
this.ghostPieces.set(tempId, ghost);
}
// Send to server
if (this.sendToServer) {
this.sendToServer({
type: 'place_request',
tempId,
pieceType,
position: this.serializeVector(position),
rotation: this.serializeRotation(rotation),
material: options.material
});
}
return localPiece;
}
/**
* Predict piece destruction
*/
predictDestroy(piece) {
const tempId = this.generateTempId();
const pieceId = piece.id;
// Remove locally immediately
const removedPiece = this.buildingSystem.removePiece(piece);
// Create prediction record
const record = new PredictionRecord(tempId, {
action: 'destroy',
pieceId,
removedPiece
}, {
timeout: this.predictionTimeout,
onReject: (r) => {
// Rollback: restore the piece
if (r.pieceData.removedPiece) {
this.buildingSystem.addPiece(r.pieceData.removedPiece);
}
}
});
this.predictions.set(tempId, record);
// Send to server
if (this.sendToServer) {
this.sendToServer({
type: 'destroy_request',
tempId,
pieceId
});
}
return tempId;
}
/**
* Handle server confirmation
*/
onServerConfirm(tempId, serverId, serverData = null) {
const record = this.predictions.get(tempId);
if (!record) return;
record.confirm(serverId, serverData);
}
/**
* Handle server rejection
*/
onServerReject(tempId, error) {
const record = this.predictions.get(tempId);
if (!record) return;
record.reject(error);
}
/**
* Internal confirm handler
*/
handleConfirm(record) {
const { tempId, serverId, serverData, localPiece } = record;
// Update piece ID
if (localPiece) {
localPiece.id = serverId;
localPiece.isPredicted = false;
// Apply any server corrections
if (serverData?.position) {
const pos = this.deserializeVector(serverData.position);
if (!localPiece.position.equals(pos)) {
localPiece.position.copy(pos);
this.stats.corrections++;
}
}
// Register with building system under real ID
this.buildingSystem.confirmPredictedPiece(tempId, serverId);
}
// Solidify ghost
const ghost = this.ghostPieces.get(tempId);
if (ghost) {
ghost.solidify();
this.ghostPieces.delete(tempId);
}
// Track mapping
this.serverIdMap.set(tempId, serverId);
// Update stats
this.stats.confirmed++;
const latency = record.getLatency();
if (latency) {
this.stats.latencySum += latency;
this.stats.avgLatency = this.stats.latencySum / this.stats.confirmed;
}
// Clean up
this.predictions.delete(tempId);
// Callback
if (this.onPredictionConfirmed) {
this.onPredictionConfirmed(record);
}
}
/**
* Internal reject handler
*/
handleReject(record) {
const { tempId, localPiece, error } = record;
// Remove predicted piece
if (localPiece) {
this.buildingSystem.removePredictedPiece(tempId);
}
// Fade out ghost
const ghost = this.ghostPieces.get(tempId);
if (ghost) {
ghost.fadeOut().then(() => {
this.ghostPieces.delete(tempId);
});
}
// Update stats
this.stats.rejected++;
// Clean up
this.predictions.delete(tempId);
// Callback
if (this.onPredictionRejected) {
this.onPredictionRejected(record, error);
}
}
/**
* Internal timeout handler
*/
handleTimeout(record) {
const { tempId, localPiece } = record;
// Retry if enabled
if (this.autoRetry && record.retryCount < record.maxRetries) {
record.retryCount++;
record.createdAt = Date.now();
record.state = PredictionState.PENDING;
// Resend request
if (this.sendToServer) {
this.sendToServer({
type: 'place_request',
tempId,
pieceType: record.pieceData.type,
position: this.serializeVector(record.pieceData.position),
rotation: this.serializeRotation(record.pieceData.rotation),
retry: record.retryCount
});
}
return;
}
// Remove predicted piece
if (localPiece) {
this.buildingSystem.removePredictedPiece(tempId);
}
// Remove ghost
const ghost = this.ghostPieces.get(tempId);
if (ghost) {
ghost.fadeOut().then(() => {
this.ghostPieces.delete(tempId);
});
}
// Update stats
this.stats.timeouts++;
// Clean up
this.predictions.delete(tempId);
// Callback
if (this.onPredictionTimeout) {
this.onPredictionTimeout(record);
}
}
/**
* Update predictions - call every frame
*/
update(deltaTime) {
// Check for timeouts
for (const [tempId, record] of this.predictions) {
if (record.isTimedOut()) {
record.markTimeout();
}
}
// Update ghost visuals
for (const ghost of this.ghostPieces.values()) {
ghost.update(deltaTime);
}
}
/**
* Get pending prediction count
*/
getPendingCount() {
return this.predictions.size;
}
/**
* Check if a piece is predicted
*/
isPredicted(pieceOrId) {
const id = pieceOrId.id ?? pieceOrId;
return this.predictions.has(id);
}
/**
* Get real server ID for a temp ID
*/
getServerId(tempId) {
return this.serverIdMap.get(tempId);
}
/**
* Reconcile with server state (after full sync)
*/
reconcileWithServerState(serverPieces) {
// Remove any predictions that conflict with server state
for (const [tempId, record] of this.predictions) {
const localPiece = record.localPiece;
if (!localPiece) continue;
// Check if server has a piece at same position
const conflicting = serverPieces.find(sp =>
this.vectorsEqual(sp.position, localPiece.position)
);
if (conflicting) {
// Server has authoritative piece - remove prediction
this.handleReject(record);
}
}
}
/**
* Serialize vector for network
*/
serializeVector(v) {
return { x: v.x, y: v.y, z: v.z };
}
/**
* Deserialize vector from network
*/
deserializeVector(data) {
return new THREE.Vector3(data.x, data.y, data.z);
}
/**
* Serialize rotation for network
*/
serializeRotation(r) {
if (!r) return { y: 0 };
return { x: r.x, y: r.y, z: r.z };
}
/**
* Compare vectors
*/
vectorsEqual(a, b, tolerance = 0.01) {
if (!a || !b) return false;
return (
Math.abs(a.x - b.x) < tolerance &&
Math.abs(a.y - b.y) < tolerance &&
Math.abs(a.z - b.z) < tolerance
);
}
/**
* Get statistics
*/
getStats() {
return {
...this.stats,
pending: this.predictions.size,
ghosts: this.ghostPieces.size,
confirmRate: this.stats.totalPredictions > 0
? Math.round(this.stats.confirmed / this.stats.totalPredictions * 100) + '%'
: 'N/A',
avgLatencyMs: Math.round(this.stats.avgLatency)
};
}
/**
* Reset statistics
*/
resetStats() {
this.stats = {
totalPredictions: 0,
confirmed: 0,
rejected: 0,
timeouts: 0,
corrections: 0,
avgLatency: 0,
latencySum: 0
};
}
/**
* Clear all predictions
*/
clear() {
// Remove all predicted pieces
for (const [tempId, record] of this.predictions) {
if (record.localPiece) {
this.buildingSystem.removePredictedPiece(tempId);
}
}
this.predictions.clear();
this.ghostPieces.clear();
this.serverIdMap.clear();
}
}
export default ClientPrediction;
/**
* PerformanceProfiler - Benchmarking utilities for building systems
*
* Measures frame time, spatial query performance, memory usage,
* and helps identify bottlenecks in building mechanics.
*
* Usage:
* const profiler = new PerformanceProfiler();
* profiler.startFrame();
* // ... game logic ...
* profiler.endFrame();
* console.log(profiler.getReport());
*/
export class PerformanceProfiler {
constructor(options = {}) {
this.historySize = options.historySize ?? 120; // ~2 seconds at 60fps
this.warnThresholds = {
frameTime: options.frameTimeWarn ?? 16.67, // 60fps
queryTime: options.queryTimeWarn ?? 1, // 1ms per query
memoryMB: options.memoryWarn ?? 500 // 500MB
};
this.frameHistory = [];
this.queryHistory = [];
this.customTimers = new Map();
this.counters = new Map();
this._frameStart = 0;
this._currentFrame = null;
this._enabled = true;
}
/**
* Enable/disable profiling
*/
setEnabled(enabled) {
this._enabled = enabled;
}
/**
* Start frame measurement
*/
startFrame() {
if (!this._enabled) return;
this._frameStart = performance.now();
this._currentFrame = {
startTime: this._frameStart,
sections: {},
queries: []
};
}
/**
* End frame measurement
*/
endFrame() {
if (!this._enabled || !this._currentFrame) return;
const frameTime = performance.now() - this._frameStart;
this._currentFrame.totalTime = frameTime;
this.frameHistory.push(this._currentFrame);
if (this.frameHistory.length > this.historySize) {
this.frameHistory.shift();
}
this._currentFrame = null;
return frameTime;
}
/**
* Start timing a named section
*/
startSection(name) {
if (!this._enabled || !this._currentFrame) return;
this._currentFrame.sections[name] = {
start: performance.now(),
end: null
};
}
/**
* End timing a named section
*/
endSection(name) {
if (!this._enabled || !this._currentFrame) return;
const section = this._currentFrame.sections[name];
if (section) {
section.end = performance.now();
section.duration = section.end - section.start;
}
}
/**
* Time a function execution
*/
time(name, fn) {
if (!this._enabled) return fn();
const start = performance.now();
const result = fn();
const duration = performance.now() - start;
if (this._currentFrame) {
this._currentFrame.sections[name] = { duration };
}
return result;
}
/**
* Time an async function execution
*/
async timeAsync(name, fn) {
if (!this._enabled) return fn();
const start = performance.now();
const result = await fn();
const duration = performance.now() - start;
if (this._currentFrame) {
this._currentFrame.sections[name] = { duration };
}
return result;
}
/**
* Record a spatial query for analysis
*/
recordQuery(type, candidateCount, resultCount, timeMs) {
if (!this._enabled) return;
const query = { type, candidateCount, resultCount, timeMs };
if (this._currentFrame) {
this._currentFrame.queries.push(query);
}
this.queryHistory.push(query);
if (this.queryHistory.length > this.historySize * 10) {
this.queryHistory.shift();
}
}
/**
* Increment a counter
*/
count(name, amount = 1) {
if (!this._enabled) return;
const current = this.counters.get(name) || 0;
this.counters.set(name, current + amount);
}
/**
* Reset a counter
*/
resetCount(name) {
this.counters.set(name, 0);
}
/**
* Get average frame time
*/
getAverageFrameTime() {
if (this.frameHistory.length === 0) return 0;
const sum = this.frameHistory.reduce((acc, f) => acc + f.totalTime, 0);
return sum / this.frameHistory.length;
}
/**
* Get current FPS estimate
*/
getFPS() {
const avgFrameTime = this.getAverageFrameTime();
return avgFrameTime > 0 ? 1000 / avgFrameTime : 0;
}
/**
* Get section timing statistics
*/
getSectionStats(sectionName) {
const times = this.frameHistory
.map(f => f.sections[sectionName]?.duration)
.filter(t => t !== undefined);
if (times.length === 0) {
return { avg: 0, min: 0, max: 0, count: 0 };
}
return {
avg: times.reduce((a, b) => a + b, 0) / times.length,
min: Math.min(...times),
max: Math.max(...times),
count: times.length
};
}
/**
* Get query statistics
*/
getQueryStats() {
if (this.queryHistory.length === 0) {
return { avgTime: 0, avgCandidates: 0, avgResults: 0, count: 0 };
}
const stats = {
avgTime: 0,
avgCandidates: 0,
avgResults: 0,
count: this.queryHistory.length,
byType: {}
};
for (const q of this.queryHistory) {
stats.avgTime += q.timeMs;
stats.avgCandidates += q.candidateCount;
stats.avgResults += q.resultCount;
if (!stats.byType[q.type]) {
stats.byType[q.type] = { time: 0, count: 0 };
}
stats.byType[q.type].time += q.timeMs;
stats.byType[q.type].count++;
}
stats.avgTime /= stats.count;
stats.avgCandidates /= stats.count;
stats.avgResults /= stats.count;
for (const type in stats.byType) {
stats.byType[type].avgTime = stats.byType[type].time / stats.byType[type].count;
}
return stats;
}
/**
* Get memory usage (if available)
*/
getMemoryUsage() {
if (performance.memory) {
return {
usedHeapMB: performance.memory.usedJSHeapSize / 1024 / 1024,
totalHeapMB: performance.memory.totalJSHeapSize / 1024 / 1024,
limitMB: performance.memory.jsHeapSizeLimit / 1024 / 1024
};
}
return null;
}
/**
* Get comprehensive report
*/
getReport() {
const avgFrameTime = this.getAverageFrameTime();
const fps = this.getFPS();
const memory = this.getMemoryUsage();
const queryStats = this.getQueryStats();
// Gather all section names
const sectionNames = new Set();
for (const frame of this.frameHistory) {
for (const name of Object.keys(frame.sections)) {
sectionNames.add(name);
}
}
const sections = {};
for (const name of sectionNames) {
sections[name] = this.getSectionStats(name);
}
// Warnings
const warnings = [];
if (avgFrameTime > this.warnThresholds.frameTime) {
warnings.push(`Frame time ${avgFrameTime.toFixed(2)}ms exceeds ${this.warnThresholds.frameTime}ms threshold`);
}
if (queryStats.avgTime > this.warnThresholds.queryTime) {
warnings.push(`Query time ${queryStats.avgTime.toFixed(2)}ms exceeds ${this.warnThresholds.queryTime}ms threshold`);
}
if (memory && memory.usedHeapMB > this.warnThresholds.memoryMB) {
warnings.push(`Memory usage ${memory.usedHeapMB.toFixed(0)}MB exceeds ${this.warnThresholds.memoryMB}MB threshold`);
}
return {
fps: Math.round(fps),
frameTime: {
avg: avgFrameTime,
min: Math.min(...this.frameHistory.map(f => f.totalTime)),
max: Math.max(...this.frameHistory.map(f => f.totalTime))
},
sections,
queries: queryStats,
memory,
counters: Object.fromEntries(this.counters),
warnings,
frameCount: this.frameHistory.length
};
}
/**
* Format report as string for console
*/
formatReport() {
const report = this.getReport();
const lines = [
`=== Performance Report ===`,
`FPS: ${report.fps} (${report.frameTime.avg.toFixed(2)}ms avg)`,
`Frame Range: ${report.frameTime.min.toFixed(2)}ms - ${report.frameTime.max.toFixed(2)}ms`,
``
];
if (Object.keys(report.sections).length > 0) {
lines.push(`Sections:`);
for (const [name, stats] of Object.entries(report.sections)) {
lines.push(` ${name}: ${stats.avg.toFixed(2)}ms avg (${stats.min.toFixed(2)}-${stats.max.toFixed(2)})`);
}
lines.push(``);
}
if (report.queries.count > 0) {
lines.push(`Queries: ${report.queries.count} total`);
lines.push(` Avg Time: ${report.queries.avgTime.toFixed(3)}ms`);
lines.push(` Avg Candidates: ${report.queries.avgCandidates.toFixed(0)}`);
lines.push(` Avg Results: ${report.queries.avgResults.toFixed(0)}`);
lines.push(``);
}
if (report.memory) {
lines.push(`Memory: ${report.memory.usedHeapMB.toFixed(0)}MB / ${report.memory.limitMB.toFixed(0)}MB`);
lines.push(``);
}
if (Object.keys(report.counters).length > 0) {
lines.push(`Counters:`);
for (const [name, value] of Object.entries(report.counters)) {
lines.push(` ${name}: ${value}`);
}
lines.push(``);
}
if (report.warnings.length > 0) {
lines.push(`⚠️ Warnings:`);
for (const warning of report.warnings) {
lines.push(` - ${warning}`);
}
}
return lines.join('\n');
}
/**
* Reset all data
*/
reset() {
this.frameHistory = [];
this.queryHistory = [];
this.counters.clear();
this._currentFrame = null;
}
/**
* Create on-screen debug display
*/
createOverlay(container = document.body) {
const overlay = document.createElement('div');
overlay.id = 'perf-overlay';
overlay.style.cssText = `
position: fixed;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: #0f0;
font-family: monospace;
font-size: 12px;
padding: 10px;
border-radius: 4px;
z-index: 10000;
pointer-events: none;
white-space: pre;
`;
container.appendChild(overlay);
const update = () => {
if (!this._enabled) {
overlay.style.display = 'none';
return;
}
overlay.style.display = 'block';
const report = this.getReport();
const fpsColor = report.fps >= 55 ? '#0f0' : report.fps >= 30 ? '#ff0' : '#f00';
overlay.innerHTML = `
<span style="color: ${fpsColor}">FPS: ${report.fps}</span>
Frame: ${report.frameTime.avg.toFixed(1)}ms
${report.memory ? `Mem: ${report.memory.usedHeapMB.toFixed(0)}MB` : ''}
${report.queries.count > 0 ? `Queries: ${report.queries.avgTime.toFixed(2)}ms avg` : ''}
${report.warnings.length > 0 ? `<span style="color: #f00">⚠ ${report.warnings.length} warnings</span>` : ''}
`.trim();
};
// Update every 100ms
const intervalId = setInterval(update, 100);
return {
element: overlay,
destroy: () => {
clearInterval(intervalId);
overlay.remove();
}
};
}
}
/**
* Benchmark utility for comparing implementations
*/
export class Benchmark {
constructor(name) {
this.name = name;
this.results = [];
}
/**
* Run a function multiple times and record results
*/
run(fn, iterations = 1000, warmup = 100) {
// Warmup
for (let i = 0; i < warmup; i++) {
fn();
}
// Actual benchmark
const times = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
fn();
times.push(performance.now() - start);
}
const result = {
iterations,
total: times.reduce((a, b) => a + b, 0),
avg: times.reduce((a, b) => a + b, 0) / times.length,
min: Math.min(...times),
max: Math.max(...times),
median: this._median(times),
p95: this._percentile(times, 95),
p99: this._percentile(times, 99)
};
this.results.push(result);
return result;
}
/**
* Compare multiple implementations
*/
static compare(benchmarks) {
const results = [];
for (const { name, fn, iterations } of benchmarks) {
const bench = new Benchmark(name);
const result = bench.run(fn, iterations);
results.push({ name, ...result });
}
// Sort by avg time
results.sort((a, b) => a.avg - b.avg);
// Calculate relative performance
const baseline = results[0].avg;
for (const result of results) {
result.relative = result.avg / baseline;
}
return results;
}
/**
* Format comparison results
*/
static formatComparison(results) {
const lines = ['=== Benchmark Comparison ===', ''];
for (const r of results) {
lines.push(`${r.name}:`);
lines.push(` Avg: ${r.avg.toFixed(4)}ms (${r.relative.toFixed(2)}x)`);
lines.push(` Min: ${r.min.toFixed(4)}ms, Max: ${r.max.toFixed(4)}ms`);
lines.push(` P95: ${r.p95.toFixed(4)}ms, P99: ${r.p99.toFixed(4)}ms`);
lines.push('');
}
return lines.join('\n');
}
_median(arr) {
const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
_percentile(arr, p) {
const sorted = [...arr].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
}
/**
* Wrapper to profile spatial structure operations
*/
export function profileSpatialStructure(structure, profiler) {
return {
insert: (object, position) => {
profiler.startSection('insert');
const result = structure.insert(object, position);
profiler.endSection('insert');
return result;
},
remove: (object) => {
profiler.startSection('remove');
const result = structure.remove(object);
profiler.endSection('remove');
return result;
},
queryRadius: (position, radius) => {
const start = performance.now();
profiler.startSection('queryRadius');
const results = structure.queryRadius(position, radius);
profiler.endSection('queryRadius');
const timeMs = performance.now() - start;
profiler.recordQuery('radius', structure.objectCount || 0, results.length, timeMs);
return results;
},
queryBounds: (bounds) => {
const start = performance.now();
profiler.startSection('queryBounds');
const results = structure.queryBounds(bounds);
profiler.endSection('queryBounds');
const timeMs = performance.now() - start;
profiler.recordQuery('bounds', structure.objectCount || 0, results.length, timeMs);
return results;
}
};
}
export default PerformanceProfiler;
Related skills
FAQ
What physics modes are supported?
Arcade, heuristic, and realistic structural validation modes.
How does it handle scale?
Spatial hash grids, octrees, and chunk loading for fast queries.