How to make a physics game in three.js: a slingshot and a tower to knock down

How to make a physics game like a slingshot game, for a first-time game maker: when a game needs a physics engine, which engines work with three.js, how to add Rapier to one HTML file, and a small game where you drag back a ball and knock down a tower of crates; what to check when objects fall through the floor or fly apart, and how an AI agent with paprika3d replays the same shot and pictures the tower before and after.

A physics game is one where things move the way real objects do: they fall, stack, roll, slide and knock each other over, and the fun comes from that. A slingshot that topples a tower of crates is the classic example. You do not write the falling and toppling yourself. A physics engine, a library that works out how solid objects move and collide, does it, and your game tells it what exists and draws what it says.

This page explains when you need one, which ones work with three.js (a free JavaScript library that draws 3D in a web page), and then builds a small slingshot game on one of them, Rapier, in a single file you can copy.

Do you need a physics engine?

Not always. Ask what moves and what it touches:

three.js has no physics of its own; it draws. The engines people pair with it are all free:

For a first physics game, any of them works; the game below would look the same on each. Pick one and stay with it, since their code is not interchangeable.

How a physics engine fits into a three.js game

The idea that makes everything else make sense: there are two worlds. Rapier keeps one with bodies (the solid things, each with a position, rotation and speed) and colliders (the shape each body has for collisions). three.js keeps the other with meshes (what you see). They know nothing of each other. Every frame, the game does two things in order:

  1. Step the physics world forward by a fixed slice of time, 1/60 s here. Rapier moves every body and settles every collision.
  2. Copy each body's position and rotation onto its mesh, then draw.

A body is dynamic (gravity and collisions move it, like the crates and the ball) or fixed (it never moves, like the ground and the platform). If a crate falls through the floor or a mesh never moves, one of those two steps or one of those two worlds is where the fault is.

The first request to type

Make a slingshot game with three.js and the Rapier physics engine, in one index.html
file. Load three and @dimforge/rapier3d-compat from a CDN with an import map. Side
view: a ball on a post at the left, a tower of 12 crates on a platform at the right.
I press on the ball, drag it back and let go; it flies the opposite way, faster the
further I pulled. Step the physics at a fixed 1/60 s. A crate that falls off the
platform counts as down. Three shots; show shots left and crates down.

The game

Here is a page of the kind that request asks for, written for this guide:

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Slingshot</title>
<style>html,body{margin:0;overflow:hidden;background:#bfe0f5}canvas{display:block;touch-action:none}
#hud{position:fixed;top:14px;left:0;right:0;text-align:center;font:600 20px system-ui;color:#123;pointer-events:none}</style></head>
<body>
<div id="hud">Drag the ball back and let go</div>
<script type="importmap">{"imports":{
  "three":"https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
  "@dimforge/rapier3d-compat":"https://cdn.jsdelivr.net/npm/@dimforge/rapier3d-compat@0.21.0/dist/rapier.mjs"}}</script>
<script type="module">
import * as THREE from 'three';
import RAPIER from '@dimforge/rapier3d-compat';

await RAPIER.init();
const SLING = new THREE.Vector3(-7, 2, 0), MAX_PULL = 3, POWER = 7, SHOTS = 3, STEP = 1 / 60;

// The physics world: gravity, the ground, a platform, and a tower of 12 crates. Rapier moves them; we only draw them.
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
world.createCollider(RAPIER.ColliderDesc.cuboid(40, 0.5, 40).setTranslation(0, -0.5, 0));  // ground
world.createCollider(RAPIER.ColliderDesc.cuboid(2, 0.5, 1.5).setTranslation(6, 0.5, 0));  // platform, top at y = 1
const crates = [];
for (let col = 0; col < 3; col++) for (let row = 0; row < 4; row++) {
  const body = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(5 + col, 1.4 + row * 0.8, 0));
  world.createCollider(RAPIER.ColliderDesc.cuboid(0.4, 0.4, 0.4).setFriction(0.8), body);
  crates.push(body);
}

let ball = null, shots = SHOTS, flying = 0, pull = null, over = false;
const spent = [];                              // balls already shot stay in the world, as bodies the next shot can hit
const down = () => crates.filter(c => c.translation().y < 1).length;   // a crate below the platform's top has fallen off

function shoot(dx, dy) {                       // dx, dy: how far the ball was pulled back from the sling
  if (ball || shots === 0) return;
  const len = Math.hypot(dx, dy), k = len > MAX_PULL ? MAX_PULL / len : 1;
  ball = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(SLING.x + dx * k, SLING.y + dy * k, 0).setCcdEnabled(true));
  world.createCollider(RAPIER.ColliderDesc.ball(0.4).setDensity(4), ball);
  spent.push({ body: ball, mesh: add(new THREE.SphereGeometry(0.4, 24, 16), 0xc0392b, `shot ${SHOTS - shots + 1}`, 0, 0) });
  ball.setLinvel({ x: -dx * k * POWER, y: -dy * k * POWER, z: 0 }, true); // launched the opposite way to the pull
  shots--; flying = 0;
}

