// Keva tower — a Meep example for plank stacking + a shoot-on-click mechanic.
//
// A tapered tower built from hundreds of thin planks, stacked the way KEVA
// planks are: each course is a grid of planks standing on edge, the next course
// laid 90° across it, with flat "decks" capping each section and a narrower
// section stacked on top. Click anywhere to fire a heavy ball from the camera
// through the cursor and bring it down.
//
// The tower is three tapered sections (numx 3→1), each a grid of numz = 3·numx + 1
// rows with the courses laid 90° apart, and a flat deck capping each section. The
// per-section course counts (numy) run base→top 5, 5, 3. This works out to
// 288 bodies — the wider numx-4 and numx-5 base sections were dropped to lighten
// the solver while keeping the tapered silhouette.
//
// Like the cube-wall and Jenga examples, the tower stays up because the
// PhysicsSystem solver is TGS (Temporal Gauss-Seidel): several substeps per
// fixed tick with position integration between them, so contact impulses
// propagate down the full stack each tick instead of leaking energy and
// jittering it apart. It settles, sleeps, and holds — until the ball arrives.
//
// Sections:
// §1 Tuning constants
// §2 Inertia helpers
// §3 Engine bootstrap physics + rendering, orbital camera
// §4 The platform one Static squashed box
// §5 The tower tapered cross-hatched plank courses
// §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 { AmbientOcclusionPostProcessEffect } from "@woosh/meep-engine/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.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 ────────────────────────────────────────────────────
//
// The plank: half-extents (0.02, 0.1, 0.4) / 2
// × 10 = (0.1, 0.5, 2.0), i.e. a 0.2 × 1.0 × 4.0 plank — thin, knee-high, long.
// Planks stand on their thin edge to form the open grid courses.
const HALF = { x: 0.1, y: 0.5, z: 2.0 };
const PLANK_MASS = 1;
const PLANK_FRICTION = 0.8;
// Tapered tower: one section per column count, widest at the base. numx is the
// section's column count, and everything else follows from it — numz = 3·numx + 1
// rows, the footprint is 4·numx wide. COURSES is the `numy` table indexed by numx
// (indices 0 and 5 unused); kept to odd values so the courses align cleanly. The
// built sections run base→top 5, 5, 3 — the wider numx-4 and numx-5 base sections
// are left out for perf; prepend 4 (or 5, 4) to SECTION_COLUMNS to widen the base.
const SECTION_COLUMNS = [3, 2, 1];
const COURSES = [0, 3, 5, 5, 7, 9];
// Platform: a square, squashed box with its top face at y = 0. Roomy on purpose
// — knocked planks scatter and skitter across it instead of dropping straight
// off; only the ones that make it past the rim fall and recycle.
const GROUND_WIDTH = 60;
const GROUND_HEIGHT = 1.5;
const GROUND_DEPTH = 60;
// 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. Planks
// and balls that stay up top are left alone — only what falls off is despawned.
const DESPAWN_Y = -8;
// Two pale-pine tones, swapped per plank so the weave
// of the courses reads clearly.
const PLANK_COLORS = [0xe6cf9a, 0xc9a86b];
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 plank is far from cubic, so its three principal moments differ a lot —
// it spins easily about its long axis but resists tumbling 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));
// Screen-space ambient occlusion — darkens the deep gaps in the plank
// weave so the cross-hatch reads with real depth (see the vehicle example).
config.addPlugin(AmbientOcclusionPostProcessEffect);
},
});
// 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,
// The tower is ~14 units tall — focus near its mid-height and pull back
// enough to keep the wide base and the narrow top in shot.
focus: new Vector3(0, 6.5, 0),
distance: 40,
pitch: 0.5,
yaw: 0.6,
cameraFarDistance: 600,
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 ───────────────────────────────────────────────────────────
//
// Built section by section. Three plank
// orientations recur, so geometry, collider shape and the precomputed inverse
// inertia are shared per orientation (a "kind") and the whole tower costs only a
// handful of draw batches.
//
// longZ — plank standing on edge, long axis along z (the even courses)
// longX — the same plank turned 90°, long axis along x (the odd courses)
// cap — plank laid flat, forming each section's top deck
function makeKind(hx, hy, hz) {
const sx = hx * 2, sy = hy * 2, sz = hz * 2; // full extents from half
return {
hx, hy, hz,
geometry: new THREE.BoxGeometry(sx, sy, sz),
shape: BoxShape3D.from_size(sx, sy, sz),
invI: boxInverseInertia(PLANK_MASS, sx, sy, sz),
};
}
const KIND_LONGZ = makeKind(HALF.x, HALF.y, HALF.z); // (0.1, 0.5, 2.0)
const KIND_LONGX = makeKind(HALF.z, HALF.y, HALF.x); // (2.0, 0.5, 0.1)
const KIND_CAP = makeKind(HALF.z, HALF.x, HALF.y); // (2.0, 0.1, 0.5)
const plankMaterials = PLANK_COLORS.map(
(color) => new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0 }),
);
let plankCount = 0;
let colorToggle = 0;
function spawnPlank(px, py, pz, plank) {
const transform = new Transform();
transform.position.set(px, py, pz);
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = PLANK_MASS;
body.inverseInertiaLocal.copy(plank.invI);
body.linearDamping = 0.02;
body.angularDamping = 0.04;
const collider = new Collider();
collider.shape = plank.shape;
collider.friction = PLANK_FRICTION;
collider.restitution = 0;
const material = plankMaterials[colorToggle];
colorToggle ^= 1; // alternate the two tones per plank
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(ShadedGeometry.from(plank.geometry, material))
.build(ecd);
plankCount++;
}
// One tapering section: numCols × numRows planks per course, numCourses tall,
// each course turned 90° from the last, then a flat deck closing the top.
function buildSection(shiftX, shiftY, shiftZ, numCols, numCourses, numRows) {
const blockWidth = 2 * HALF.z * numCols; // footprint, = 4·numCols
const sectionHeight = 2 * HALF.y * numCourses; // grid height, before the deck
const spacing = (HALF.z * numCols - HALF.x) / (numRows - 1);
// numx/numz are swapped each course so the grid alternates orientation.
let numx = numCols;
let numz = numRows;
for (let i = 0; i < numCourses; i++) {
[numx, numz] = [numz, numx];
const even = (i % 2 === 0);
const plank = even ? KIND_LONGZ : KIND_LONGX;
const y = plank.hy * i * 2;
for (let j = 0; j < numx; j++) {
const x = even ? spacing * j * 2 : plank.hx * j * 2;
for (let k = 0; k < numz; k++) {
const z = even ? plank.hz * k * 2 : spacing * k * 2;
spawnPlank(
x + plank.hx + shiftX,
y + plank.hy + shiftY,
z + plank.hz + shiftZ,
plank,
);
}
}
}
// Close the top with a flat deck of planks tiling the whole footprint.
const cap = KIND_CAP;
const cols = Math.round(blockWidth / (cap.hx * 2));
const rows = Math.round(blockWidth / (cap.hz * 2));
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
spawnPlank(
i * cap.hx * 2 + cap.hx + shiftX,
cap.hy + shiftY + sectionHeight,
j * cap.hz * 2 + cap.hz + shiftZ,
cap,
);
}
}
}
// Stack the sections widest-first, each centred on the tower axis and resting
// on the deck of the one below.
let blockHeight = 0;
let gridBlocks = 0;
for (const numCols of SECTION_COLUMNS) {
const numCourses = COURSES[numCols];
const numRows = numCols * 3 + 1;
const blockWidth = numCols * HALF.z * 2;
buildSection(-blockWidth / 2, blockHeight, -blockWidth / 2, numCols, numCourses, numRows);
blockHeight += numCourses * HALF.y * 2 + HALF.x * 2;
gridBlocks += numCols * numCourses * numRows;
}
// gridBlocks is the keva-block count (the cap-deck planks are excluded).
console.log(`Num keva blocks: ${gridBlocks}; total bodies incl. caps: ${plankCount}`);
// ─── §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>Keva 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: 440px;
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">288</span></div>
<div><span class="label">shots</span><span class="value" id="shots">0</span></div>
</div>
<div class="panel legend">
A <strong>288-body Keva tower</strong> — three tapered sections of
cross-hatched planks standing on edge, capped with flat decks, held up by the
TGS solver: the stack settles, sleeps, and stays standing instead of
jittering apart. <strong>Click to fire a ball</strong> from the camera through
the cursor and bring it down. Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Keva tower",
"description": "A 288-body tapered tower of cross-hatched planks standing on edge with flat capping decks, held up by the TGS solver. Click to fire a heavy ball through it; bodies that fall off the platform are recycled.",
"category": "Physics",
"status": "live",
"order": 9,
"tags": ["physics", "rigid-body", "stacking", "planks", "tgs", "raycast", "ecs"],
"sourceHint": "examples-src/keva-tower/",
"demoUrl": "/examples/keva-tower/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/keva-tower",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A tapered Keva-plank tower (TGS solver), ~716 bodies. 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"
}
}
# keva-tower
A tapered, three-tier Keva tower that stays standing, plus a shoot-on-click
mechanic: click to fire a heavy ball from the camera through the cursor and bring
it down. Planks (and balls) that fall off the platform are recycled; anything
still resting up top is left alone.
The tower is three tapered sections (`numx` 3→1), each a grid of `numz = 3·numx + 1`
rows with the courses laid 90° apart, and a flat deck capping each section. The
per-section course counts (`numy`) run base→top `5, 5, 3` — which works out to
**288 dynamic bodies**. The wider base sections are dropped for perf; prepend `4`
(or `5, 4`) to `SECTION_COLUMNS` to widen the base.
## Run locally
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
Output goes to `../../public/examples/keva-tower/demo.html`.
## What this demonstrates
- **Stable plank 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 stack each tick instead of leaking energy and
jittering it apart. Hundreds of thin planks standing on edge settle to rest,
sleep, and hold — then tumble naturally when the ball hits.
- **The Keva weave** — each course is a grid of planks standing on edge; the next
course is laid 90° across it. The `spacing = (h.z·numCols − h.x) / (numRows − 1)`
and the per-course swap of the column/row counts reproduce the interlocking
weave that lets the open grid carry load.
- **Tapered sections** — sections are stacked widest-first, each centred on the
tower axis and resting on the flat deck that closes the section below.
- **Ambient occlusion** — `config.addPlugin(AmbientOcclusionPostProcessEffect)`
adds screen-space AO so the deep gaps in the plank weave darken, giving the
open lattice real depth (the same one-liner the raycast-vehicle example uses).
- **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.
- **Per-shape inertia** — a plank is far from cubic, so its three principal
moments differ a lot; `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** — planks recur in three orientations, so they
share three geometries, three collider shapes and two materials; all 288
bodies cost only a handful of 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/keva-tower");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/keva-tower"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});