Category: three.js

  • roll

    <!DOCTYPE html>
    <html>
    <head>
        <title>Roll - Dark Descent</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
        <style>
            body { margin: 0; overflow: hidden; font-family: Arial, sans-serif; touch-action: none; background-color: #fffafa; }
            #TutContainer { width: 100%; height: 100vh; }
            #scoreBoard { position: absolute; top: 20px; left: 20px; font-size: 20px; font-weight: bold; color: #333; z-index: 10; pointer-events: none; }
            #gameOverScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); display: none; flex-direction: column; justify-content: center; align-items: center; z-index: 100; color: white; }
            #gameOverScreen h1 { font-size: 32px; margin-bottom: 20px; text-align: center; }
            #restartBtn { padding: 15px 30px; font-size: 20px; cursor: pointer; background: #33ff33; border: none; border-radius: 5px; font-weight: bold; }
        </style>
    </head>
    <body>
        <div id="scoreBoard">Score: 0</div> 
        <div id="gameOverScreen">
            <h1>GAME OVER</h1>
            <button id="restartBtn">RUN AGAIN</button>
        </div>
        <div id="TutContainer"></div>  
        <script src="https://cdn.jsdelivr.net/npm/three@0.145.0/build/three.min.js"></script>   
         <script>
           let sceneWidth, sceneHeight, camera, scene, renderer, dom;
            let sun, heroRollingSpeed, clock, treesPool, treesInPath;
            let pathAngleValues;
            let globalRenderID;
    
            const worldRadius = 26;
            const heroRadius = 0.2;
            const heroBaseY = 2.1; 
            const gravity = 0.005;
            const particleCount = 20;
    
            let rollingSpeed = 0.008; 
            let treeReleaseInterval = 0.5; 
            let rightLane = 1;
            let middleLane = 0;
            let leftLane = -1;
            let lerpSpeed = 0.15; 
    
            let scoreText, score, hasCollided;
            let jumping = false;
            let bounceValue = 0;
            let currentLane = middleLane;
            let heroSphere;
            let rollingGroundSphere;
            let particles;
            let particleGeometry;
            let explosionPower = 1;
            let sphericalHelper;
    
            let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
            const minSwipeDistance = 30;
    
            init();
    
            function init() {
                adjustSettings(); 
                createScene();
                update();
            }
    
            function adjustSettings() {
                if (window.innerWidth < 768) {
                    rollingSpeed = 0.005;
                    treeReleaseInterval = 1.0;
                    leftLane = -0.6;
                    rightLane = 0.6;
                    lerpSpeed = 0.25;
                } else {
                    rollingSpeed = 0.008;
                    treeReleaseInterval = 0.5;
                    leftLane = -1;
                    rightLane = 1;
                    lerpSpeed = 0.15;
                }
            }
    
            function createScene() {
                hasCollided = false;
                score = 0;
                treesPool = [];
                treesInPath = [];
                clock = new THREE.Clock();
                clock.start();
                
                heroRollingSpeed = (rollingSpeed * worldRadius / heroRadius) / 5;
                sphericalHelper = new THREE.Spherical();
                pathAngleValues = [1.52, 1.57, 1.62];
    
                sceneWidth = window.innerWidth;
                sceneHeight = window.innerHeight;
    
                scene = new THREE.Scene();
                scene.background = new THREE.Color(0xfffafa);
                scene.fog = new THREE.FogExp2(0xf0fff0, 0.14);
    
                camera = new THREE.PerspectiveCamera(60, sceneWidth / sceneHeight, 0.1, 1000);
                renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
                renderer.setClearColor(0xfffafa, 1);
                renderer.shadowMap.enabled = true;
                renderer.shadowMap.type = THREE.PCFSoftShadowMap;
                renderer.setSize(sceneWidth, sceneHeight);
    
                dom = document.getElementById('TutContainer');
                dom.appendChild(renderer.domElement);
                scoreText = document.getElementById('scoreBoard');
    
                createTreesPool();
                addWorld();
                addLight();
                addHero();
                addExplosion();
    
                camera.position.z = 6.5;
                camera.position.y = 2.5;
    
                window.addEventListener('resize', onWindowResize, false);
                document.addEventListener('keydown', handleKeyDown);
                document.addEventListener('touchstart', handleTouchStart, {passive: false});
                document.addEventListener('touchend', handleTouchEnd, {passive: false});
    
                const restartBtn = document.getElementById('restartBtn');
                if (restartBtn) {
                    restartBtn.addEventListener('click', () => { location.reload(); });
                }
            }
    
            function moveLeft() {
                if (hasCollided) return;
                if (currentLane === middleLane) currentLane = leftLane;
                else if (currentLane === rightLane) currentLane = middleLane;
                triggerJumpEffect();
            }
    
            function moveRight() {
                if (hasCollided) return;
                if (currentLane === middleLane) currentLane = rightLane;
                else if (currentLane === leftLane) currentLane = middleLane;
                triggerJumpEffect();
            }
    
            function jump() {
                if (hasCollided) return;
                if (!jumping) {
                    bounceValue = 0.12;
                    jumping = true;
                }
            }
    
            function triggerJumpEffect() {
                if (!jumping) {
                    jumping = true;
                    bounceValue = 0.06;
                }
            }
    
            function handleKeyDown(event) {
                const key = event.key.toLowerCase();
                if (key === 'a') moveLeft();
                else if (key === 'd') moveRight();
                else if (key === ' ') jump();
            }
    
            function handleTouchStart(e) {
                touchStartX = e.changedTouches[0].screenX;
                touchStartY = e.changedTouches[0].screenY;
            }
    
            function handleTouchEnd(e) {
                touchEndX = e.changedTouches[0].screenX;
                touchEndY = e.changedTouches[0].screenY;
                let dx = touchEndX - touchStartX;
                let dy = touchEndY - touchStartY;
                if (Math.abs(dx) > Math.abs(dy)) {
                    if (Math.abs(dx) > minSwipeDistance) {
                        if (dx < 0) moveLeft(); else moveRight();
                    }
                } else {
                    if (Math.abs(dy) > minSwipeDistance) {
                        if (dy < 0) jump();
                    }
                }
            }
    
            function update() {
                if (hasCollided) {
                    doExplosionLogic();
                    render();
                    globalRenderID = requestAnimationFrame(update);
                    return; 
                }
    
                // 1. Update Lighting and Atmosphere based on score
                updateAtmosphere();
    
                rollingGroundSphere.rotation.x += rollingSpeed;
                heroSphere.rotation.x -= heroRollingSpeed;
    
                if (heroSphere.position.y <= heroBaseY) {
                    jumping = false;
                    bounceValue = (Math.random() * 0.04) + 0.005;
                }
                heroSphere.position.y += bounceValue;
                heroSphere.position.x = THREE.MathUtils.lerp(heroSphere.position.x, currentLane, lerpSpeed); 
                bounceValue -= gravity;
    
                if (clock.getElapsedTime() > treeReleaseInterval) {
                    clock.start();
                    addPathTree();
                    score += 1;
                    if (scoreText) scoreText.innerHTML = "Score: " + score;
                }
    
                doTreeLogic();
                doExplosionLogic();
                render();
                globalRenderID = requestAnimationFrame(update);
            }
    
            function updateAtmosphere() {
                // Gradually get darker. Max darkness at score 200.
                let darkness = Math.min(score / 200, 1); 
                
                // Light intensity fades from 0.9 to 0.2
                sun.intensity = THREE.MathUtils.lerp(0.9, 0.2, darkness);
                
                // Background color fades from White to Deep Midnight Blue
                scene.background.setRGB(
                    THREE.MathUtils.lerp(1, 0.05, darkness), 
                    THREE.MathUtils.lerp(1, 0.05, darkness), 
                    THREE.MathUtils.lerp(1, 0.2, darkness)
                );
                
                // Fog changes color to match
                scene.fog.color.setRGB(
                    THREE.MathUtils.lerp(0.94, 0.05, darkness), 
                    THREE.MathUtils.lerp(1, 0.05, darkness), 
                    THREE.MathUtils.lerp(0.94, 0.2, darkness)
                );
            }
    
            function render() {
                renderer.render(scene, camera);
            }
    
            function gameOver() {
                const screen = document.getElementById('gameOverScreen');
                if (screen) screen.style.display = 'flex';
            }
    
            function createTreesPool() {
                var maxTreesInPool = 15;
                for (var i = 0; i < maxTreesInPool; i++) {
                    treesPool.push(createTree());
                }
            }
    
            function addPathTree() {
                var options = [0, 1, 2];
                var lane = Math.floor(Math.random() * 3);
                addTree(true, lane);
                options.splice(lane, 1);
                if (Math.random() > 0.5) {
                    lane = Math.floor(Math.random() * 2);
                    addTree(true, options[lane]);
                }
            }
    
            function addHero() {
                var sphereGeometry = new THREE.DodecahedronGeometry(heroRadius, 1);
                var sphereMaterial = new THREE.MeshStandardMaterial({ color: 0xe5f2f2, flatShading: true });
                heroSphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
                heroSphere.receiveShadow = true;
                heroSphere.castShadow = true;
                scene.add(heroSphere);
                heroSphere.position.set(middleLane, heroBaseY, 4.8);
                currentLane = middleLane;
            }
    
            function addWorld() {
                var sides = 40, tiers = 40;
                var sphereGeometry = new THREE.SphereGeometry(worldRadius, sides, tiers);
                var sphereMaterial = new THREE.MeshStandardMaterial({ color: 0xfffafa, flatShading: true });
                
                const pos = sphereGeometry.attributes.position;
                for (let i = 0; i < pos.count; i++) {
                    let x = pos.getX(i);
                    let y = pos.getY(i);
                    let z = pos.getZ(i);
                    let vec = new THREE.Vector3(x, y, z).normalize();
                    let heightValue = (Math.random() * 0.05) - 0.025;
                    vec.multiplyScalar(worldRadius + heightValue);
                    pos.setXYZ(i, vec.x, vec.y, vec.z);
                }
                pos.needsUpdate = true;
    
                rollingGroundSphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
                rollingGroundSphere.receiveShadow = true;
                rollingGroundSphere.rotation.z = -Math.PI / 2;
                scene.add(rollingGroundSphere);
                rollingGroundSphere.position.set(0, -24, 2);
                addWorldTrees();
            }
    
            function addLight() {
                scene.add(new THREE.HemisphereLight(0xfffafa, 0x000000, .9));
                sun = new THREE.DirectionalLight(0xcdc1c5, 0.9);
                sun.position.set(12, 6, -7);
                sun.castShadow = true;
                scene.add(sun);
            }
    
            function createTree() {
                var sides = 8, tiers = 6;
                var scalarMultiplier = (Math.random() * 0.15) + 0.05;
                var treeGeometry = new THREE.ConeGeometry(0.5, 1, sides, tiers);
                var treeMaterial = new THREE.MeshStandardMaterial({ color: 0x33ff33, flatShading: true });
                
                const pos = treeGeometry.attributes.position;
                blowUpTree(pos, sides, 0, scalarMultiplier);
                tightenTree(pos, sides, 1);
                blowUpTree(pos, sides, 2, scalarMultiplier * 1.1, true);
                tightenTree(pos, sides, 3);
                blowUpTree(pos, sides, 4, scalarMultiplier * 1.2);
                tightenTree(pos, sides, 5);
                pos.needsUpdate = true;
    
                var treeTop = new THREE.Mesh(treeGeometry, treeMaterial);
                treeTop.castShadow = true;
                treeTop.position.y = 0.9;
                treeTop.rotation.y = Math.random() * Math.PI;
                var treeTrunk = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 0.5), new THREE.MeshStandardMaterial({ color: 0x886633, flatShading: true }));
                treeTrunk.position.y = 0.25;
                var tree = new THREE.Object3D();
                tree.add(treeTrunk);
                tree.add(treeTop);
                return tree;
            }
    
            function blowUpTree(pos, sides, currentTier, scalarMultiplier, odd) {
                for (var i = 0; i < sides; i++) {
                    var vertexIndex = (currentTier * sides) + 1;
                    var idx = i + vertexIndex;
                    let vX = pos.getX(idx);
                    let vY = pos.getY(idx);
                    let vZ = pos.getZ(idx);
                    let vertexVector = new THREE.Vector3(vX, vY, vZ);
                    let midPointVector = new THREE.Vector3(0, vY, 0);
                    let offset = vertexVector.clone().sub(midPointVector);
                    if (odd) {
                        if (i % 2 === 0) offset.normalize().multiplyScalar(scalarMultiplier / 6);
                        else {
                            offset.normalize().multiplyScalar(scalarMultiplier);
                            let nextY = pos.getY(idx + sides);
                            pos.setY(idx, nextY + 0.05);
                        }
                    } else {
                        if (i % 2 !== 0) offset.normalize().multiplyScalar(scalarMultiplier / 6);
                        else {
                            offset.normalize().multiplyScalar(scalarMultiplier);
                            let nextY = pos.getY(idx + sides);
                            pos.setY(idx, nextY + 0.05);
                        }
                    }
                    let finalVec = vertexVector.add(offset);
                    pos.setXYZ(idx, finalVec.x, finalVec.y, finalVec.z);
                }
            }
    
            function tightenTree(pos, sides, currentTier) {
                for (var i = 0; i < sides; i++) {
                    var vertexIndex = (currentTier * sides) + 1;
                    var idx = i + vertexIndex;
                    let vX = pos.getX(idx);
                    let vY = pos.getY(idx);
                    let vZ = pos.getZ(idx);
                    let vertexVector = new THREE.Vector3(vX, vY, vZ);
                    let midPointVector = new THREE.Vector3(0, vY, 0);
                    let offset = vertexVector.clone().sub(midPointVector);
                    offset.normalize().multiplyScalar(0.06);
                    let finalVec = vertexVector.sub(offset);
                    pos.setXYZ(idx, finalVec.x, finalVec.y, finalVec.z);
                }
            }
    
            function addWorldTrees() {
                var numTrees = 36, gap = 6.28 / 36;
                for (var i = 0; i < numTrees; i++) {
                    addTree(false, i * gap, true);
                    addTree(false, i * gap, false);
                }
            }
    
            function addTree(inPath, row, isLeft) {
                var newTree;
                if (inPath) {
                    if (treesPool.length == 0) return;
                    newTree = treesPool.pop();
                    newTree.visible = true;
                    
                    // Reset rotations in case it was a fallen tree
                    newTree.rotation.set(0,0,0);
    
                    // Randomly decide if this tree falls (30% chance)
                    newTree.userData.isFalling = Math.random() < 0.3;
                    newTree.userData.fallRotation = 0;
    
                    treesInPath.push(newTree);
                    sphericalHelper.set(worldRadius - 0.3, pathAngleValues[row], -rollingGroundSphere.rotation.x + 4);
                } else {
                    newTree = createTree();
                    var forestAreaAngle = isLeft ? 1.68 + Math.random() * 0.1 : 1.46 - Math.random() * 0.1;
                    sphericalHelper.set(worldRadius - 0.3, forestAreaAngle, row);
                }
                newTree.position.setFromSpherical(sphericalHelper);
                var rollingGroundVector = rollingGroundSphere.position.clone().normalize();
                var treeVector = newTree.position.clone().normalize();
                newTree.quaternion.setFromUnitVectors(treeVector, rollingGroundVector);
                newTree.rotation.x += (Math.random() * (2 * Math.PI / 10)) + -Math.PI / 10;
                rollingGroundSphere.add(newTree);
            }
    
            function addExplosion() {
                particleGeometry = new THREE.BufferGeometry();
                const positions = new Float32Array(particleCount * 3);
                particleGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
                var pMaterial = new THREE.PointsMaterial({ color: 0xfffafa, size: 0.2 });
                particles = new THREE.Points(particleGeometry, pMaterial);
                scene.add(particles);
                particles.visible = false;
            }
    
            function explode() {
                particles.position.set(heroSphere.position.x, 2, 4.8);
                const pos = particleGeometry.attributes.position;
                for (var i = 0; i < particleCount; i++) {
                    pos.setXYZ(i, -0.2 + Math.random() * 0.4, -0.2 + Math.random() * 0.4, -0.2 + Math.random() * 0.4);
                }
                pos.needsUpdate = true;
                explosionPower = 1.07;
                particles.visible = true;
            }
    
            function doTreeLogic() {
                var treePos = new THREE.Vector3(), treesToRemove = [];
                treesInPath.forEach(function (oneTree) {
                    treePos.setFromMatrixPosition(oneTree.matrixWorld);
                    
                    // Animation: If it's a falling tree, tip it over as it gets closer
                    if (oneTree.userData.isFalling) {
                        if (oneTree.userData.fallRotation < Math.PI / 2) {
                            oneTree.userData.fallRotation += 0.05;
                            oneTree.rotation.z = oneTree.userData.fallRotation;
                        }
                    }
    
                    if (treePos.z > 6 && oneTree.visible) {
                        treesToRemove.push(oneTree);
                    } else if (treePos.distanceTo(heroSphere.position) <= 0.6) {
                        
                        // SPECIAL COLLISION: If the tree is fallen, you can jump over it!
                        if (oneTree.userData.isFalling && heroSphere.position.y > heroBaseY + 0.2) {
                            // Safe! User jumped over the fallen tree
                        } else {
                            hasCollided = true;
                            explode();
                            gameOver();
                        }
                    }
                });
                treesToRemove.forEach(function (element) {
                    var fromWhere = treesInPath.indexOf(element);
                    treesInPath.splice(fromWhere, 1);
                    treesPool.push(element);
                    element.visible = false;
                });
            }
    
            function doExplosionLogic() {
                if (!particles.visible) return;
                const pos = particleGeometry.attributes.position;
                for (var i = 0; i < particleCount; i++) {
                    let x = pos.getX(i) * explosionPower;
                    let y = pos.getY(i) * explosionPower;
                    let z = pos.getZ(i) * explosionPower;
                    pos.setXYZ(i, x, y, z);
                }
                pos.needsUpdate = true;
                if (explosionPower > 1.005) explosionPower -= 0.001;
                else particles.visible = false;
            }
    
            function onWindowResize() {
                sceneHeight = window.innerHeight;
                sceneWidth = window.innerWidth;
                renderer.setSize(sceneWidth, sceneHeight);
                camera.aspect = sceneWidth / sceneHeight;
                camera.updateProjectionMatrix();
            } 
        </script>
    </body>
    </html>
  • world : fly – clone – ride – build – social – optimization

    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:

    1. Low-latency mobile gesture recognition and aerodynamic flight modeling.
    2. Multi-agent state synchronization, asynchronous asset-loading race conditions, and spatial mesh visibility traps.
    3. Raycast-driven collision mechanics, self-intersection stuttering, and frame-rate independent camera tracking.
    4. Persistent world state management versus runtime vehicle entity lifecycles.
    5. 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 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 (

            ϕϕ
          

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

    1. Raycast collision directly intersects an object whose root ancestor belongs to build.buildGroup with userData.isVehicle === true.
    2. 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) 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.

    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:

    1. socket.emit(‘move’) emitted position and yaw coordinates, omitting vehicleScale.
    2. 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 .
    3. 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:

    1. 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.
    2. 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.
    3. 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)

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

    1. 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 });
    2. 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 / ChallengeTheoretical Root CauseEngineering Solution
    Mobile Vehicle UnresponsiveViewport 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 LockSequential Euler angle evaluation order ‘XYZ’ skewing local axes under simultaneous roll/pitch.Switched aircraft coordinate transforms to ‘YXZ’ order.
    Multiplayer Vehicle ShrinkingDefault GLTF normalization scale ( 2.5 units2.5 units ) hardcoded during remote asset load.Serialized scalar dimensions (vehicleScale) across Socket.IO payloads.
    High-Frequency Forward StutterRaycaster self-intersecting internal vehicle chassis geometry in horizontal sweep.Hierarchical mesh exclusion filters in BuildSystem.isBlocked().
    One-Way InvisibilityLate-resolving asynchronous asset loader callbacks overriding root visibility flags.Scene-graph decoupling (rootGroup vs bodyMesh) and update packet queues.
    Fleet Duplication SpawnsClient-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 LeaksRemote player root groups added to scene with default visible = true.Visibility state bound to isStarted flag and synchronized on joined event.
    UI Touch Scroll FailureGlobal 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.

  • world – assets – drive and fly

    hit ‘enter’ key to in car and plane

    FLY manual

    take off – ‘W’ then ‘space bar’
    fly high – S
    fly down – W

    coming soon mobile support!

  • Dev Blog: Engineering the “Infinite Run” – Cinematic

    The Evolution: From Simple Stickmen to Cinematic Neon Giants

    https://mero.live/games/run

    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.

    code JavaScript

    // Hand/Wrist flick logic
    const swingX = Math.sin(t);
    const swingY = Math.cos(t);
    
    this.lHand.rotation.x = swingX * 0.5;   // Wrist flick back/forth
    this.lHand.position.z = swingY * 0.2;   // Subtle depth "drag"
    this.lArm.rotation.z = Math.abs(swingX) * 0.2; // Natural arm flare

    3. “Hero Jump” vs. “Cartoon Flatten”

    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.

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Infinite Run - Director Mode</title>
        <style>
            body { margin: 0; overflow: hidden; background-color: #000; font-family: 'Courier New', Courier, monospace; }
            canvas { display: block; width: 100vw; height: 100vh; cursor: crosshair; }
            #ui-left { position: absolute; top: 15px; left: 15px; color: #00ffcc; background: rgba(0,0,0,0.8); padding: 12px; border-left: 3px solid #00ffcc; pointer-events: none; font-size: 11px; text-transform: uppercase; z-index: 10; }
            #instruction { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); color: #ff00ff; font-weight: bold; text-transform: uppercase; letter-spacing: 2px; pointer-events: none; animation: pulse 1.5s infinite; font-size: 12px; }
            @keyframes pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
            .highlight { color: #fff; font-weight: bold; font-size: 16px; }
            .label { opacity: 0.6; font-size: 9px; display: block; margin-top: 5px; }
        </style>
    </head>
    <body>
    
    <div id="instruction">Tap to Launch Ball | Cinematic Jumps</div>
    <div id="ui-left">
        <div>SYSTEM: <span style="color: #00ff00;">DIRECTOR_V5_STABLE</span></div>
        <span class="label">Total Streamed Data</span>
        <div><span id="total-kb" class="highlight">0.00</span> MB</div>
        <span class="label">Jumps This Roll</span>
        <div><span id="jump-count-ui" class="highlight">0</span> / <span id="jump-max-ui">0</span></div>
        <span class="label">Active Runners</span>
        <div id="unit-count" class="highlight">0</div>
    </div>
    
    <script type="importmap">
        { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
    </script>
    
    <script type="module">
        import * as THREE from 'three';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
        import { Reflector } from 'three/addons/objects/Reflector.js';
        import Stats from 'three/addons/libs/stats.module.js';
    
        let totalKB = 0, jumpsThisRoll = 0, maxJumpsForThisRoll = 0, ballActive = false;
        const uiTotal = document.getElementById('total-kb'), uiJumpCount = document.getElementById('jump-count-ui'), uiJumpMax = document.getElementById('jump-max-ui'), uiUnits = document.getElementById('unit-count');
    
        function logData(kb) { totalKB += kb; uiTotal.innerText = (totalKB / 1024).toFixed(2); }
    
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x020205);
        scene.fog = new THREE.FogExp2(0x020205, 0.006);
        const camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 1000);
        let camTargetPos = new THREE.Vector3(0, 30, 100), lookTargetSmooth = new THREE.Vector3();
    
        const renderer = new THREE.WebGLRenderer({ antialias: false });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(window.devicePixelRatio);
        document.body.appendChild(renderer.domElement);
        const stats = new Stats();
        stats.dom.style.left = 'auto'; stats.dom.style.right = '0px';
        document.body.appendChild(stats.dom);
    
        const clock = new THREE.Clock(), raycaster = new THREE.Raycaster(), mouse = new THREE.Vector2();
        scene.add(new THREE.AmbientLight(0xffffff, 0.2));
        const sun = new THREE.DirectionalLight(0xffffff, 2.5);
        sun.position.set(50, 100, 50); scene.add(sun);
    
        const ground = new Reflector(new THREE.PlaneGeometry(2000, 2000), { clipBias: 0.003, textureWidth: 512, textureHeight: 512, color: 0x111111 });
        ground.rotateX(-Math.PI / 2); scene.add(ground);
    
        const headGeo = new THREE.SphereGeometry(1.2, 12, 12), bodyGeo = new THREE.CapsuleGeometry(1.0, 2.0, 4, 10), limbGeo = new THREE.CapsuleGeometry(0.35, 1.6, 4, 8); 
        limbGeo.translate(0, -0.8, 0);
        const handGeo = new THREE.SphereGeometry(0.5, 8, 8); 
    
        let runners = [];
        const colors = [0x00ffff, 0xff00ff, 0x00ff88, 0xffff00, 0xff3333];
        const jumpCamAngles = [new THREE.Vector3(40, 15, 0), new THREE.Vector3(-40, 15, 5), new THREE.Vector3(0, 8, 50)];
        let currentJumpAngle = new THREE.Vector3();
    
        class Runner {
            constructor(x, z, speed, color) {
                this.group = new THREE.Group();
                this.mat = new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 2 });
                const body = new THREE.Mesh(bodyGeo, this.mat); body.position.y = 3.2;
                const head = new THREE.Mesh(headGeo, this.mat); head.position.y = 5.6;
                this.lLeg = new THREE.Mesh(limbGeo, this.mat); this.rLeg = new THREE.Mesh(limbGeo, this.mat);
                this.lLeg.position.set(0.7, 2, 0); this.rLeg.position.set(-0.7, 2, 0);
                this.lArm = new THREE.Mesh(limbGeo, this.mat); this.rArm = new THREE.Mesh(limbGeo, this.mat);
                this.lArm.position.set(1.5, 4.2, 0); this.rArm.position.set(-1.5, 4.2, 0);
                this.lHand = new THREE.Mesh(handGeo, this.mat); this.rHand = new THREE.Mesh(handGeo, this.mat);
                this.lHand.position.y = -1.6; this.rHand.position.y = -1.6;
                this.lArm.add(this.lHand); this.rArm.add(this.rHand);
                this.group.add(head, body, this.lLeg, this.rLeg, this.lArm, this.rArm);
                this.group.position.set(x, 0, z);
                this.speed = speed * 120; this.animOffset = Math.random() * 10;
                this.state = 'running'; this.jumpProgress = 0;
                scene.add(this.group); logData(120.4);
            }
            update(elapsed, delta, ball) {
                if (this.state === 'flat') return;
                const distZ = this.group.position.z - ball.position.z, distX = Math.abs(this.group.position.x - ball.position.x);
                if (this.state === 'running' && ballActive && jumpsThisRoll < maxJumpsForThisRoll) {
                    if (distZ < 30 && distZ > 15 && distX < 10) {
                        this.state = 'jumping'; this.jumpProgress = 0; jumpsThisRoll++;
                        uiJumpCount.innerText = jumpsThisRoll;
                        currentJumpAngle.copy(jumpCamAngles[Math.floor(Math.random() * jumpCamAngles.length)]);
                    }
                }
                if (this.state === 'jumping') {
                    this.jumpProgress += delta * 1.8;
                    this.group.position.y = Math.sin(this.jumpProgress * Math.PI) * 26;
                    this.group.rotation.x = -Math.sin(this.jumpProgress * Math.PI) * 0.6;
                    if (this.jumpProgress >= 1.0) { this.state = 'running'; this.group.position.y = 0; this.group.rotation.x = 0; }
                } else {
                    if (distZ < 60 && distZ > 0 && distX < 22) this.group.position.x += (this.group.position.x > ball.position.x ? 1 : -1) * 35 * delta;
                }
                const t = elapsed * 15 + this.animOffset, swingX = Math.sin(t), swingY = Math.cos(t);
                this.lLeg.rotation.x = swingX; this.rLeg.rotation.x = -swingX;
                this.lArm.rotation.x = -swingX * 1.2; this.rArm.rotation.x = swingX * 1.2;
                this.lHand.rotation.x = swingX * 0.5; this.rHand.rotation.x = -swingX * 0.5;
                if (this.state === 'running') this.group.position.y = Math.abs(Math.cos(t * 1.5)) * 0.6;
                this.group.position.z += this.speed * delta;
            }
            flatten() {
                if (this.state === 'flat') return;
                this.state = 'flat'; this.group.scale.set(2.0, 0.02, 2.0); this.group.position.y = 0.05;
                setTimeout(() => { scene.remove(this.group); runners = runners.filter(r => r !== this); }, 3000);
            }
        }
    
        const ballRadius = 8;
        const deathBall = new THREE.Mesh(new THREE.SphereGeometry(ballRadius, 32, 32), new THREE.MeshStandardMaterial({ color: 0x000000, emissive: 0xff0044, emissiveIntensity: 5 }));
        deathBall.position.set(0, ballRadius, -1000); scene.add(deathBall);
    
        function launchBall(event) {
            const x = (event.clientX || (event.touches && event.touches[0].clientX)), y = (event.clientY || (event.touches && event.touches[0].clientY));
            mouse.x = (x / window.innerWidth) * 2 - 1; mouse.y = -(y / window.innerHeight) * 2 + 1;
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObject(ground);
            if (intersects.length > 0) {
                deathBall.position.set(intersects[0].point.x, ballRadius, 140);
                ballActive = true; jumpsThisRoll = 0; maxJumpsForThisRoll = Math.floor(Math.random() * 2) + 1;
                uiJumpCount.innerText = "0"; uiJumpMax.innerText = maxJumpsForThisRoll; logData(480.0);
            }
        }
        window.addEventListener('mousedown', launchBall); window.addEventListener('touchstart', launchBall);
    
        let spawnTimer = 0;
        function spawnSingleRunner() {
            const x = (Math.random() - 0.5) * 120, color = colors[Math.floor(Math.random() * colors.length)];
            runners.push(new Runner(x, -550, 0.18 + Math.random() * 0.1, color));
        }
    
        const composer = new EffectComposer(renderer);
        composer.addPass(new RenderPass(scene, camera));
        composer.addPass(new UnrealBloomPass(new THREE.Vector2(256, 256), 1.3, 0.4, 0.85));
    
        function animate() {
            stats.begin();
            let rawDelta = clock.getDelta();
            const jumpingHero = runners.find(r => r.state === 'jumping');
            const anyoneJumping = !!jumpingHero;
            const bulletTime = (anyoneJumping && jumpingHero.group.position.y > 5) ? 0.2 : 1.0;
            const delta = rawDelta * bulletTime, elapsed = clock.getElapsedTime();
    
            spawnTimer += rawDelta; if (spawnTimer >= 0.4) { spawnSingleRunner(); spawnTimer = 0; }
            deathBall.position.z -= 135 * delta; deathBall.rotation.x -= 10 * delta;
            if (deathBall.position.z < -600) ballActive = false;
    
            for (let i = runners.length - 1; i >= 0; i--) {
                runners[i].update(elapsed, delta, deathBall);
                if (runners[i].group.position.distanceTo(deathBall.position) < ballRadius + 2.2 && runners[i].group.position.y < ballRadius + 1.5) runners[i].flatten();
                if (runners[i].group.position.z > 160) { scene.remove(runners[i].group); runners.splice(i, 1); }
            }
    
            if (anyoneJumping) {
                camTargetPos.set(jumpingHero.group.position.x + currentJumpAngle.x, jumpingHero.group.position.y + currentJumpAngle.y, jumpingHero.group.position.z + currentJumpAngle.z);
                lookTargetSmooth.lerp(jumpingHero.group.position, 0.1);
            } else {
                let avgX = 0; if(runners.length > 0) { runners.forEach(r => avgX += r.group.position.x); avgX /= runners.length; }
                camTargetPos.set(avgX * 0.5, 30, 110); lookTargetSmooth.lerp(new THREE.Vector3(avgX, 10, -20), 0.05);
            }
            camera.position.lerp(camTargetPos, 0.1); camera.lookAt(lookTargetSmooth);
            uiUnits.innerText = runners.length; composer.render(); stats.end();
            requestAnimationFrame(animate);
        }
        window.addEventListener('resize', () => { camera.aspect = window.innerWidth/window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });
        animate();
    </script>
    </body>
    </html>
  • Dev Blog: Engineering the “Infinite Run”

    https://mero.live/build/games/run

    The Journey from Stickmen to Optimized Neon Physics

    stable code

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Glow Balloon Men - No Ghosts</title>
        <style>
            body { margin: 0; overflow: hidden; background-color: #020202; }
            canvas { display: block; }
        </style>
    </head>
    <body>
    
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>
    
    <script type="module">
        import * as THREE from 'three';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
    
        // --- Setup ---
        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.set(0, 12, 22);
        camera.lookAt(0, 0, 0);
    
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(window.devicePixelRatio);
        document.body.appendChild(renderer.domElement);
    
        // Lights for balloon shine
        scene.add(new THREE.AmbientLight(0xffffff, 0.4));
        const dl = new THREE.DirectionalLight(0xffffff, 1);
        dl.position.set(5, 10, 7);
        scene.add(dl);
    
        // --- Glow (Bloom) ---
        const renderScene = new RenderPass(scene, camera);
        const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.8, 0.4, 0.1);
        const composer = new EffectComposer(renderer);
        composer.addPass(renderScene);
        composer.addPass(bloomPass);
    
        const grid = new THREE.GridHelper(100, 40, 0x111111, 0x111111);
        scene.add(grid);
    
        const runners = [];
        const COLLISION_RADIUS = 1.5; // Space they occupy
    
        class BalloonRunner {
            constructor(color) {
                this.group = new THREE.Group();
                this.radius = COLLISION_RADIUS;
                
                const mat = new THREE.MeshPhongMaterial({ 
                    color: color, 
                    emissive: color, 
                    emissiveIntensity: 0.4,
                    transparent: true, 
                    opacity: 0.9, 
                    shininess: 100 
                });
    
                // Balloon Body parts (thick radius)
                const head = new THREE.Mesh(new THREE.SphereGeometry(0.6, 16, 16), mat);
                head.position.y = 2.4;
                const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.5, 0.8, 4, 12), mat);
                torso.position.y = 1.4;
    
                const limbGeo = new THREE.CapsuleGeometry(0.25, 0.7, 4, 10);
                limbGeo.translate(0, -0.35, 0);
    
                this.lArm = new THREE.Mesh(limbGeo, mat);
                this.rArm = new THREE.Mesh(limbGeo, mat);
                this.lLeg = new THREE.Mesh(limbGeo, mat);
                this.rLeg = new THREE.Mesh(limbGeo, mat);
    
                this.lArm.position.set(0.7, 1.8, 0);
                this.rArm.position.set(-0.7, 1.8, 0);
                this.lLeg.position.set(0.3, 0.9, 0);
                this.rLeg.position.set(-0.3, 0.9, 0);
    
                this.group.add(head, torso, this.lArm, this.rArm, this.lLeg, this.rLeg);
                
                // Movement physics
                this.pos = new THREE.Vector3((Math.random()-0.5)*20, 0, (Math.random()-0.5)*15);
                this.vel = new THREE.Vector3((Math.random()-0.5)*0.1, 0, (Math.random()-0.5)*0.1);
                this.animOffset = Math.random() * 10;
                
                scene.add(this.group);
            }
    
            update(time) {
                // Animation
                const t = time * 10 + this.animOffset;
                this.lLeg.rotation.x = Math.sin(t) * 1.0;
                this.rLeg.rotation.x = Math.sin(t + Math.PI) * 1.0;
                this.lArm.rotation.x = Math.sin(t + Math.PI) * 0.7;
                this.rArm.rotation.x = Math.sin(t) * 0.7;
                this.group.position.y = Math.abs(Math.cos(t * 1.5)) * 0.3;
    
                // Apply Velocity
                this.pos.add(this.vel);
                
                // Screen Boundaries (Turn back)
                const limX = 15, limZ = 10;
                if (Math.abs(this.pos.x) > limX) this.vel.x *= -1;
                if (Math.abs(this.pos.z) > limZ) this.vel.z *= -1;
    
                // Update Mesh position and rotation
                this.group.position.x = this.pos.x;
                this.group.position.z = this.pos.z;
                this.group.rotation.y = Math.atan2(this.vel.x, this.vel.z);
            }
        }
    
        // Initialize runners
        const colors = [0x00ffff, 0xff00ff, 0x00ff00, 0xffff00, 0xff3333, 0xffffff];
        for(let i=0; i<colors.length; i++) {
            runners.push(new BalloonRunner(colors[i]));
        }
    
        function handleCollisions() {
            for (let i = 0; i < runners.length; i++) {
                for (let j = i + 1; j < runners.length; j++) {
                    const r1 = runners[i];
                    const r2 = runners[j];
                    
                    // Distance 2D check (on the floor)
                    const dx = r2.pos.x - r1.pos.x;
                    const dz = r2.pos.z - r1.pos.z;
                    const distance = Math.sqrt(dx * dx + dz * dz);
                    const minDist = r1.radius + r2.radius;
    
                    if (distance < minDist) {
                        // Collision detected - push them apart
                        const angle = Math.atan2(dz, dx);
                        const pushX = Math.cos(angle) * 0.05;
                        const pushZ = Math.sin(angle) * 0.05;
    
                        r1.pos.x -= pushX;
                        r1.pos.z -= pushZ;
                        r2.pos.x += pushX;
                        r2.pos.z += pushZ;
    
                        // Swap velocities slightly so they "bounce"
                        const tempX = r1.vel.x;
                        const tempZ = r1.vel.z;
                        r1.vel.x = r2.vel.x;
                        r1.vel.z = r2.vel.z;
                        r2.vel.x = tempX;
                        r2.vel.z = tempZ;
                    }
                }
            }
        }
    
        function animate(time) {
            requestAnimationFrame(animate);
            const seconds = time * 0.001;
    
            handleCollisions();
            runners.forEach(r => r.update(seconds));
    
            composer.render();
        }
    
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
            composer.setSize(window.innerWidth, window.innerHeight);
        });
    
        animate(0);
    </script>
    </body>
    </html>
    Expand

    1. The Core Architecture: Defining the “Balloon”

    Our first challenge was moving away from standard boxy 3D models to something that felt like an inflated balloon.

    The Idea: Use rounded edges and thick limbs to create a “50px radius” feel.
    The Code Change: We transitioned from BoxGeometry to CapsuleGeometry.

    code JavaScript

    // Reusable low-poly geometries to save memory
    const headGeo = new THREE.SphereGeometry(0.6, 8, 8); // Low segments for speed
    const bodyGeo = new THREE.CapsuleGeometry(0.5, 0.8, 2, 8); // Thick "Balloon" torso
    const limbGeo = new THREE.CapsuleGeometry(0.25, 0.6, 2, 6); // Fat limbs

    2. Aesthetics: 90% Opacity & Subtle Glow

    We wanted the runners to look like neon-infused rubber. This required a balance between transparency and emissive light.

    The Logic:

    • Opacity: Set to 0.9 so they aren’t fully solid, giving a light, airy balloon feel.
    • Shininess: High specular values to make them look like polished plastic.
    • Glow: Using UnrealBloomPass but with a low strength to prevent “blinding” the user.

    code JavaScript

    const mat = new THREE.MeshPhongMaterial({ 
        color: color, 
        emissive: color, 
        emissiveIntensity: 0.3, // Soft internal glow
        transparent: true, 
        opacity: 0.9,           // 90% Visibility
        shininess: 100          // Balloon-like plastic shine
    });

    3. Dealing with “Ghosts”: Simple Physics

    In early versions, the runners passed through each other. To make them feel like real objects, we had to implement a collision system without the overhead of a heavy physics engine like Cannon.js.

    The Solution: A 2D Distance Check.
    Even though the world is 3D, runners only move on the floor. We treated them as 2D circles.

    code JavaScript

    function handleCollisions() {
        for (let i = 0; i < runners.length; i++) {
            for (let j = i + 1; j < runners.length; j++) {
                const r1 = runners[i], r2 = runners[j];
                const dx = r2.pos.x - r1.pos.x;
                const dz = r2.pos.z - r1.pos.z;
                const distance = Math.sqrt(dx * dx + dz * dz);
    
                if (distance < 1.8) { // Collision threshold
                    // Calculate push vector to separate them
                    const angle = Math.atan2(dz, dx);
                    r1.pos.x -= Math.cos(angle) * 0.05;
                    r1.pos.z -= Math.sin(angle) * 0.05;
                    
                    // Swap velocities (The "Bounce")
                    [r1.vel.x, r2.vel.x] = [r2.vel.x, r1.vel.x];
                    [r1.vel.z, r2.vel.z] = [r2.vel.z, r1.vel.z];
                }
            }
        }
    }

    4. The “Infinite Run” Cycle

    The user wanted a scene that never ends, with different numbers of men and different speeds every time.

    The Lifecycle Logic:

    1. Spawn: Men are created far away (Z = -60).
    2. Variability: Every run, we randomize the count (3-10 men) and the formation (Line, V-Shape, or Cluster).
    3. The End: When a runner’s Z-position passes the camera (Z > 25), they are marked “Finished.”
    4. Reset: Once the whole group is finished, we clear the memory and start again.

    code JavaScript

    function startNewRun() {
        runners.forEach(r => r.remove()); // Memory management: clear old meshes
        runners = [];
    
        const count = Math.floor(Math.random() * 8) + 3; // Random 3 to 10 men
        const speedBase = 0.15 + Math.random() * 0.2;
    
        for (let i = 0; i < count; i++) {
            // Assign random individual speeds so they overtake each other
            const individualSpeed = speedBase + (Math.random() * 0.1);
            runners.push(new BalloonRunner(x, startZ, individualSpeed, color));
        }
    }

    5. Survival Mode: Optimizing for Junk PCs & Firefox

    Old laptops struggle with Three.js because of high-resolution screens (Retina/DPI) and complex post-processing.

    What we did to fix the lag:

    • Resolution Downscaling: We rendered the scene at half the window size and used CSS to stretch it back. This is the #1 performance booster.
    • Locked Pixel Ratio: Forced renderer.setPixelRatio(1) so 4K monitors don’t kill the GPU.
    • Bloom Downsampling: The Glow (Bloom) is calculated on a tiny 256×256 texture, which keeps the frame rate high even on old Firefox versions.

    code JavaScript

    // The "Junk PC" Renderer Setup
    const renderer = new THREE.WebGLRenderer({ antialias: false }); // Antialias is too heavy
    renderer.setSize(window.innerWidth / 2, window.innerHeight / 2, false); // Render half-res
    renderer.setPixelRatio(1); // No high-DPI stress
    
    // Ultra-low-res Bloom
    const bloomPass = new UnrealBloomPass(new THREE.Vector2(256, 256), 0.4, 0.4, 0.1);

    Final Summary

    We took a complex visual concept—glowing, transparent, colliding balloon men—and stripped away all the hardware-heavy “waste.” By reusing geometries, simplifying math to 2D, and forcing the resolution down, we created an infinite 3D race that is both beautiful and accessible to everyone, regardless of their computer’s age.

    This is the final, complete, and fully optimized one-page code.

    It combines the “fat balloon” aesthetic, 90% transparency, subtle neon glow, collision detection (no ghosting), and the infinite “Far-to-Front” run logic. It is specifically tuned to run smoothly on old laptops and Firefox.

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Infinite Run</title>
        <style>
            body { margin: 0; overflow: hidden; background-color: #000; font-family: sans-serif; }
            /* Canvas optimization: CSS helps the browser scale low-res 3D smoothly */
            canvas { display: block; width: 100vw; height: 100vh; }
            #info { position: absolute; top: 10px; left: 10px; color: #444; font-size: 12px; pointer-events: none; }
        </style>
    </head>
    <body>
    <div id="info">Infinite Run: Procedural Generation Active</div>
    
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>
    
    <script type="module">
        import * as THREE from 'three';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
    
        // --- 1. OPTIMIZED ENGINE SETUP ---
        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200);
        camera.position.set(0, 6, 25);
        camera.lookAt(0, 2, 0);
    
        const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(1); // Crucial: Prevents lag on high-DPI/Retina screens
        document.body.appendChild(renderer.domElement);
    
        // Simple lighting for balloon "shine"
        scene.add(new THREE.AmbientLight(0xffffff, 0.4));
        const dl = new THREE.DirectionalLight(0xffffff, 1);
        dl.position.set(5, 10, 7);
        scene.add(dl);
    
        // --- 2. SUBTLE GLOW (Optimized for old Firefox) ---
        const renderScene = new RenderPass(scene, camera);
        // Low resolution (256) for bloom makes it very fast
        const bloomPass = new UnrealBloomPass(new THREE.Vector2(256, 256), 0.4, 0.4, 0.1);
        const composer = new EffectComposer(renderer);
        composer.addPass(renderScene);
        composer.addPass(bloomPass);
    
        // --- 3. REUSABLE BALLOON GEOMETRY ---
        // Low polygon counts (segments) to help "junk" PCs
        const headGeo = new THREE.SphereGeometry(0.6, 10, 10);
        const bodyGeo = new THREE.CapsuleGeometry(0.5, 0.8, 2, 8);
        const limbGeo = new THREE.CapsuleGeometry(0.25, 0.7, 2, 6);
        limbGeo.translate(0, -0.35, 0); // Set pivot for swinging
    
        let runners = [];
        const colors = [0x00ffff, 0xff00ff, 0x00ff00, 0xffff00, 0xff3333, 0xffffff];
    
        class BalloonRunner {
            constructor(x, z, speed, color) {
                this.group = new THREE.Group();
                this.radius = 1.2; // For collision detection
                
                const mat = new THREE.MeshPhongMaterial({ 
                    color: color, 
                    emissive: color, 
                    emissiveIntensity: 0.3,
                    transparent: true, 
                    opacity: 0.9, 
                    shininess: 80 
                });
    
                this.head = new THREE.Mesh(headGeo, mat);
                this.head.position.y = 2.4;
                this.torso = new THREE.Mesh(bodyGeo, mat);
                this.torso.position.y = 1.4;
    
                this.lArm = new THREE.Mesh(limbGeo, mat);
                this.rArm = new THREE.Mesh(limbGeo, mat);
                this.lLeg = new THREE.Mesh(limbGeo, mat);
                this.rLeg = new THREE.Mesh(limbGeo, mat);
    
                this.lArm.position.set(0.7, 1.8, 0);
                this.rArm.position.set(-0.7, 1.8, 0);
                this.lLeg.position.set(0.3, 0.9, 0);
                this.rLeg.position.set(-0.3, 0.9, 0);
    
                this.group.add(this.head, this.torso, this.lArm, this.rArm, this.lLeg, this.rLeg);
                
                this.group.position.set(x, 0, z);
                this.speed = speed;
                this.animOffset = Math.random() * 10;
                this.isDone = false;
    
                scene.add(this.group);
            }
    
            update(time) {
                const t = time * 12 + this.animOffset;
                // Running Animation
                this.lLeg.rotation.x = Math.sin(t) * 1.0;
                this.rLeg.rotation.x = Math.sin(t + Math.PI) * 1.0;
                this.lArm.rotation.x = Math.sin(t + Math.PI) * 0.7;
                this.rArm.rotation.x = Math.sin(t) * 0.7;
                this.group.position.y = Math.abs(Math.cos(t * 1.5)) * 0.3;
    
                // Move Forward (Toward Camera)
                this.group.position.z += this.speed;
    
                // If passed camera, mark for removal
                if (this.group.position.z > 30) {
                    this.isDone = true;
                }
            }
    
            destroy() {
                scene.remove(this.group);
                // Help garbage collection
                this.group.traverse(child => {
                    if (child.material) child.material.dispose();
                });
            }
        }
    
        // --- 4. INFINITE RUN LOGIC ---
        function startNewRun() {
            // Clear memory
            runners.forEach(r => r.destroy());
            runners = [];
    
            // Randomly decide group traits
            const count = Math.floor(Math.random() * 7) + 3; // 3 to 10 men
            const formation = Math.floor(Math.random() * 3); // 0=Line, 1=V, 2=Chaos
            const baseSpeed = 0.12 + Math.random() * 0.2;
            const startZ = -70; // Start far away
    
            for (let i = 0; i < count; i++) {
                let xPos = 0;
                let zOffset = 0;
    
                if (formation === 0) { // Line
                    xPos = (i - count / 2) * 3.5;
                    zOffset = Math.random() * 5;
                } else if (formation === 1) { // V-Shape
                    xPos = (i - count / 2) * 4;
                    zOffset = Math.abs(xPos) * 1.5;
                } else { // Chaos Cluster
                    xPos = (Math.random() - 0.5) * 25;
                    zOffset = Math.random() * 20;
                }
    
                const runnerSpeed = baseSpeed + (Math.random() * 0.1);
                const color = colors[Math.floor(Math.random() * colors.length)];
                
                runners.push(new BalloonRunner(xPos, startZ - zOffset, runnerSpeed, color));
            }
        }
    
        // --- 5. COLLISION DETECTION (No Ghosts) ---
        function solveCollisions() {
            for (let i = 0; i < runners.length; i++) {
                for (let j = i + 1; j < runners.length; j++) {
                    const a = runners[i];
                    const b = runners[j];
                    
                    const dx = b.group.position.x - a.group.position.x;
                    const dz = b.group.position.z - a.group.position.z;
                    const dist = Math.sqrt(dx * dx + dz * dz);
                    const minDist = 1.8;
    
                    if (dist < minDist) {
                        const angle = Math.atan2(dz, dx);
                        const tx = Math.cos(angle) * 0.05;
                        const tz = Math.sin(angle) * 0.05;
                        // Push them apart
                        a.group.position.x -= tx;
                        a.group.position.z -= tz;
                        b.group.position.x += tx;
                        b.group.position.z += tz;
                        // Switch speeds slightly so they don't stay stuck
                        const temp = a.speed;
                        a.speed = b.speed;
                        b.speed = temp;
                    }
                }
            }
        }
    
        // --- 6. ANIMATION LOOP ---
        startNewRun();
    
        function animate(time) {
            requestAnimationFrame(animate);
            const seconds = time * 0.001;
    
            solveCollisions();
    
            let activeCount = 0;
            runners.forEach(r => {
                r.update(seconds);
                if (!r.isDone) activeCount++;
            });
    
            // Reset scene when all runners pass the front
            if (activeCount === 0) {
                startNewRun();
            }
    
            composer.render();
        }
    
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
            composer.setSize(window.innerWidth, window.innerHeight);
        });
    
        animate(0);
    </script>
    </body>
    </html>
  • world – car.glb

    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.

  • World – 7th MAZE & PERSISTENCE – The Labyrinth Update

    Maze

    world.officialstupid.me

    Following the chaos of Thunder Mode, our 7th update focused on structure, persistence, and UX refinement. We introduced a Procedural Maze System, extended our Ghost Persistence to 24 hours, and performed “UX Subtraction” to ensure mobile controls don’t get in the way of gameplay.

    1. Procedural Architecture: The Recursive Labyrinth

    Instead of static maps, we implemented a Recursive Backtracking Algorithm to generate a unique 25×25 grid every time a session begins. To make it a true challenge, we punched only two exits (North and South), forcing players to navigate deep into the core before finding a way out.

    The “No-Jump” Constraint:
    To prevent players from simply hopping over the challenge, we dynamically disabled the Jump logic whenever Maze Mode is active. This forces a grounded, tactical experience where movement speed and memory are the only tools for survival.

    2. UX Subtraction: Fixing Mobile Friction

    One of the biggest issues in Round 6 was “Control Interference.” On mobile, players frequently swiped down while trying to turn, accidentally triggering the Camera Flip, which inverted their controls and caused them to run directly into lightning bolts.

    The Solution:

    • Removed Swipe-Down Flip: We completely stripped the camera-flip gesture from mobile and the ‘V’ key from the maze. In a high-stakes maze or storm, the player needs a consistent perspective.
    • The 45px Centering: We refined the landing page UI by calculating a precise dual-toggle layout. By offsetting the Thunder and Maze buttons by 45px from the center, we created a balanced, professional “Game Mode” selector that disappears once the player enters the world.

    3. Camera Engineering: Preventing “Wall Cheating”

    In early testing, the camera was too far back, allowing players to see over the walls and easily locate the exit. We re-engineered the camera for a “Close-In” feel.

    code JavaScript

    // Optimized Maze Perspective
    const mazeOn = maze && maze.enabled;
    const h = mazeOn ? 4 : 6;  // Lower height to stay inside the hallways
    const d = mazeOn ? 6 : 12; // Shorter distance to block the "long view"
    
    const camOffset = new THREE.Vector3(0, h, -d).applyQuaternion(localPlayer.quaternion);
    camera.position.lerp(localPlayer.position.clone().add(camOffset), 0.1);

    To balance this difficulty, we added the ‘M’ Map Cheat. Pressing ‘M’ flies the camera 180 units into the sky. To prevent a “white-out” from the scene fog, we implemented a Dynamic Fog Override:

    • Normal: Fog end = 150 (Atmospheric).
    • Map View: Fog end = 2000 (Clear overview).

    4. Persistence: The 24-Hour Ghost System

    A world feels empty if you only see who is currently online. We updated the Node.js server to transform every disconnected player into a Persistent Ghost.

    These ghosts stay at their last known coordinate for 24 hours. They are rendered with 50% opacity and marked as “(OFFLINE),” serving as a historical record of who has visited the world. If a player rejoins with the same name, the server automatically “cleans” their old ghost to prevent duplicates.

    5. The Victory Loop: High-Precision Timers

    Competitive play requires data. We integrated a millisecond-accurate timer that triggers the moment “START” is clicked.

    code JavaScript

    // Victory Logic in maze.js
    const timeTaken = ((Date.now() - this.startTime) / 1000).toFixed(2);
    const name = player.userData.name || "Explorer";
    
    this.ui.msg.innerHTML = `
        <div style="font-size:24px;">🏆 ${name} ESCAPED!</div>
        <div style="font-size:18px;">TIME: ${timeTaken}s</div>
    `;

    When a player crosses the maze boundary, the system automatically clears the geometry, displays a victory card, and logs the time, creating a “speed-run” meta-game within the social space.

    The Result

    Round 7 turned our world from a simple “look and see” experience into a “do and survive” platform. By tightening the camera, removing confusing mobile gestures, and adding persistent history through ghosts, we’ve created a world that feels lived-in and challenging.

    The walls are up, and the clock is ticking—can you find the exit in under 60 seconds?

    https://aistudio.google.com