In this tutorial we build a small physics toy with three.js: a closed box with a balance scale inside. An iron weight sits on one side of the beam. Every click drops a handful of falling sand particles, the sand piles up on the beam, and the beam tilts according to the torque on each side. Your job is to pour just enough sand to level it. Each level also picks a random amount of sand per click, so you have to plan your pours. A Reset button clears the sand and starts a new level with a new random weight and sand amount.
Play it first, then we will walk through how every part works.
You can also play it full-size on the Sand Balance game page.
How the game logic fits together
Every frame follows the same path: input changes the shared state, fixed physics steps move each free grain, torque tilts the beam, and three.js draws the result. The sections below walk through each box in this chart.
What you will learn
- Setting up an orthographic three.js scene for a 2D game
- Rendering thousands of sand grains with a single
THREE.Pointscloud - A cheap falling-sand pile using column heights (angle of repose)
- Parenting objects to a rotating
THREE.Groupand converting between world and local coordinates - Computing torque to decide which side of the balance goes down
- A fixed-timestep game loop so physics runs at the same speed on every screen
1. An orthographic camera for a 2D world
Sand games are 2D, so we skip perspective and use OrthographicCamera. The camera bounds become our world units: the box is 100 units wide and 60 units tall, with the origin at the bottom centre. That makes the physics maths easy, because one unit is the same size everywhere on screen.
// ---- World setup: a closed box 100 x 60 units, origin at the bottom centre ----
const W = 100, H = 60, G = 0.7; // world size and grain size
const MAX_GRAINS = 6000;
const GRAVITY = -60;
const PIVOT = new THREE.Vector2(0, 18); // fulcrum of the balance
const BEAM_HALF = 36, BEAM_THICK = 1.2; // beam is 72 units long
const LIP_GRAINS = 9; // pan lips hold this many grains high
const BINS = Math.floor((BEAM_HALF * 2) / G);
const FLOOR_BINS = Math.floor(W / G);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
const camera = new THREE.OrthographicCamera(-W / 2, W / 2, H, 0, -10, 10);
// Box walls
const wallMat = new THREE.LineBasicMaterial({ color: 0x00c2ff });
const box = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-W / 2 + 0.2, 0.2, 0), new THREE.Vector3(W / 2 - 0.2, 0.2, 0),
new THREE.Vector3(W / 2 - 0.2, H - 0.2, 0), new THREE.Vector3(-W / 2 + 0.2, H - 0.2, 0),
new THREE.Vector3(-W / 2 + 0.2, 0.2, 0)]);
scene.add(new THREE.Line(box, wallMat));
The blue THREE.Line draws the walls of the closed environment. Grains bounce off the side walls and pile up on the floor, so nothing ever leaves the box.
2. The balance beam as a rotating group
The beam, its two pan lips and the iron weight are all children of one THREE.Group placed at the pivot. Rotating the group with beam.rotation.z tilts everything on it at once.
// Beam group: everything on the beam lives in its local space and rotates with it
const beam = new THREE.Group();
beam.position.set(PIVOT.x, PIVOT.y, 0);
scene.add(beam);
const beamMat = new THREE.MeshBasicMaterial({ color: 0xb14dff });
const plank = new THREE.Mesh(new THREE.PlaneGeometry(BEAM_HALF * 2, BEAM_THICK), beamMat);
plank.position.y = BEAM_THICK / 2;
beam.add(plank);
[-1, 1].forEach(s => {
const lip = new THREE.Mesh(new THREE.PlaneGeometry(0.8, LIP_GRAINS * G), beamMat);
lip.position.set(s * (BEAM_HALF + 0.4), BEAM_THICK + LIP_GRAINS * G / 2, 0);
beam.add(lip);
});
const weightMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ color: 0x8a95a8 }));
beam.add(weightMesh);
3. Thousands of grains in one draw call
Creating a mesh per grain would be far too slow. Instead every grain is one vertex in a single BufferGeometry, drawn with THREE.Points. Each frame we write the positions into a Float32Array and set needsUpdate = true. Per-vertex colours give the sand a natural speckled look.
// Sand grains rendered as one Points cloud
const positions = new Float32Array(MAX_GRAINS * 3);
const colors = new Float32Array(MAX_GRAINS * 3);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const pointMat = new THREE.PointsMaterial({ size: 4, vertexColors: true, sizeAttenuation: false });
const points = new THREE.Points(geo, pointMat);
scene.add(points);
With an orthographic camera, PointsMaterial.size is measured in pixels, so we rescale it whenever the canvas is resized to keep grains the same size as a world cell:
function resize() {
const w = stage.clientWidth;
renderer.setSize(w, w * 3 / 5, false);
pointMat.size = Math.max(2, G * w / W * 1.2); // ortho camera: point size is in pixels
}
window.addEventListener('resize', resize);
4. Spawning sand on every click
A click converts the mouse position into world X and spawns a burst of grains near the top of the box. The burst size is not fixed: reset() picks a random amount between 10 and 50 grains for each level, and holding the button pours a steady stream at a matching rate. Moving the mouse moves the stream.
function spawn(x, n) {
n = Math.min(n, MAX_GRAINS - grains.length);
for (let i = 0; i < n; i++) {
const c = 0.75 + Math.random() * 0.25;
grains.push({ x: x + (Math.random() - 0.5) * 2, y: H - 2 - Math.random() * 2,
vx: (Math.random() - 0.5) * 4, vy: -Math.random() * 5,
state: FREE, bin: 0, level: 0, r: c, g: c * 0.78, b: c * 0.45 });
}
}
// Input: click spawns a burst, holding keeps pouring
function worldX(e) {
const r = renderer.domElement.getBoundingClientRect();
return ((e.clientX - r.left) / r.width - 0.5) * W;
}
stage.addEventListener('pointerdown', e => { pourX = worldX(e); spawn(pourX, burst); pouring = true; });
stage.addEventListener('pointermove', e => { if (pouring) pourX = worldX(e); });
window.addEventListener('pointerup', () => { pouring = false; });
document.getElementById('sb-reset').addEventListener('click', reset);
5. World and beam-local coordinates
A falling grain lives in world space, but the pile on the beam lives in the beam’s own rotated space. Two small helpers rotate a point by the beam angle around the pivot. To test a collision we convert the grain into beam-local space, where the beam is always flat and the check becomes a simple height comparison.
// Convert world <-> beam-local coordinates
function toLocal(x, y) {
const dx = x - PIVOT.x, dy = y - PIVOT.y, c = Math.cos(-angle), s = Math.sin(-angle);
return [dx * c - dy * s, dx * s + dy * c];
}
function toWorld(lx, ly) {
const c = Math.cos(angle), s = Math.sin(angle);
return [PIVOT.x + lx * c - ly * s, PIVOT.y + lx * s + ly * c];
}
const binX = i => -BEAM_HALF + (i + 0.5) * G;
6. Making sand pile up like sand
Real falling-sand games often simulate every cell of a grid. For a balance we only need the shape of the piles, so we use a lighter trick: the beam is split into columns (“bins”) one grain wide, and each bin stores how many grains are stacked in it. When a grain lands, it checks its neighbours. If a neighbour is more than one grain lower, the grain slides into it and checks again. That rule produces the familiar sand cone with a natural angle of repose.
The end bins have lips. If a pile grows taller than the lip, the grain spills over the edge, becomes a free particle again and falls to the floor, where it counts as spilled and no longer weighs on the scale.
// Sand slides down to a lower neighbour like a real pile (angle of repose)
function settleOnBeam(p, i) {
for (;;) {
const l = i > 0 ? beamBins[i - 1] : (beamBins[i] >= LIP_GRAINS ? -1 : Infinity);
const r = i < BINS - 1 ? beamBins[i + 1] : (beamBins[i] >= LIP_GRAINS ? -1 : Infinity);
const low = Math.min(l, r);
if (low >= beamBins[i] - 1) break;
const next = (l === r ? (Math.random() < 0.5 ? -1 : 1) : (l < r ? -1 : 1));
if (low === -1) { // spills over the lip
const [wx, wy] = toWorld(binX(i) + next * 1.5, BEAM_THICK + (beamBins[i] + 1) * G);
p.x = wx; p.y = wy; p.vx = next * 6; p.vy = 2; p.state = FREE; p.spill = true;
return;
}
i += next;
}
p.state = ON_BEAM; p.bin = i; p.level = beamBins[i]++;
}
7. The physics step
Each free grain gets gravity, moves, bounces off the walls, and is then tested against the beam pile (in local space) and the floor pile. The ply > -0.5 check makes sure the grain was above the plank on the previous step, so grains cannot tunnel through a pile that grew under them.
function step(dt) {
if (pouring) spawn(pourX, pourRate);
for (const p of grains) {
if (p.state !== FREE) continue;
const [, ply] = toLocal(p.x, p.y);
p.vy += GRAVITY * dt;
p.x += p.vx * dt; p.y += p.vy * dt;
// Closed box: bounce off the side walls
if (p.x < -W / 2 + G) { p.x = -W / 2 + G; p.vx = Math.abs(p.vx) * 0.3; }
if (p.x > W / 2 - G) { p.x = W / 2 - G; p.vx = -Math.abs(p.vx) * 0.3; }
// Did it land on the beam (or the pile already on it)?
const [lx, ly] = toLocal(p.x, p.y);
if (Math.abs(lx) < BEAM_HALF) {
const i = Math.min(BINS - 1, Math.floor((lx + BEAM_HALF) / G));
const top = BEAM_THICK + beamBins[i] * G;
const wasAbove = ply > -0.5; // was above the plank last step
if (ly <= top) if (wasAbove) { settleOnBeam(p, i); continue; }
}
// Otherwise it piles up on the floor
const fi = Math.max(0, Math.min(FLOOR_BINS - 1, Math.floor((p.x + W / 2) / G)));
if (p.y <= floorBins[fi] * G) settleOnFloor(p, fi);
}
8. Torque decides which side goes down
Each grain weighs 1 g. A grain’s torque is its mass times its distance from the pivot (its lever arm). We add up the torque on each side, including the iron weight, and the difference tells us which way the beam should tilt. The beam is driven towards a target angle by a damped spring, so it eases into place and wobbles a little like a real scale. Math.tanh keeps the tilt within about 18 degrees no matter how lopsided it gets.
// Torque = sum(mass * lever arm). Sand grains weigh 1 g each.
let left = 0, right = 0;
for (let i = 0; i < BINS; i++) {
const t = beamBins[i] * binX(i);
if (t < 0) left -= t; else right += t;
}
if (weight.x < 0) left -= weight.mass * weight.x; else right += weight.mass * weight.x;
const torque = right - left;
// Heavier side goes down; damped spring towards a tilt proportional to the imbalance
const target = -0.32 * Math.tanh(torque / 1500);
angVel += ((target - angle) * 30 - angVel * 8) * dt;
angle += angVel * dt;
beam.rotation.z = angle;
Because torque depends on distance, sand placed far from the pivot counts for more. A 300 g weight near the centre can be balanced by far fewer grains poured near the tip of the other side. Try it!
9. The Reset button
Reset empties every array, levels the beam, and places a new iron weight with a random mass (150–390 g) at a random distance on a random side. It also rolls the sand per click for the level and shows it in the bottom bar.
function reset() {
grains = [];
beamBins = new Array(BINS).fill(0);
floorBins = new Array(FLOOR_BINS).fill(0);
angle = 0; angVel = 0; spilled = 0;
// A random iron weight on one side; the player balances it with sand
const side = Math.random() < 0.5 ? -1 : 1;
weight = { mass: 150 + Math.floor(Math.random() * 25) * 10, x: side * (8 + Math.random() * 22) };
const size = Math.sqrt(weight.mass) * 0.35;
weightMesh.scale.set(size, size, 1);
weightMesh.position.set(weight.x, BEAM_THICK + size / 2, 0);
// Each level also gets a random amount of sand per click (10-50 g); holding pours at a matching rate
burst = 10 + Math.floor(Math.random() * 9) * 5;
pourRate = Math.max(1, Math.round(burst / 15));
document.getElementById('sb-burst').textContent = burst;
geo.setDrawRange(0, 0);
document.getElementById('sb-status').textContent = 'Iron weight: ' + weight.mass + ' g, ' + burst + ' g of sand per click. Balance it!';
document.getElementById('sb-status').classList.remove('ok');
}
10. A fixed-timestep game loop
requestAnimationFrame runs at 60, 120 or 144 Hz depending on the monitor, and slows down in background tabs. To keep the sand falling at the same speed everywhere, we collect real elapsed time in an accumulator and run the physics in fixed 1/60 s steps, then draw once.
// Fixed 60 Hz physics steps so the sand falls at the same speed on any screen
const DT = 1 / 60;
let last = performance.now(), acc = 0;
(function loop(now) {
acc = Math.min(acc + (now - last) / 1000, 0.25); last = now;
while (acc >= DT) { step(DT); acc -= DT; }
draw();
requestAnimationFrame(loop);
})(last);
Full source code
Here is the complete game in one HTML snippet. Save it as an .html file and open it in a browser, or paste it into a WordPress Custom HTML block like this post does. Three.js r128 is loaded from cdnjs.
<div id="sb-game" class="sb-game">
<div class="sb-hud">
<span>Left torque: <b id="sb-left">0</b></span>
<span id="sb-status" class="sb-status">Pour sand to balance the scale</span>
<span>Right torque: <b id="sb-right">0</b></span>
</div>
<div id="sb-stage" class="sb-stage"></div>
<div class="sb-bar">
<span class="sb-hint">Click or hold inside the box to pour sand. Per click: <b id="sb-burst">0</b> g. Grains: <b id="sb-count">0</b> / spilled: <b id="sb-spilled">0</b></span>
<button id="sb-reset" type="button">Reset</button>
</div>
</div>
<style>
.sb-game{max-width:900px;margin:1.5em auto;font-family:ui-monospace,Consolas,monospace;color:#d8f3ff;background:#070b14;border:1px solid #1c2a44;border-radius:10px;padding:10px;box-sizing:border-box}
.sb-hud,.sb-bar{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;font-size:14px;padding:4px 6px}
.sb-status{font-weight:bold;color:#ffd166}
.sb-status.ok{color:#3ef0a0}
.sb-stage{position:relative;width:100%;aspect-ratio:5/3;cursor:crosshair;touch-action:none;border-radius:6px;overflow:hidden}
.sb-stage canvas{display:block;width:100%!important;height:100%!important}
.sb-hint{opacity:.8;font-size:13px}
#sb-reset{background:linear-gradient(90deg,#00c2ff,#b14dff);color:#fff;border:0;border-radius:6px;padding:8px 18px;font:inherit;font-weight:bold;cursor:pointer}
#sb-reset:hover{filter:brightness(1.15)}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function () {
// ---- World setup: a closed box 100 x 60 units, origin at the bottom centre ----
const W = 100, H = 60, G = 0.7; // world size and grain size
const MAX_GRAINS = 6000;
const GRAVITY = -60;
const PIVOT = new THREE.Vector2(0, 18); // fulcrum of the balance
const BEAM_HALF = 36, BEAM_THICK = 1.2; // beam is 72 units long
const LIP_GRAINS = 9; // pan lips hold this many grains high
const BINS = Math.floor((BEAM_HALF * 2) / G);
const FLOOR_BINS = Math.floor(W / G);
const stage = document.getElementById('sb-stage');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
const camera = new THREE.OrthographicCamera(-W / 2, W / 2, H, 0, -10, 10);
// Box walls
const wallMat = new THREE.LineBasicMaterial({ color: 0x00c2ff });
const box = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-W / 2 + 0.2, 0.2, 0), new THREE.Vector3(W / 2 - 0.2, 0.2, 0),
new THREE.Vector3(W / 2 - 0.2, H - 0.2, 0), new THREE.Vector3(-W / 2 + 0.2, H - 0.2, 0),
new THREE.Vector3(-W / 2 + 0.2, 0.2, 0)]);
scene.add(new THREE.Line(box, wallMat));
// Fulcrum (triangle)
const tri = new THREE.Shape();
tri.moveTo(-5, 0); tri.lineTo(5, 0); tri.lineTo(0, PIVOT.y); tri.closePath();
scene.add(new THREE.Mesh(new THREE.ShapeGeometry(tri), new THREE.MeshBasicMaterial({ color: 0x5b6b8c })));
// Beam group: everything on the beam lives in its local space and rotates with it
const beam = new THREE.Group();
beam.position.set(PIVOT.x, PIVOT.y, 0);
scene.add(beam);
const beamMat = new THREE.MeshBasicMaterial({ color: 0xb14dff });
const plank = new THREE.Mesh(new THREE.PlaneGeometry(BEAM_HALF * 2, BEAM_THICK), beamMat);
plank.position.y = BEAM_THICK / 2;
beam.add(plank);
[-1, 1].forEach(s => {
const lip = new THREE.Mesh(new THREE.PlaneGeometry(0.8, LIP_GRAINS * G), beamMat);
lip.position.set(s * (BEAM_HALF + 0.4), BEAM_THICK + LIP_GRAINS * G / 2, 0);
beam.add(lip);
});
const weightMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ color: 0x8a95a8 }));
beam.add(weightMesh);
// Sand grains rendered as one Points cloud
const positions = new Float32Array(MAX_GRAINS * 3);
const colors = new Float32Array(MAX_GRAINS * 3);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const pointMat = new THREE.PointsMaterial({ size: 4, vertexColors: true, sizeAttenuation: false });
const points = new THREE.Points(geo, pointMat);
scene.add(points);
// Simulation state
let grains, beamBins, floorBins, angle, angVel, weight, spilled, pouring, pourX, burst, pourRate;
const FREE = 0, ON_BEAM = 1, ON_FLOOR = 2;
function reset() {
grains = [];
beamBins = new Array(BINS).fill(0);
floorBins = new Array(FLOOR_BINS).fill(0);
angle = 0; angVel = 0; spilled = 0;
// A random iron weight on one side; the player balances it with sand
const side = Math.random() < 0.5 ? -1 : 1;
weight = { mass: 150 + Math.floor(Math.random() * 25) * 10, x: side * (8 + Math.random() * 22) };
const size = Math.sqrt(weight.mass) * 0.35;
weightMesh.scale.set(size, size, 1);
weightMesh.position.set(weight.x, BEAM_THICK + size / 2, 0);
// Each level also gets a random amount of sand per click (10-50 g); holding pours at a matching rate
burst = 10 + Math.floor(Math.random() * 9) * 5;
pourRate = Math.max(1, Math.round(burst / 15));
document.getElementById('sb-burst').textContent = burst;
geo.setDrawRange(0, 0);
document.getElementById('sb-status').textContent = 'Iron weight: ' + weight.mass + ' g, ' + burst + ' g of sand per click. Balance it!';
document.getElementById('sb-status').classList.remove('ok');
}
function spawn(x, n) {
n = Math.min(n, MAX_GRAINS - grains.length);
for (let i = 0; i < n; i++) {
const c = 0.75 + Math.random() * 0.25;
grains.push({ x: x + (Math.random() - 0.5) * 2, y: H - 2 - Math.random() * 2,
vx: (Math.random() - 0.5) * 4, vy: -Math.random() * 5,
state: FREE, bin: 0, level: 0, r: c, g: c * 0.78, b: c * 0.45 });
}
}
// Convert world <-> beam-local coordinates
function toLocal(x, y) {
const dx = x - PIVOT.x, dy = y - PIVOT.y, c = Math.cos(-angle), s = Math.sin(-angle);
return [dx * c - dy * s, dx * s + dy * c];
}
function toWorld(lx, ly) {
const c = Math.cos(angle), s = Math.sin(angle);
return [PIVOT.x + lx * c - ly * s, PIVOT.y + lx * s + ly * c];
}
const binX = i => -BEAM_HALF + (i + 0.5) * G;
// Sand slides down to a lower neighbour like a real pile (angle of repose)
function settleOnBeam(p, i) {
for (;;) {
const l = i > 0 ? beamBins[i - 1] : (beamBins[i] >= LIP_GRAINS ? -1 : Infinity);
const r = i < BINS - 1 ? beamBins[i + 1] : (beamBins[i] >= LIP_GRAINS ? -1 : Infinity);
const low = Math.min(l, r);
if (low >= beamBins[i] - 1) break;
const next = (l === r ? (Math.random() < 0.5 ? -1 : 1) : (l < r ? -1 : 1));
if (low === -1) { // spills over the lip
const [wx, wy] = toWorld(binX(i) + next * 1.5, BEAM_THICK + (beamBins[i] + 1) * G);
p.x = wx; p.y = wy; p.vx = next * 6; p.vy = 2; p.state = FREE; p.spill = true;
return;
}
i += next;
}
p.state = ON_BEAM; p.bin = i; p.level = beamBins[i]++;
}
function settleOnFloor(p, i) {
for (;;) {
const l = i > 0 ? floorBins[i - 1] : Infinity, r = i < FLOOR_BINS - 1 ? floorBins[i + 1] : Infinity;
const low = Math.min(l, r);
if (low >= floorBins[i] - 1) break;
i += l === r ? (Math.random() < 0.5 ? -1 : 1) : (l < r ? -1 : 1);
}
p.state = ON_FLOOR; p.bin = i; p.level = floorBins[i]++;
if (p.spill) spilled++;
}
function step(dt) {
if (pouring) spawn(pourX, pourRate);
for (const p of grains) {
if (p.state !== FREE) continue;
const [, ply] = toLocal(p.x, p.y);
p.vy += GRAVITY * dt;
p.x += p.vx * dt; p.y += p.vy * dt;
// Closed box: bounce off the side walls
if (p.x < -W / 2 + G) { p.x = -W / 2 + G; p.vx = Math.abs(p.vx) * 0.3; }
if (p.x > W / 2 - G) { p.x = W / 2 - G; p.vx = -Math.abs(p.vx) * 0.3; }
// Did it land on the beam (or the pile already on it)?
const [lx, ly] = toLocal(p.x, p.y);
if (Math.abs(lx) < BEAM_HALF) {
const i = Math.min(BINS - 1, Math.floor((lx + BEAM_HALF) / G));
const top = BEAM_THICK + beamBins[i] * G;
const wasAbove = ply > -0.5; // was above the plank last step
if (ly <= top) if (wasAbove) { settleOnBeam(p, i); continue; }
}
// Otherwise it piles up on the floor
const fi = Math.max(0, Math.min(FLOOR_BINS - 1, Math.floor((p.x + W / 2) / G)));
if (p.y <= floorBins[fi] * G) settleOnFloor(p, fi);
}
// Torque = sum(mass * lever arm). Sand grains weigh 1 g each.
let left = 0, right = 0;
for (let i = 0; i < BINS; i++) {
const t = beamBins[i] * binX(i);
if (t < 0) left -= t; else right += t;
}
if (weight.x < 0) left -= weight.mass * weight.x; else right += weight.mass * weight.x;
const torque = right - left;
// Heavier side goes down; damped spring towards a tilt proportional to the imbalance
const target = -0.32 * Math.tanh(torque / 1500);
angVel += ((target - angle) * 30 - angVel * 8) * dt;
angle += angVel * dt;
beam.rotation.z = angle;
// HUD
document.getElementById('sb-left').textContent = Math.round(left);
document.getElementById('sb-right').textContent = Math.round(right);
document.getElementById('sb-count').textContent = grains.length;
document.getElementById('sb-spilled').textContent = spilled;
const st = document.getElementById('sb-status');
const falling = grains.some(p => p.state === FREE);
const error = Math.abs(torque) / Math.max(left, right);
const won = [!falling, error < 0.02, grains.length > 0].every(Boolean);
if (won) {
st.textContent = 'BALANCED! ' + grains.length + ' grains (' + (error * 100).toFixed(1) + '% off)';
st.classList.add('ok');
} else if (grains.length > 0) {
st.textContent = (torque > 0 ? 'Right' : 'Left') + ' side heavier by ' + (error * 100).toFixed(1) + '%';
st.classList.remove('ok');
}
}
function draw() {
let k = 0;
for (const p of grains) {
let x = p.x, y = p.y;
if (p.state === ON_BEAM) [x, y] = toWorld(binX(p.bin), BEAM_THICK + (p.level + 0.5) * G);
else if (p.state === ON_FLOOR) { x = -W / 2 + (p.bin + 0.5) * G; y = (p.level + 0.5) * G; }
positions[k * 3] = x; positions[k * 3 + 1] = y; positions[k * 3 + 2] = 0;
colors[k * 3] = p.r; colors[k * 3 + 1] = p.g; colors[k * 3 + 2] = p.b;
k++;
}
geo.setDrawRange(0, k);
geo.attributes.position.needsUpdate = true;
geo.attributes.color.needsUpdate = true;
renderer.render(scene, camera);
}
// Input: click spawns a burst, holding keeps pouring
function worldX(e) {
const r = renderer.domElement.getBoundingClientRect();
return ((e.clientX - r.left) / r.width - 0.5) * W;
}
stage.addEventListener('pointerdown', e => { pourX = worldX(e); spawn(pourX, burst); pouring = true; });
stage.addEventListener('pointermove', e => { if (pouring) pourX = worldX(e); });
window.addEventListener('pointerup', () => { pouring = false; });
document.getElementById('sb-reset').addEventListener('click', reset);
function resize() {
const w = stage.clientWidth;
renderer.setSize(w, w * 3 / 5, false);
pointMat.size = Math.max(2, G * w / W * 1.2); // ortho camera: point size is in pixels
}
window.addEventListener('resize', resize);
reset(); resize();
// Fixed 60 Hz physics steps so the sand falls at the same speed on any screen
const DT = 1 / 60;
let last = performance.now(), acc = 0;
(function loop(now) {
acc = Math.min(acc + (now - last) / 1000, 0.25); last = now;
while (acc >= DT) { step(DT); acc -= DT; }
draw();
requestAnimationFrame(loop);
})(last);
})();
</script>
Ideas to extend it
- Add a timer and score the player on how few grains they used
- Give the grains different densities (wet sand, gravel) with different colours
- Let sand slide along the beam when it tilts too steeply
- Add a second weight or a moving pivot for harder levels




Leave a Reply