// Jenga tower — a Meep example for stable stacking + a shoot-on-click mechanic.
//
// A 10-course Jenga tower — three flat blocks per layer, each layer rotated 90°
// from the one below — stands on a platform until you knock it down. Click
// anywhere to fire a heavy ball from the camera through the cursor and watch
// the courses scatter.
//
// The tower stays up because the PhysicsSystem solver is TGS (Temporal
// Gauss-Seidel): it runs several substeps per fixed tick with position
// integration between them, so contact impulses propagate down the full height
// of the stack each tick instead of leaking energy and letting the tower jitter
// apart. A tall cross-hatched stack settles to rest, goes to sleep, and holds —
// until the ball arrives and the blocks tumble freely.
//
// Sections:
// §1 Tuning constants
// §2 Inertia helpers
// §3 Engine bootstrap physics + rendering, orbital camera
// §4 The platform one Static squashed box
// §5 The tower 10 courses × 3 blocks, alternating 90°
// §6 Shoot on click viewportProjectionRay → spawn a ball
// §7 Despawn what falls off + HUD
import * as THREE from "three";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import { PhysicsSystem } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsSystem.js";
import { ColliderObserverSystem } from "@woosh/meep-engine/src/engine/physics/ecs/ColliderObserverSystem.js";
import { RigidBody } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { BodyKind } from "@woosh/meep-engine/src/engine/physics/ecs/BodyKind.js";
import { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
import { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.js";
import Vector2 from "@woosh/meep-engine/src/core/geom/Vector2.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
//
// A Jenga block in classic 1.5 : 2.5 : 7.5 cm proportions, scaled so three
// blocks laid side by side span the length of one — the rule that makes the
// cross-hatched courses interlock. BLOCK_GAP shaves a sliver off the width so
// the seams between the three blocks of a course stay visible.
const BLOCK_LENGTH = 3; // long horizontal axis, metres
const BLOCK_WIDTH = 1; // short horizontal axis → 3 wide = 1 long
const BLOCK_HEIGHT = 0.6; // vertical thickness (flat, like the real thing)
const BLOCK_GAP = 0.04; // seam between adjacent blocks in a course
const BLOCK_MASS = 1;
const COURSES = 10; // 10 courses × 3 blocks = 30 blocks
const BLOCKS_PER_COURSE = 3;
// Platform: a square, squashed box with its top face at y = 0. Roomy on purpose
// — most knocked blocks tumble and skitter across it (which is the fun part)
// instead of dropping straight off the edge; only the ones that make it past the
// rim fall.
const GROUND_WIDTH = 24;
const GROUND_HEIGHT = 1.5;
const GROUND_DEPTH = 24;
// The ball — a hefty wrecking ball, heavy and fast enough to plough through the
// tower rather than bounce off it.
const BALL_RADIUS = 1;
const BALL_MASS = 8;
const BALL_SPEED = 42; // m/s along the aim ray
const BALL_SPAWN_OFFSET = 2; // spawn this far in front of the camera
// Anything dynamic that falls this far below the platform is recycled. Blocks
// and balls that stay up top are left alone — only what falls off is despawned.
const DESPAWN_Y = -8;
// Warm maple tones, varied per block so the tower reads as stacked wood.
const WOOD_COLORS = [0xd8b777, 0xc9a35b, 0xe0c088, 0xbe9a55, 0xd0ac6a];
const BALL_COLOR = 0x4ef0a8; // Meep brand green
// ─── §2 Inertia helpers ─────────────────────────────────────────────────────
//
// Without a non-zero inverse inertia a body can't rotate (see the rigid-bodies
// doc). A Jenga block is not a cube, so its three principal moments differ —
// it's much easier to spin about its long axis than to tumble end-over-end.
function boxInverseInertia(mass, sx, sy, sz) {
// Solid box, principal moments I = m·(a² + b²)/12 about each axis.
const ix = (mass * (sy * sy + sz * sz)) / 12;
const iy = (mass * (sx * sx + sz * sz)) / 12;
const iz = (mass * (sx * sx + sy * sy)) / 12;
return new Vector3(1 / ix, 1 / iy, 1 / iz);
}
function sphereInverseInertia(mass, radius) {
return 1 / (0.4 * mass * radius * radius); // solid sphere, I = (2/5) m r²
}
// ─── §3 Engine bootstrap ────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine));
const physics = new PhysicsSystem();
config.addSystem(physics);
config.addSystem(new ColliderObserverSystem(physics));
},
});
// Orbital camera: drag to look around the tower, wheel to zoom. A click that
// isn't a drag is a "tap", which we use to shoot (§6).
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
// Framed on the full height of the slender tower — focus near its
// mid-height and pull back enough to keep the top course in shot.
focus: new Vector3(0, 3, 0),
distance: 18,
pitch: 0.6,
yaw: 0.6,
cameraFarDistance: 400,
showFps: false,
});
const ecd = engine.entityManager.dataset;
// ─── §4 The platform ────────────────────────────────────────────────────────
{
const transform = new Transform();
transform.position.set(0, -GROUND_HEIGHT / 2, 0); // top face at y = 0
const body = new RigidBody();
body.kind = BodyKind.Static;
const collider = new Collider();
collider.shape = BoxShape3D.from_size(GROUND_WIDTH, GROUND_HEIGHT, GROUND_DEPTH);
collider.friction = 0.9;
const geometry = new THREE.BoxGeometry(GROUND_WIDTH, GROUND_HEIGHT, GROUND_DEPTH);
const material = new THREE.MeshStandardMaterial({ color: 0x2a313b, roughness: 0.95, metalness: 0 });
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(ShadedGeometry.from(geometry, material))
.build(ecd);
}
// ─── §5 The tower ───────────────────────────────────────────────────────────
//
// 30 Dynamic blocks in 10 courses. Each course is three flat blocks laid side
// by side; the next course is rotated 90° and laid across it, so every block
// rests on the three perpendicular blocks below — the interlocking cross-hatch
// that makes a Jenga tower stand. Geometry, collider shapes and the precomputed
// inverse inertia come in two orientations (long-in-x, long-in-z) and are
// shared, so the whole tower is a handful of draw batches.
const BLOCK_W = BLOCK_WIDTH - BLOCK_GAP; // actual block, slimmed for the seam
// Orientation A: the block's long axis runs along x.
const geomLongX = new THREE.BoxGeometry(BLOCK_LENGTH, BLOCK_HEIGHT, BLOCK_W);
const shapeLongX = BoxShape3D.from_size(BLOCK_LENGTH, BLOCK_HEIGHT, BLOCK_W);
const invILongX = boxInverseInertia(BLOCK_MASS, BLOCK_LENGTH, BLOCK_HEIGHT, BLOCK_W);
// Orientation B: rotated 90° — the long axis runs along z.
const geomLongZ = new THREE.BoxGeometry(BLOCK_W, BLOCK_HEIGHT, BLOCK_LENGTH);
const shapeLongZ = BoxShape3D.from_size(BLOCK_W, BLOCK_HEIGHT, BLOCK_LENGTH);
const invILongZ = boxInverseInertia(BLOCK_MASS, BLOCK_W, BLOCK_HEIGHT, BLOCK_LENGTH);
const woodMaterials = WOOD_COLORS.map(
(color) => new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0 }),
);
for (let course = 0; course < COURSES; course++) {
// Even courses run long-in-x with the three blocks spread along z; odd
// courses are the 90° rotation. Bottom course rests exactly on y = 0.
const longX = (course % 2 === 0);
const y = BLOCK_HEIGHT / 2 + course * BLOCK_HEIGHT;
for (let j = 0; j < BLOCKS_PER_COURSE; j++) {
const offset = (j - 1) * BLOCK_WIDTH; // −1, 0, +1 block-widths
const transform = new Transform();
transform.position.set(longX ? 0 : offset, y, longX ? offset : 0);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = BLOCK_MASS;
body.inverseInertiaLocal.copy(longX ? invILongX : invILongZ);
body.linearDamping = 0.02;
body.angularDamping = 0.04;
const collider = new Collider();
collider.shape = longX ? shapeLongX : shapeLongZ;
collider.friction = 0.8;
collider.restitution = 0;
const material = woodMaterials[(course * BLOCKS_PER_COURSE + j) % woodMaterials.length];
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(ShadedGeometry.from(longX ? geomLongX : geomLongZ, material))
.build(ecd);
}
}
// ─── §6 Shoot on click ──────────────────────────────────────────────────────
//
// A tap (a click that isn't a camera drag) fires a ball. We turn the cursor
// into a world ray with the graphics engine's own projection — no THREE
// Raycaster — and launch the ball from the ray's origin (the camera) along its
// direction.
const ballGeometry = new THREE.SphereGeometry(BALL_RADIUS, 28, 20);
const ballMaterial = new THREE.MeshStandardMaterial({ color: BALL_COLOR, roughness: 0.4, metalness: 0.25 });
const ballInvI = sphereInverseInertia(BALL_MASS, BALL_RADIUS);
const ballShape = SphereShape3D.from(BALL_RADIUS);
let ballsFired = 0;
function fireBall(originX, originY, originZ, dirX, dirY, dirZ) {
// Normalise the aim direction so BALL_SPEED is a true speed.
const len = Math.hypot(dirX, dirY, dirZ) || 1;
const nx = dirX / len, ny = dirY / len, nz = dirZ / len;
const transform = new Transform();
transform.position.set(
originX + nx * BALL_SPAWN_OFFSET,
originY + ny * BALL_SPAWN_OFFSET,
originZ + nz * BALL_SPAWN_OFFSET,
);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = BALL_MASS;
body.inverseInertiaLocal.set(ballInvI, ballInvI, ballInvI);
body.linearVelocity.set(nx * BALL_SPEED, ny * BALL_SPEED, nz * BALL_SPEED);
const collider = new Collider();
collider.shape = ballShape;
collider.friction = 0.4;
collider.restitution = 0.2;
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(ShadedGeometry.from(ballGeometry, ballMaterial))
.build(ecd);
ballsFired++;
}
const ndc = new Vector2();
const raySource = new Vector3();
const rayDirection = new Vector3();
engine.devices.pointer.on.tap.add((position) => {
// Pointer position (viewport pixels) → normalised clip coords → world ray.
engine.graphics.normalizeViewportPoint(position, ndc);
engine.graphics.viewportProjectionRay(ndc.x, ndc.y, raySource, rayDirection);
fireBall(
raySource.x, raySource.y, raySource.z,
rayDirection.x, rayDirection.y, rayDirection.z,
);
});
// ─── §7 Despawn what falls off + HUD ────────────────────────────────────────
const fpsEl = document.getElementById("fps");
const bodiesEl = document.getElementById("bodies");
const shotsEl = document.getElementById("shots");
const dead = [];
let fpsWindow = 0, fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = (nowMs - lastFrameMs) / 1000;
lastFrameMs = nowMs;
// Recycle only the bodies that have fallen off the platform; everything
// still resting up top is left in place.
dead.length = 0;
let liveDynamic = 0;
ecd.traverseEntities([RigidBody, Transform], (body, transform, entity) => {
if (body.kind === BodyKind.Static) return;
liveDynamic++;
if (transform.position.y < DESPAWN_Y) dead.push(entity);
});
for (let i = 0; i < dead.length; i++) ecd.removeEntity(dead[i]);
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
if (fpsEl) fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
if (bodiesEl) bodiesEl.textContent = String(liveDynamic - dead.length);
if (shotsEl) shotsEl.textContent = String(ballsFired);
fpsWindow = 0;
fpsFrames = 0;
}
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jenga tower · Meep</title>
<meta name="robots" content="noindex">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #07090c;
color: #e6edf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.panel {
position: fixed;
z-index: 100;
background: rgba(7, 9, 12, 0.72);
border: 1px solid #1f2731;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
}
.hud {
top: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.82rem;
line-height: 1.7;
color: #9aa5b1;
min-width: 180px;
}
.hud .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.5rem;
}
.hud .value { color: #4ef0a8; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
max-width: 420px;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span><span class="value" id="fps">--</span></div>
<div><span class="label">bodies</span><span class="value" id="bodies">30</span></div>
<div><span class="label">shots</span><span class="value" id="shots">0</span></div>
</div>
<div class="panel legend">
A <strong>30-block Jenga tower</strong> — 10 cross-hatched courses held up by
the TGS solver: a freely-rotating stack settles, sleeps, and stays standing
instead of jittering apart. <strong>Click to fire a ball</strong> from the
camera through the cursor and knock it down. Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Jenga tower",
"description": "A 30-block, 10-course Jenga tower held up by the TGS solver — each course laid 90° across the one below into a stable cross-hatch. Click to fire a heavy ball through it; bodies that fall off the platform are recycled.",
"category": "Physics",
"status": "live",
"order": 8,
"tags": ["physics", "rigid-body", "stacking", "tgs", "raycast", "ecs"],
"sourceHint": "examples-src/jenga/",
"demoUrl": "/examples/jenga/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/jenga",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A stable 10-course Jenga tower (TGS solver). Click to fire a ball through it.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "2.158.0",
"three": "0.136.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# jenga
A 10-course, 30-block Jenga tower that stays standing, plus a shoot-on-click
mechanic: click to fire a heavy ball from the camera through the cursor and
topple it. Blocks (and balls) that fall off the platform are recycled; anything
still resting up top is left alone.
## Run locally
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
Output goes to `../../public/examples/jenga/demo.html`.
## What this demonstrates
- **Stable tall stacking (TGS)** — the solver runs several Temporal Gauss-Seidel
substeps per fixed tick with position integration between them, so contact
impulses propagate down the full height of the stack each tick instead of
leaking energy and jittering it apart. A 10-course, freely-rotating tower
settles to rest, sleeps, and holds — then tumbles naturally when the ball hits.
- **Cross-hatched courses** — each course of three flat blocks is laid 90° across
the course below, so every block rests on the three perpendicular blocks under
it — the interlocking weave that makes a real Jenga tower stand.
- **Shoot on click** — `engine.graphics.viewportProjectionRay(ndcX, ndcY,
origin, direction)` turns the cursor into a world-space aim ray (no
`THREE.Raycaster`); the ball spawns at the ray origin with its velocity set
along the direction.
- **`normalizeViewportPoint`** — converts the pointer's viewport-pixel position
into the normalised clip coordinates `viewportProjectionRay` expects.
- **Per-shape inertia** — a Jenga block is not a cube, so its three principal
moments differ; `boxInverseInertia` computes a real inverse-inertia tensor per
orientation (the default of zero would lock rotation), and the ball gets its
own so it rolls.
- **Targeted despawning** — a per-frame pass removes only the dynamic bodies that
have fallen below the platform, leaving the standing tower untouched.
- **Batching-friendly authoring** — blocks share two geometries, two collider
shapes and a small set of wood materials, so 30 bodies cost only a few draw
batches.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
{
// Copy the committed source thumbnail into the generated gallery folder.
// public/examples/<id>/ is build output (gitignored); thumbnail.png is
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve it.
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/jenga");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/jenga"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});