Engineering Research Journal & Technical Dev-Log: Synchronous Multi-Agent WebGL Simulation, Mobile Aerodynamics, and Real-Time State Replication
Abstract
This paper documents the design, iterative failure analysis, and architectural optimization of a browser-based 3D multi-agent interactive environment built atop Three.js (WebGL), Node.js/Socket.IO, and an embedded real-time level design framework (BuildSystem).
We examine the engineering hurdles encountered across several core subsystems:
- Low-latency mobile gesture recognition and aerodynamic flight modeling.
- Multi-agent state synchronization, asynchronous asset-loading race conditions, and spatial mesh visibility traps.
- Raycast-driven collision mechanics, self-intersection stuttering, and frame-rate independent camera tracking.
- Persistent world state management versus runtime vehicle entity lifecycles.
- DOM/WebGL event-loop conflicts in mobile touch-action propagation.
System Architecture Overview
code Code
+------------------------------------------+
| Node.js Server |
| (Socket.IO Hub, Persistence, Disconnect) |
+--------------------+---------------------+
|
WebSocket Protocol (Full Duplex)
|
+------------------------+------------------------+
| |
+------------v------------+ +------------v------------+
| Client A (Local) | | Client B (Remote) |
| +---------------------+ | | +---------------------+ |
| | Input Engine | | | | Network Receiver | |
| | (Touch / Keyboard) | | | | (Packet Queuing) | |
| +----------+----------+ | | +----------+----------+ |
| | | | | |
| +----------v----------+ | | +----------v----------+ |
| | Kinematic Engine | | | | Scene Graph Sync | |
| | (Physics / Aero) | | | | (Entity Lifecycle) | |
| +----------+----------+ | | +----------+----------+ |
| | | | | |
| +----------v----------+ | | +----------v----------+ |
| | Local Render Tree | | | | Remote Render Tree | |
| | (Scene Graph) | | | | (Scene Graph) | |
| +---------------------+ | | +---------------------+ |
+-------------------------+ +-------------------------+
Section 1: Mobile Touch Mapping & Kinematic Aerodynamics
1.1 The Initial Failure: Control Disconnect & Flight Impossibility
In desktop mode, aircraft navigation operated via discrete key codes (W, A, S, D, Space, Shift). When mobile touch handlers were initially introduced:
- Touch movements (touchmove) applied angular displacement directly to localPlayer.rotation.y.
- When boarded into an aircraft (isDriving = true), localPlayer was disabled and hidden. The touch inputs mutated the hidden human entity while the physical aircraft (currentVehicle) remained unresponsive.
- Vertical swipes emitted momentary pulses (
120 ms120 ms) to keys[‘ ‘], causing aerodynamic pitch jitter (0.28 rad→0 rad0.28 rad→0 radper frame) rather than sustained lift.
code Code
[Touch Drag] ---> Mutated: localPlayer.rotation.y (Hidden Entity)
Ignored: currentVehicle.rotation.y (Active Entity)
Result: Complete loss of directional steering on mobile
1.2 The Aerodynamic Physics Model
We implemented a velocity-coupled aerodynamic kinematic model. Horizontal displacement governs wing bank (
ϕϕ
) and yaw (
ψψ
). Vertical gestures control angle of attack (
θθ
).
v⃗forward=u⃗heading⋅vairvforward=uheading⋅vair
Δy=vair⋅sin(θ)⋅ΔtΔy=vair⋅sin(θ)⋅Δt
ϕtarget=−clamp(Δxtouch90,−1.2,1.2)⋅0.45ϕtarget=−clamp(90Δxtouch,−1.2,1.2)⋅0.45
θclimb=lerp(θ,θtarget,Δt⋅3.5)θclimb=lerp(θ,θtarget,Δt⋅3.5)
code JavaScript
// REVISION 1: Unified Entity-Aware Steering Engine
if (isStarted && !isHoldingMic && !build.isEditMode) {
let moveDx = touch.clientX - touchStartX;
if (isDriving) {
// Continuous Steering: preserves forward momentum during lateral drags
if (Math.abs(totalDx) > 12) {
touchSteer = THREE.MathUtils.clamp(-totalDx / 90, -1.2, 1.2);
} else {
touchSteer = 0;
}
// Sustained Angle of Attack control
if (isAirplane && isAirborne) {
if (totalDy > 35) {
touchHoldingClimb = true;
touchHoldingDive = false;
} else if (totalDy < -35) {
touchHoldingDive = true;
touchHoldingClimb = false;
} else {
touchHoldingClimb = false;
touchHoldingDive = false;
}
}
} else if (localPlayer) {
localPlayer.rotation.y -= moveDx * 0.012;
}
touchStartX = touch.clientX;
}
Section 2: Input State Collision & The Walking Regression
2.1 The Problem
When implementing vehicle entry via touch, broad tap handlers intercepted gestures globally. A double-tap anywhere on the viewport evaluated vehicle bounding checks against global spatial coordinates. If a user double-tapped on open terrain to engage sprint, they were abruptly teleported into a distant vehicle. Conversely, tapping to walk on foot failed because vehicle entry raycasts consumed the tap payload.
2.2 The Solution: Raycast Isolation & Bounding Validation
Vehicle entry was refactored into a strict two-stage predicate:
- Raycast collision directly intersects an object whose root ancestor belongs to build.buildGroup with userData.isVehicle === true.
- The Euclidean distance between localPlayer.position and targetVehicle.position satisfies
d≤8.0 metersd≤8.0 meters.
code Code
[Screen Tap Event]
|
Raycast Intersect?
/ \
(Yes) (No)
/ \
Is userData.isVehicle? Toggle Standard Walk
/ \ (Single: Walk / Double: Run)
(Yes) (No)
/ \
Distance <= 8m? Toggle Standard Walk
/ \
(Yes) (No)
| \
Board Toggle Standard Walk
Vehicle
code JavaScript
// REVISION 2: Spatial Raycast Verification
let tappedVehicle = null;
if (!isTouchMoving) {
mouse.set((touch.clientX / window.innerWidth) * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1);
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(build.buildGroup.children, true);
if (hits.length > 0) {
let obj = hits[0].object;
while (obj.parent && obj.parent !== build.buildGroup) obj = obj.parent;
if (obj.userData && obj.userData.isVehicle && localPlayer.position.distanceTo(obj.position) < 8.0) {
tappedVehicle = obj;
}
}
}
if (tappedVehicle) {
toggleVehicle(tappedVehicle);
} else {
// Retain default walking / sprint toggle
if (isDoubleTap) {
keys['w'] = true;
keys['shift'] = !keys['shift'];
} else {
keys['w'] = !keys['w'];
if (!keys['w']) keys['shift'] = false;
}
}
Section 3: Aerodynamic Pitch Stepping vs. Gimbal Lock
3.1 The Gimbal Lock & Rotation Glitch
When users steered horizontally while pitching upward, aircraft meshes exhibited unpredictable inverted flips and axis locking.
- Cause: Default Three.js Euler evaluation order is ‘XYZ’. Computing
Roll(z)Roll(z)afterPitch(x)Pitch(x)with an activeYaw(y)Yaw(y)introduces kinematic coupling where pitch rotations skew the local roll axis. - Solution: Switched the rotation evaluation order to ‘YXZ’. Yaw is applied first globally, followed by pitch around the local transverse axis, and finally roll around the longitudinal axis.
code JavaScript
activeVeh.rotation.order = 'YXZ';
3.2 Discrete Elevation Nudging vs. Continuous Angle of Attack
Flight controls must distinguish between a quick flick to change altitude and a sustained pull-up.
code JavaScript
// Quick Flick vs. Sustained Flight Stick Pull
if (isAirplane && isAirborne) {
if (touchDuration < 300) {
if (totalDy > 40) {
currentVehicle.position.y += 1.8; // Step Altitude Climb
} else if (totalDy < -40) {
currentVehicle.position.y = Math.max(1.5, currentVehicle.position.y - 1.8); // Step Dive
}
}
touchHoldingClimb = false;
touchHoldingDive = false;
}
Section 4: Scale Desynchronization in Multiplayer Replicas
4.1 Root Cause Analysis
Vehicles scaled up within Build Mode (e.g., scale factor
s=2.2s=2.2
) preserved their scale locally inside map_objects.json. However, when a player boarded a vehicle:
- socket.emit(‘move’) emitted position and yaw coordinates, omitting vehicleScale.
- Remote clients received the update, instantiated the GLTF asset, and passed it through build.normalizeModel(obj, 2.5), which hard-clamped the vehicle’s bounding box to
2.5 units2.5 units. - Result: The driver appeared to ride a vehicle that shrank to miniature dimensions on other screens.
code Code
[Driver Client] [Remote Client]
Scale = 2.2 (Custom Edit Mode) Network Packet: { pos, rot, isDriving } (Missing Scale)
| |
v v
Broadcasting ... GLTF Instantiated -> Clamped to 2.5
Result: Scaled down to miniature size
4.2 Code Implementation
We explicitly embedded scalar matrices into the network serialization protocol:
code JavaScript
// Networking Payload Serialization (index.html)
socket.emit('move', {
pos: activeControl.position,
rot: activeControl.rotation.y,
rotX: activeControl.rotation.x || 0,
rotZ: activeControl.rotation.z || 0,
anim: anim,
isDriving: isDriving,
vehicleId: (isDriving && currentVehicle) ? currentVehicle.userData.id : null,
vehicleScale: (isDriving && currentVehicle) ? (currentVehicle.scale.x || 1.0) : 1.0
});
Section 5: Kinematic Stutter & Self-Intersecting Raycasts
5.1 The Root Cause of Forward Translation Jitter on [W]
When driving on the ground, holding W produced severe, high-frequency stuttering.
Three compounding issues caused this behavior:
- Raycast Self-Intersection: The 8-directional horizontal collision sweep (isBlocked) cast rays from
(x,y+0.6,z)(x,y+0.6,z). Because vehicle geometries possess large bounding radii, the collision rays struck the car’s own wheels and body panels, registering a false-positive obstacle distanced<1.3 md<1.3 m. The engine repeatedly reset activeControl.position to oldX and oldZ on alternating frames. - Raw Ground Snapping: Vertical elevation (position.y) was set directly to raw terrain raycast hits without smoothing:
position.y=targetGroundposition.y=targetGround
Micro-variations across polygonal terrain triangles caused the entity to vibrate vertically. - Frame-Rate Dependent Camera Latching: The third-person chase camera interpolated position using fixed frame-rate scalars:
P⃗cam=lerp(P⃗cam,P⃗target,0.1)Pcam=lerp(Pcam,Ptarget,0.1)
Frame-time fluctuations between14 ms14 msand18 ms18 mscaused visual camera hitching.
code Code
Frame N: TranslateZ(+speed) -> isBlocked() hits vehicle chassis -> Reset to (oldX, oldZ)
Frame N+1: Ray passes between internal vertices -> Movement allowed
Frame N+2: Ray strikes internal wheel vertex -> Reset to (oldX, oldZ)
Result: High-frequency 30Hz position vibration (Stutter)
5.2 The Unified Mathematical Resolution
code JavaScript
// 1. Rigorous Descendant Exclusion in Collision Traversal (build.js)
const validMeshes = [];
this.collidableMeshes.forEach(m => {
let isPart = false;
if (ignoreVehicle && (m.parent === ignoreVehicle || (m.userData && m.userData.isVehiclePart))) {
isPart = true;
}
if (!isPart) validMeshes.push(m);
});
// 2. Continuous Exponential Ground Damping (index.html)
activeControl.position.y = THREE.MathUtils.lerp(
activeControl.position.y,
targetGround,
delta * 15.0
);
// 3. Delta-Compensated Camera Latching (index.html)
const cameraDampFactor = 1.0 - Math.exp(-8.0 * delta);
camera.position.lerp(_vLookTarget, cameraDampFactor);
Section 6: Asynchronous Asset Loading Race Conditions & Invisibility Traps
6.1 The One-Way Invisibility Failure
A critical desync occurred when two players operated vehicles simultaneously:
- Player 1 and Player 2 exited aircraft at the same destination.
- Player 2 saw Player 1 clearly, but Player 1 could not see Player 2.
code Code
Timeline of the Asynchronous Loading Trap:
Player 2 Client Player 1 Client (Observing Player 2)
---------------- ------------------------------------
1. Board Vehicle (isDriving=true) --> 2. Receives update: p.isDriving = true
3. Begins GLTF loader for vehicle
4. Sets Player 2 human mesh: visible = false
5. Exits Vehicle (isDriving=false) -> 6. Receives update: p.isDriving = false
7. Sets Player 2 human mesh: visible = true
8. (500ms later) GLTF Loader FINISHES late!
9. Loader callback executes: adds vehicle,
overrides human visibility to FALSE!
Result: Player 2 permanently invisible!
6.2 Structural Resolution: Scene-Graph Root Decoupling & Packet Queuing
We eliminated deep hierarchy traversals (p.model.traverse(…)) by introducing an explicit Root Group Isolation Architecture:
code Code
rootGroup (Spatial Node: Position, Rotation)
/ \
bodyMesh (Human GLTF) vehicleMesh (Dynamic GLTF)
code JavaScript
// applyRemoteUpdate: Race-Condition Immune Mesh Management
function applyRemoteUpdate(p, d) {
p.rootGroup.position.lerp(new THREE.Vector3(d.pos.x, d.pos.y, d.pos.z), 0.3);
p.rootGroup.rotation.y = d.rot;
p.isDriving = !!d.isDriving;
if (d.isDriving && d.vehicleId) {
if (p.bodyMesh) p.bodyMesh.visible = false;
// Directly manipulate the physical world vehicle
const worldVeh = build.buildGroup.children.find(c => c.userData && c.userData.id === d.vehicleId);
if (worldVeh) {
worldVeh.position.lerp(new THREE.Vector3(d.pos.x, d.pos.y, d.pos.z), 0.35);
worldVeh.rotation.y = d.rot;
worldVeh.rotation.x = d.rotX || 0;
worldVeh.rotation.z = d.rotZ || 0;
}
} else {
// Instant, unconditional human mesh restoration
if (p.bodyMesh) p.bodyMesh.visible = true;
let active = p[d.anim] || p.idle;
if (active) {
if (d.anim === 'death' && !active.isRunning()) active.reset().play();
else active.play();
[p.idle, p.walk, p.run, p.jump, p.wave, p.death].forEach(a => { if (a && a !== active) a.stop(); });
}
}
}
code JavaScript
// Packet Queuing for Entities Loading in Background
function createRemote(id, data) {
if (otherPlayers[id]) return;
const rootGroup = new THREE.Group();
rootGroup.position.set(data.pos.x, data.pos.y, data.pos.z);
rootGroup.rotation.y = data.rot;
rootGroup.visible = isStarted;
scene.add(rootGroup);
loader.load('player/' + data.model, (gltf) => {
const body = gltf.scene;
rootGroup.add(body);
const playerObj = {
id,
rootGroup,
bodyMesh: body,
isDriving: !!data.isDriving,
mixer: new THREE.AnimationMixer(body),
// ... animations ...
};
otherPlayers[id] = playerObj;
// Flush queued packets received during download phase
if (pendingUpdates[id]) {
applyRemoteUpdate(playerObj, pendingUpdates[id]);
delete pendingUpdates[id];
}
});
}
Section 7: Infinite Spawner Proliferation & World-State Isolation
7.1 The Problem: Fleet Multiplication Bug
When a spawner automatically produced a replacement vehicle after
5 seconds5 seconds
of displacement (
d>25 md>25 m
), an edge-case occurred:
- Player A drove Car 1 to destination
XX. - Car 2 spawned on the runway.
- Player A returned on foot, boarded Car 2, and drove to destination
YY. - A single user could generate an unbounded number of vehicles across the simulation.
code Code
Initial State: [Spawn Pad: Car 1]
Player takes it: [Car 1 moving...] -> 5s Timer -> [Spawn Pad: Car 2 created]
Player returns: Abandons Car 1 at Spot X -> Takes Car 2 -> [Spawn Pad: Car 3 created]
Result: Exponential object growth, disk bloat in map_objects.json, physics lag
7.2 The Solution: Strict Single-Active-Vehicle-Per-Player Policy
code Code
Player Attempts to Board Vehicle (targetId)
|
Does Player have myActiveVehicleId?
/ \
(Yes) (No)
/ \
myActiveVehicleId !== targetId? Set myActiveVehicleId = targetId
/ \ |
(Yes) (No) |
/ \ |
Delete Old Vehicle \ |
(BuildGroup & Server) \ |
| \ |
+--------------------+-----------+
|
Board New Vehicle
code JavaScript
// 1-Vehicle Per Player Enforcement Engine (index.html)
if (myActiveVehicleId && myActiveVehicleId !== targetId) {
const oldVeh = build.buildGroup.children.find(c => c.userData && c.userData.id === myActiveVehicleId);
if (oldVeh) {
build.buildGroup.remove(oldVeh);
build.collidableMeshes = build.collidableMeshes.filter(m => !oldVeh.children.includes(m));
build.disposeObject(oldVeh);
socket.emit('delete_object', { id: myActiveVehicleId });
}
}
myActiveVehicleId = targetId;
code JavaScript
// Spatial Clearance Verification for Spawn Pad Replacements
let alreadyHasFreeVehicle = false;
for (let obj of build.buildGroup.children) {
if (obj.userData && obj.userData.isVehicle) {
if (obj.position.distanceTo(originSpawnData.pos) < 12.0) {
alreadyHasFreeVehicle = true;
break;
}
}
}
let playerOnPad = false;
if (!isDriving && localPlayer && localPlayer.position.distanceTo(originSpawnData.pos) < 5.0) {
playerOnPad = true;
}
for (let id in otherPlayers) {
const p = otherPlayers[id];
if (p && p.rootGroup && p.rootGroup.position.distanceTo(originSpawnData.pos) < 5.0) {
playerOnPad = true;
break;
}
}
// Postpone spawn if occupied by a physical vehicle or human avatar
if (playerOnPad) {
respawnCountdown = 2.0;
} else if (!alreadyHasFreeVehicle) {
hasRespawnedAtOrigin = true;
const freshSpawn = {
id: 'obj_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
file: originSpawnData.file,
pos: originSpawnData.pos,
rot: originSpawnData.rot,
scale: originSpawnData.scale
};
socket.emit('place_object', freshSpawn);
build.createInScene(freshSpawn);
}
Section 8: Build Mode (?edit) Scene Isolation & Welcome Screen Sandboxing
8.1 Welcome Screen Leakage
When unauthenticated users were on the initial avatar selection carousel (isStarted === false), remote players who joined and moved through coordinates
(0,0,0)(0,0,0)
were visible behind the modal UI.
code JavaScript
// Resolution: Initial Visibility Latch
rootGroup.visible = isStarted; // Remains false until enterGame() completes
code JavaScript
// On 'joined' Event: Unhide remote entities simultaneously
socket.on('joined', data => {
isStarted = true;
Object.values(otherPlayers).forEach(p => {
if (p.rootGroup) p.rootGroup.visible = true;
});
});
8.2 Private Editor Sandbox in Build Mode
When entering level-editing mode (build.isEditMode === true):
- All remote player instances and offline ghosts are dynamically hidden from the scene graph.
- The local player avatar is suppressed.
- OrbitControls take exclusive control of the camera view.
- Exiting Build Mode restores visibility and state without desynchronizing network streams.
code JavaScript
// animate() Rendering Loop: Strict Sandbox Partitioning
if (inBuild) {
Object.values(otherPlayers).forEach(p => {
if (p.rootGroup) p.rootGroup.visible = false;
});
if (localPlayer) localPlayer.visible = false;
} else {
Object.values(otherPlayers).forEach(p => {
if (p.rootGroup) p.rootGroup.visible = true;
});
if (!isDriving && localPlayer) localPlayer.visible = true;
}
Section 9: Mobile DOM Touch-Action Interception (social.js)
9.1 Root Cause of Horizontal Scroll Lock on Mobile
The quick-chat social menu rendered properly on desktop, but on mobile devices, dragging the menu failed to scroll the emojis or phrases.
- Cause: The global document listener in index.html executed e.preventDefault() on all touchmove events to prevent browser window pull-down refresh: code JavaScript
window.addEventListener('touchmove', (e) => { e.preventDefault(); }, { passive: false }); - Result: Because #socialMenu was not excluded in touchmove, horizontal pan gestures inside .social-row were cancelled by the browser before native scrolling could occur.
9.2 The Dual-Layer Fix
- DOM Tree Event Boundary Check (index.html): code JavaScript
window.addEventListener('touchmove', (e) => { if (e.target.closest('#socialMenu, #modal, #editBtn, #assetPanel, #buildToolbar')) return; // ... standard gameplay gesture processing ... e.preventDefault(); }, { passive: false }); - CSS Touch-Action Declaration & Event Isolation (social.js): code CSS
.social-row { display: flex; gap: 6px; flex-wrap: nowrap; overflow-x: auto; touch-action: pan-x !important; /* Informs browser to allow native horizontal panning */ -webkit-overflow-scrolling: touch; }code JavaScript['touchstart', 'touchmove', 'touchend'].forEach(evt => { div.addEventListener(evt, (e) => e.stopPropagation(), { passive: true }); });
Section 10: Ghost Shading Calibration
To ensure disconnected players remained visible as non-intrusive historical presences across 24-hour retention cycles, offline ghost rendering was calibrated to a subtle semi-transparent appearance:
code JavaScript
// Calibration: 0.5 -> 0.1 Material Opacity
if (isGhost) {
body.traverse(c => {
if (c.isMesh) {
c.material = c.material.clone();
c.material.transparent = true;
c.material.opacity = 0.1; // Reduced visual footprint for offline records
}
});
}
Engineering Post-Mortem & Verified Architecture Matrix
| Bug / Challenge | Theoretical Root Cause | Engineering Solution |
| Mobile Vehicle Unresponsive | Viewport drag events mutated localPlayer.rotation.y instead of the active entity currentVehicle. | Dynamic control binding to activeControl (Vehicle vs. Human) in touch handlers. |
| Aircraft Gimbal Lock | Sequential Euler angle evaluation order ‘XYZ’ skewing local axes under simultaneous roll/pitch. | Switched aircraft coordinate transforms to ‘YXZ’ order. |
| Multiplayer Vehicle Shrinking | Default GLTF normalization scale ( 2.5 units2.5 units ) hardcoded during remote asset load. | Serialized scalar dimensions (vehicleScale) across Socket.IO payloads. |
| High-Frequency Forward Stutter | Raycaster self-intersecting internal vehicle chassis geometry in horizontal sweep. | Hierarchical mesh exclusion filters in BuildSystem.isBlocked(). |
| One-Way Invisibility | Late-resolving asynchronous asset loader callbacks overriding root visibility flags. | Scene-graph decoupling (rootGroup vs bodyMesh) and update packet queues. |
| Fleet Duplication Spawns | Client-side spawner triggers firing without global player ownership checks. | Strict 1-Active-Vehicle-Per-Player enforcement and spatial clearance validation ( r=12 mr=12 m ). |
| Welcome Screen Player Leaks | Remote player root groups added to scene with default visible = true. | Visibility state bound to isStarted flag and synchronized on joined event. |
| UI Touch Scroll Failure | Global canvas touchmove invoking e.preventDefault() indiscriminately. | Added element boundary exclusions and CSS touch-action: pan-x !important. |
Conclusion
By systematically decoupling the kinematic simulation layer, the network serialization protocol, and the Three.js scene graph, we transformed a fragile prototype into a resilient, high-performance web multiplayer engine. The system delivers 60 FPS simulations across mobile and desktop runtimes, maintains clean persistent world state, and ensures reliable spatial synchronization across all connected clients.