// Cube wall — a Meep example for stable stacking + a shoot-on-click mechanic.
//
// An 8-wide × 10-tall wall of 80 cubes stands on a platform until you knock it
// down. Click anywhere to fire a heavy ball from the camera through the cursor
// and watch it punch through, cubes tumbling away from the hole.
//
// The wall 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, freely-rotating stack settles to rest, goes to sleep, and holds
// — until the ball arrives and the cubes 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 wall 128 Dynamic cubes, perfectly stacked
// §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 ────────────────────────────────────────────────────
const CUBE = 1; // cube side, metres
const WALL_COLS = 8; // cubes wide (x)
const WALL_ROWS = 10; // cubes tall (y) → 8 × 10 = 80 cubes, one deep
const WALL_Z = 0;
// Platform: a square, squashed box with its top face at y = 0. Roomy on purpose
// — most knocked cubes 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 = 28;
const GROUND_HEIGHT = 1.5;
const GROUND_DEPTH = 28;
// The ball — a hefty wrecking ball, heavy and fast enough to plough through the
// wall 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. Cubes
// and balls that stay up top are left alone — only what falls off is despawned.
const DESPAWN_Y = -8;
const BRICK_COLORS = [0x8a4b3a, 0x9c5a44, 0xa9654c, 0x7c4636];
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). We want the cubes to tumble when struck and the ball to roll.
function cubeInverseInertia(mass, side) {
const i = (mass * side * side) / 6; // solid cube, I = m·s²/6 per axis
return 1 / i;
}
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 wall, 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,
// Off-axis (yaw) and elevated (pitch), but a fairly head-on look at the wall
// — pitch is the elevation angle, so keeping it well under an RTS-style 1.0
// tilts the view down only modestly.
focus: new Vector3(0, 4.5, 0),
distance: 28,
pitch: 0.7,
yaw: 0.55,
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 wall ────────────────────────────────────────────────────────────
//
// 128 Dynamic cubes placed in a perfect grid, each resting exactly on the one
// below. Geometry and the (few) brick materials are shared so the whole wall is
// a handful of draw batches; the collider shape is shared too. The cube inverse
// inertia is precomputed once — every cube is identical.
const cubeGeometry = new THREE.BoxGeometry(CUBE, CUBE, CUBE);
const cubeShape = BoxShape3D.from_size(CUBE, CUBE, CUBE);
const cubeInvI = cubeInverseInertia(1, CUBE);
const brickMaterials = BRICK_COLORS.map(
(color) => new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.02 }),
);
for (let row = 0; row < WALL_ROWS; row++) {
for (let col = 0; col < WALL_COLS; col++) {
const transform = new Transform();
transform.position.set(
(col - (WALL_COLS - 1) / 2) * CUBE, // centred on x = 0
CUBE / 2 + row * CUBE, // bottom row rests on y = 0
WALL_Z,
);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = 1;
body.inverseInertiaLocal.set(cubeInvI, cubeInvI, cubeInvI);
body.linearDamping = 0.02;
body.angularDamping = 0.04;
const collider = new Collider();
collider.shape = cubeShape;
collider.friction = 0.7;
collider.restitution = 0;
// Brick-courses: shift the colour by row so the wall reads as masonry.
const material = brickMaterials[(row + (col & 1)) % brickMaterials.length];
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(ShadedGeometry.from(cubeGeometry, 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>Cube wall · 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">80</span></div>
<div><span class="label">shots</span><span class="value" id="shots">0</span></div>
</div>
<div class="panel legend">
An <strong>80-cube wall</strong> held up by the TGS solver — a tall 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": "Cube wall",
"description": "An 80-cube wall held up by the TGS solver — a tall, freely-rotating stack stays stable. Click to fire a heavy ball through it; bodies that fall off the platform are recycled.",
"category": "Physics",
"status": "live",
"order": 3,
"tags": ["physics", "rigid-body", "stacking", "tgs", "raycast", "ecs"],
"sourceHint": "examples-src/cube-wall/",
"demoUrl": "/examples/cube-wall/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/cube-wall",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A stable 8×16 wall of stacked cubes (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"
}
}
# cube-wall
An 8-wide × 10-tall wall of 80 stacked cubes that stays standing, plus a
shoot-on-click mechanic: click to fire a heavy ball from the camera through the
cursor and knock the wall apart. Cubes (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/cube-wall/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. An 80-cube, freely-rotating wall settles
to rest, sleeps, and holds — then tumbles naturally when the ball hits it.
- **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** — cubes and the ball get real inverse-inertia tensors so
they tumble and roll when struck (the default of zero would lock rotation).
- **Targeted despawning** — a per-frame pass removes only the dynamic bodies
that have fallen below the platform, leaving the standing wall untouched.
- **Batching-friendly authoring** — all cubes share one geometry, one collider
shape, and a small set of brick materials, so 128 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/cube-wall");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/cube-wall"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});