Engineering a Relaxing Hot Air Balloon System in Three.js
Date: September 18, 2026 Project: World Simulation Focus: Vehicle Physics, Camera Immersion, and Collision Resolution
1. Abstract
The objective was to implement a Hot Air Balloon vehicle that felt “weightless” and “atmospheric.” Initial attempts resulted in “snappy” movement akin to a ground vehicle. Through iterative testing, we moved from Kinematic Movement (instant) to Momentum-Based Physics (drifting), while solving critical bugs regarding spawn-teleportation and collision locking.
2. The Evolution: What Went Wrong & What Went Right
❌ The “Bad” (Initial Pain Points)
The “Car” Feel: Initially, the balloon stopped the millisecond the key was released. It felt like a floating car, not a balloon.
The “Jump” Bug: Upon entering the balloon, the code forced a +3 height increase. This looked like a “glitchy jump” and ruined the smooth entry.
The “Stuck” Paradox: When exiting in mid-air, the player was teleported instantly to the ground. If a building was beneath them, they spawned inside the mesh, triggering the isBlocked function and freezing the player in place.
Speed Issues: Initial momentum was too high, making the balloon feel like a jet engine rather than a peaceful drift.
✅ The “Good” (The Wins)
Momentum Flow: Implementing a velocity vector allowed the balloon to “glide.” Now, it takes time to speed up and time to slow down.
The Aerial Perspective: Switching to a 45-degree diagonal camera shifted the experience from “playing a game” to “viewing a landscape.”
Natural Exit: By removing the forced ground-snap, players now fall naturally from the balloon, adding a sense of gravity and danger.
3. Technical Implementation (The “Golden” Code)
To achieve the final feel, we modified two core systems: the Vehicle Logic (index.html) and the Object Classification (build.js).
A. The Physics Engine (index.html)
We replaced instant position updates with a Velocity-Friction Model.
The Constants:
code JavaScript
// Ultra-Slow Floating Physics
const BALLOON_ACCEL = 0.5; // Slow start, prevents "snapping"
const BALLOON_FRICTION = 0.998; // Near-zero friction = infinite glide
const BALLOON_MAX_SPEED = 4.0; // Capped for a relaxing pace
const BALLOON_LIFT_SPEED = 5.0; // Constant lift for the Q key
The Logic Loop:
code JavaScript
// Inside animate() -> if (isBalloon)
const forward = new THREE.Vector3(0, 0, 1).applyQuaternion(activeControl.quaternion);
// Horizontal Momentum (W/S)
if (keys['w']) balloonVelocity.addScaledVector(forward, BALLOON_ACCEL * delta);
if (keys['s']) balloonVelocity.addScaledVector(forward, -BALLOON_ACCEL * delta);
// Direct Lift (Q) - No momentum, just constant rise
if (keys['q']) activeControl.position.y += BALLOON_LIFT_SPEED * delta;
// Downward Drift (E) - Uses momentum to "sink"
if (keys['e']) balloonVelocityY -= BALLOON_ACCEL * delta;
// Apply Glide Friction
balloonVelocity.multiplyScalar(BALLOON_FRICTION);
balloonVelocityY *= BALLOON_FRICTION;
// Final Position Update
activeControl.position.add(balloonVelocity);
activeControl.position.y += balloonVelocityY;
B. The Cinematic Camera (index.html)
To simulate “Sunlight at 9 AM,” we positioned the camera at a high diagonal offset.
code JavaScript
// Specialized Balloon Camera View
if (isDriving && currentVehicle && currentVehicle.userData && currentVehicle.userData.isBalloon) {
const camDist = 25;
// Offset camera 45 degrees top-down and behind
const offset = new THREE.Vector3(camDist, camDist, camDist).applyQuaternion(activeControl.quaternion);
_vLookTarget.copy(activeControl.position).add(offset);
camera.position.lerp(_vLookTarget, delta * 5);
camera.lookAt(activeControl.position);
}
C. The Build Classification (build.js)
For the physics to know it’s a balloon and not a car, we added metadata during the GLB load.
code JavaScript
// Inside createInScene(data)
const isBalloon = nameLower.includes('balloon') || nameLower.includes('hot-air');
if (isBalloon) {
obj.userData.isVehicle = true;
obj.userData.isBalloon = true; // This flag triggers the momentum physics
}
4. Conclusion & Final Tuning
The “Perfect Drift” was found by balancing Acceleration vs. Friction. By lowering acceleration to 0.5 and raising friction to 0.998, we achieved a state of “perpetual flow.”
The removal of the nearest.position.y += 3 line in toggleVehicle finally allowed the balloon to sit naturally at ground level 0, creating a seamless transition from walking to flying.
for f in*.fbx; do base="${f%.fbx}" # 1. Convert FBX to a temporary GLB fbx2gltf -i "$f"-o "${base}_raw.glb" # 2. Complete optimization pipeline: Resize textures, clear empty animations, apply Draco gltf-transform resize "${base}_raw.glb""${base}.glb"--width 512--height 512&& \ gltf-transform draco "${base}.glb""${base}.glb"--quantizePosition 10--quantizeTexcoord 10 # 3. Clean up the large intermediate file rm "${base}_raw.glb"done
This updated command downscales all textures to 256×256 pixels and combines geometry quantization with animation thinning. This pipeline will maximize your file savings, likely bringing your 25 MB models down to under 1.5 MB each.
Run this loop in your Debian terminal:
bash
for f in*.fbx; do base="${f%.fbx}" # 1. Convert FBX to a raw GLB fbx2gltf -i "$f"-o "${base}_raw.glb" # 2. Resize textures to 256x256, prune empty elements, and aggressively compress geometry gltf-transform resize "${base}_raw.glb""${base}.glb"--width 256--height 256&& \ gltf-transform prune "${base}.glb""${base}.glb"&& \ gltf-transform draco "${base}.glb""${base}.glb"--quantizePosition 10--quantizeTexcoord 10--quantizeNormal 8 # 3. Remove the heavy source file rm "${base}_raw.glb"done
Use code with caution.
What makes this code compress so aggressively?
resize --width 256 --height 256: Shrinks texture memory overhead by up to 98% compared to standard Mixamo 2K textures.
prune: This automatically detects and removes unused nodes, empty animations (like that dead Take 001 track in your log), and duplicate materials.
--quantizeNormal 8: Adds normal vector quantization to the Draco step, stripping extra floating-point precision data from the model’s lighting data where the human eye won’t notice it.
Let me know what your final file sizes look like after running this loop! If they are still too large, we can add animation sampling reduction to strip out heavy keyframes, or switch the texture format to KTX2. Would you like to check the final sizes first?
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.
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 rad per 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 (
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
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) after Pitch(x)Pitch(x) with an active Yaw(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.
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:
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 distance d<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 between 14 ms14 ms and 18 ms18 ms caused 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)
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
When unauthenticated users were on the initial avatar selection carousel (isStarted === false), remote players who joined and moved through coordinates
// 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 JavaScriptwindow.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 JavaScriptwindow.addEventListener('touchmove', (e) => { if (e.target.closest('#socialMenu, #modal, #editBtn, #assetPanel, #buildToolbar')) return; // ... standard gameplay gesture processing ... e.preventDefault(); }, { passive: false });
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
}
});
}
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.
What started as a basic experiment in 3D movement has evolved into a high-stakes, cinematic “Data Stream” simulation. We moved away from “balloon men” into Director Mode—a system where physics, HERO jumps, and cartoonish deaths are captured through a dynamic lens.
1. Scaling the Threat: The 2X Giant
We realized that to make the “Infinite Run” feel impactful, the runners needed to dominate the screen. We doubled their scale to make them feel like “Titan” data packets.
The Idea: Move from human-scale to giant-scale (2X taller).
The Code Change: We scaled the CapsuleGeometry and added a complex joint system for the arms and hands.
code JavaScript
// Engineering the 2X Giant
const bodyGeo = new THREE.CapsuleGeometry(1.0, 2.0, 4, 10); // Double height
const limbGeo = new THREE.CapsuleGeometry(0.35, 1.6, 4, 8); // Thick reinforced limbs
const handGeo = new THREE.SphereGeometry(0.5, 8, 8); // Large glowing contact points
2. Hand Motion & “Wrist Flick”
To prevent the giants from looking like stiff robots, we implemented a secondary animation layer for the wrists.
The Logic: As the arm swings (Pendulum physics), the hand should “flick” in the opposite direction to simulate weight and momentum.
The core gameplay mechanic involves a high-speed ball. We wanted the interaction to feel like a high-budget action movie.
Hero Jump: Only 1 or 2 runners per roll are “chosen” to jump. They launch into a parabolic arc, leaning forward like a superhero.
Flatten Physics: If a runner fails to jump or is hit, they don’t just disappear—they are instantly crushed into a 0.02-scale pancake, sliding across the mirror floor.
code JavaScript
// The "Pancake" Death Logic
flatten() {
this.state = 'flat';
this.group.scale.set(2.0, 0.02, 2.0); // Extreme vertical squash
this.group.position.y = 0.05; // Stick to the mirror floor
}
4. Director Mode: Cinematic Camera Cuts
A fixed camera is boring. We built a Virtual Director that watches the race and decides when to “cut” to a better angle.
The Logic: When a Hero Jump is detected, the camera snaps to a random cinematic preset (Side-view, Birds-eye, or Low-Front) and zooms in using Bullet Time (Slow Motion).
code JavaScript
// Director Camera Angle Presets
const jumpCamAngles = [
new THREE.Vector3(40, 15, 0), // Dramatic Profile
new THREE.Vector3(-40, 15, 5), // Reverse Angle
new THREE.Vector3(0, 8, 50), // Low Hero Angle
];
// Switch to Cinematic Focus
if (anyoneJumping) {
camTargetPos.set(hero.x + angle.x, hero.y + angle.y, hero.z + angle.z);
bulletTime = 0.2; // Slow down the universe
}
5. Visible Data: KB/MB Tracking
To tie the theme together, we treated every object as a “Download.”
The Concept: Spawning a runner isn’t just a 3D call; it’s a 120.4 KB data payload.
Tracking: Every tap to launch the ball costs data, and every death results in “Packet Loss.”
Final Optimized Code
This code includes the 2X Scale Giants, Hand/Wrist Motion, Director Mode Camera, Hero Jump Logic (1-2 per roll), and Flattening Physics.