function tick() {                              // one physics step of 1/60 s
  world.step();
  if (ball) {
    flying++;
    const v = ball.linvel(), p = ball.translation();
    if ((flying > 60 && Math.hypot(v.x, v.y, v.z) < 0.2) || flying > 240 || p.y < -5) ball = null; // the shot is over: load the next
  }
  over = !ball && (shots === 0 || down() === crates.length);
  hud.textContent = over ? `${down()} of ${crates.length} crates down. Click to play again` : `Shots left ${shots} · crates down ${down()} of ${crates.length}`;
}

// The picture, seen from the side.
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xbfe0f5);
scene.fog = new THREE.Fog(0xbfe0f5, 25, 55);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 4, 14); camera.lookAt(0, 2, 0); camera.updateMatrixWorld(); // ready for a click before the first frame
scene.add(new THREE.HemisphereLight(0xffffff, 0x667755, 2));
const sun = new THREE.DirectionalLight(0xffffff, 2); sun.position.set(-5, 10, 8); scene.add(sun);
const add = (geo, color, name, x, y) => { const m = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color })); m.name = name; m.position.set(x, y, 0); scene.add(m); return m; };
add(new THREE.BoxGeometry(80, 1, 80), 0x7fb069, 'ground', 0, -0.5);
add(new THREE.BoxGeometry(4, 1, 3), 0x8d7b68, 'platform', 6, 0.5);
add(new THREE.CylinderGeometry(0.15, 0.2, SLING.y, 12), 0x6b4226, 'sling', SLING.x, SLING.y / 2 - 0.4);
const crateMeshes = crates.map((c, i) => add(new THREE.BoxGeometry(0.8, 0.8, 0.8), i % 2 ? 0xc48a2c : 0xe0b050, `crate ${i + 1}`, 0, 0));
const ballMesh = add(new THREE.SphereGeometry(0.4, 24, 16), 0xc0392b, 'ball', SLING.x, SLING.y);
const band = new THREE.Line(new THREE.BufferGeometry().setFromPoints([SLING, SLING]), new THREE.LineBasicMaterial({ color: 0x222222 }));
scene.add(band);
const hud = document.getElementById('hud');

// Dragging: the pointer's spot on the plane the game is played in (z = 0) is where the ball is pulled to.
const ray = new THREE.Raycaster(), plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), hit = new THREE.Vector3();
function onPlane(e) {
  ray.setFromCamera({ x: e.clientX / innerWidth * 2 - 1, y: -(e.clientY / innerHeight) * 2 + 1 }, camera);
  return ray.ray.intersectPlane(plane, hit) ? hit.clone() : null;
}
renderer.domElement.addEventListener('pointerdown', e => {
  if (over) return location.reload();
  const p = onPlane(e);
  if (!ball && shots > 0 && p && p.distanceTo(SLING) < 1.5) pull = p.sub(SLING);
});
addEventListener('pointermove', e => { const p = pull && onPlane(e); if (p) pull = p.sub(SLING); });
addEventListener('pointerup', () => { if (pull) shoot(pull.x, pull.y); pull = null; });

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});

// The loop: run one physics step for every 1/60 s that passed, then draw where Rapier says everything is.
let last = null, owed = 0;
renderer.setAnimationLoop(now => {
  owed += last === null ? STEP : Math.min((now - last) / 1000, 0.1);
  last = now;
  while (owed >= STEP - 1e-6) { tick(); owed -= STEP; }
  crates.forEach((c, i) => { crateMeshes[i].position.copy(c.translation()); crateMeshes[i].quaternion.copy(c.rotation()); });
  spent.forEach(b => b.mesh.position.copy(b.body.translation()));
  if (pull) { const k = Math.min(1, MAX_PULL / pull.length()); ballMesh.position.copy(SLING).addScaledVector(pull, k); }
  else ballMesh.position.copy(SLING);
  ballMesh.visible = !ball && shots > 0;
  band.geometry.setFromPoints([SLING, pull ? ballMesh.position : SLING]);
  renderer.render(scene, camera);
});

const r2 = v => Math.round(v * 100) / 100;
window.__game = {
  describe: () => ({ name: 'slingshot', mouse: 'press on the ball, drag back, release', actions: { shoot: '{ dx, dy }: release a pull of dx, dy from the sling' } }),
  observe: () => ({ shots, down: down(), flying: !!ball, ball: ball ? { x: r2(ball.translation().x), y: r2(ball.translation().y) } : null,
    tower: crates.map(c => r2(c.translation().y)), over }),
  act: ({ action, dx = 0, dy = 0 }) => { if (action === 'shoot') shoot(dx, dy); },
  setState: () => {},
  subject: 'ball',
};
</script>
</body></html>

It loads three.js and Rapier from the internet, so saving it as index.html and double-clicking it works while you are online. Rapier is several megabytes, so the first load takes a moment. The window.__game object at the end lets a testing tool read the tower and fire a shot by numbers; a player never sees it.

The parts to recognise, in order:

When it breaks

