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>