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>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:
- Spawn: Men are created far away (Z = -60).
- Variability: Every run, we randomize the count (3-10 men) and the formation (Line, V-Shape, or Cluster).
- The End: When a runner’s Z-position passes the camera (Z > 25), they are marked “Finished.”
- 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>