Letting the agent replay the shot

Physics bugs are hard to describe: "it sort of wobbles and then falls" is all you can tell an AI after watching. paprika3d is a plugin for Claude Code (and an MCP server, a tool server other agents can use) that gives the agent a browser of its own on a paused clock, which moves exactly one 1/60 s frame at a time. So the agent drags the slingshot itself, reads where every crate ended up, and can play the same shot again to see whether a change helped. Two commands in the terminal install it:

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d

The replies below were captured on 2026-10-30 on the game above, on a Linux server with no graphics card, cut to the lines this page discusses with nothing reworded. interact presses on the ball, drags it back and down, releases, and waits 180 frames, with trace following three values every frame:

interact {"steps":[{"mouse":"down","x":214,"y":400},{"mouse":"move","x":140,"y":420,"frames":5},{"mouse":"move","x":60,"y":440,"frames":5},{"mouse":"up","x":60,"y":440},{"wait":180}],"trace":["down","shots","flying"]}

{"changed":{"shots":2,"down":4,"tower":[1.4,2.2,3,0.42,1.4,2.2,3,3.8,1.4,0.4,0.4,0.51]},"logs":["warn: THREE.WARNING: Multiple instances of Three.js being imported."]}
Motion (ball): travelled 5.4 units (0.0232 from start to end), its direction of travel turned 180° right.
mouse up at (60, 440): over ball (Mesh, 16.8 units)
down: 0 → 4 at frame 157; first changed at frame 146
shots: 3 → 2 at frame 12; first changed at frame 12
flying: false until frame 12, true until frame 87, then false

The shot left on frame 12 and 4 crates came off the platform; tower lists every crate's height, so the agent sees which ones (the top of the first column and three of the last). The Motion line is the loaded ball being dragged back and appearing again at the sling for the next shot, and the warning comes from the tool's own copy of three.js, which it loads to name the object under the mouse; neither is a fault in the game.

Played again from a fresh page with the same steps, the reply is the same to the last digit, which is what makes "did my change fix it?" a question with an answer:

open {"url":"index.html","cwd":"<project>"}
interact {"steps":[{"mouse":"down","x":214,"y":400},{"mouse":"move","x":140,"y":420,"frames":5},{"mouse":"move","x":60,"y":440,"frames":5},{"mouse":"up","x":60,"y":440},{"wait":180}],"trace":["down","shots","flying"]}

{"changed":{"shots":2,"down":4,"tower":[1.4,2.2,3,0.42,1.4,2.2,3,3.8,1.4,0.4,0.4,0.51]},"logs":["warn: THREE.WARNING: Multiple instances of Three.js being imported."]}
down: 0 → 4 at frame 157; first changed at frame 146

look with sheet pictures several moments side by side, each on a fresh page: the start, after that one shot, and after all three shots (fired through the game's shoot action):

look {"sheet":[{"label":"start"},{"label":"one shot","steps":[{"mouse":"down","x":214,"y":400},{"mouse":"move","x":60,"y":440,"frames":10},{"mouse":"up","x":60,"y":440},{"wait":180}]},{"label":"three shots","steps":[{"action":"shoot","dx":-2.5,"dy":-0.65},{"wait":260},{"action":"shoot","dx":-2.9,"dy":-0.5},{"wait":260},{"action":"shoot","dx":-2.7,"dy":-1},{"wait":260}]}]}

start: subject ball 0.2%; on screen: ground, platform, crate * ×12, sling, ball; pixels: brightness 73%, colour saturation 32%, 0% near black, 0% blown out
one shot: subject ball 0.2%; on screen: ground, platform, crate * ×12, sling, ball, shot 1; pixels: brightness 73%, colour saturation 32%, 0% near black, 0% blown out
three shots: subject ball 0%; on screen: ground, platform, shot * ×2, crate * ×10, sling; left the view: crate * ×2; pixels: brightness 73%, colour saturation 32%, 0% near black, 0% blown out

After three shots two crates have flown out of the picture and the loaded ball is gone, as it should be with no shots left. And the end of the game, checked with expect:

interact {"steps":[{"action":"shoot","dx":-2.5,"dy":-0.65},{"wait":260},{"action":"shoot","dx":-2.9,"dy":-0.5},{"wait":260},{"action":"shoot","dx":-2.7,"dy":-1},{"wait":260}],"trace":["down","shots"],"expect":{"over":true}}

{"changed":{"shots":0,"down":5,"tower":[1.4,2.2,2.2,1.4,1.4,0.4,0.46,1.89,1.2,0.4,0.4,0.4],"over":true}}
Expect: over true ✓

Rapier's standard build, used here, repeats exactly on one machine. When the plugin is installed, its threejs-game skill also offers game-state, a small library that runs Rapier's deterministic build in a web worker, stepped once per 1/60 s tick, for games with many more bodies than this; three.js physics in a web worker shows it.

What it does not do: it does not choose your engine or tune your game. Whether the launch is too strong or the crates too light is a matter of taste; the tools show what each number does, and you decide by playing.

What to do next