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.
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.
I have a car.glb model. When the player character moves near the car and the user presses Enter, the character should hide, and the controls should switch to the car. The user can then drive the car using the same movement keys (WASD/Arrows), except the car cannot jump using the Spacebar. When the user presses Enter again, the character should exit the car, reappear next to it, and regain normal walking controls to continue the game.