Three.js Falling Sand: Build a Balance-the-Scale Physics Game

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.

Left torque: 0 Pour sand to balance the scale Right torque: 0
Click or hold inside the box to pour sand. Per click: 0 g. Grains: 0 / spilled: 0

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.

Sand Balance // game logic flowchart
One frame: input changes state, fixed physics steps move the sand, torque tilts the beam, then three.js draws it. INPUT // STATE // LOOPClick or hold in the boxspawn(x, burst)10-50 g per click (random)matching rate while heldReset buttonreset(): new levelrandom iron weightrandom sand per clickShared stategrains[ ] beamBins[ ]floorBins[ ] anglerequestAnimationFrameAccumulatorrun fixed 1/60 s stepsclearsaddsstep(dt) // EVERY FREE GRAINGravity and movevy += g dt, x += v dtBounce off box wallstoLocal(): into beam spaceLanded onbeam pile?settleOnBeam()slide to a lower binPile tallerthan the lip?Stacked on beamadds weight to its sideHit floorpile?settleOnFloor()restingSpill over edgefree againyesnonoyesyesno: keep fallingfallsstep()readsBALANCE // HUD // DRAWTorquesum of mass x lever armleft side vs right sideTarget tilt-0.32 tanh(torque / 1500)Damped springsets beam.rotation.zAll settled andwithin 2 percent?BALANCED!Heavierside showndraw()positions to BufferGeometryTHREE.Pointsrenderer.render()after grainsyesnonext frame

What you will learn

  • Setting up an orthographic three.js scene for a 2D game
  • Rendering thousands of sand grains with a single THREE.Points cloud
  • A cheap falling-sand pile using column heights (angle of repose)
  • Parenting objects to a rotating THREE.Group and 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

Written by

GitHub · About Codeonion · Contact

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

>> Related articles

all articles →