A 3D slingshot siege game: ten toy castles of wood, stone and glass, six kinds of shot (stone, heavy iron, bomb, splitter, fire pot, boulder), and every king has to fall. Built with plain Three.js for the view and cannon-es for rigid-body physics; no models or image files, everything is generated in code.
Architecture
core/ is pure TypeScript with no engine imports: level data (levels.ts) and the aim maths (aim.ts: drag to aim, launch velocity, the trajectory preview, scoring and stars). Unit-tested with Vitest.physics.ts is the cannon-es simulation and nothing else (no Three.js): bodies built from the level data, the shot in flight, damage, breaking, toppled kings and the end of each shot. It steps at a fixed 1/60 s, so the same shot gives the same result at any frame rate; the tests use that to replay shots headless and to prove, with a small search over aims, that every castle can be won with its shots.world.ts is the Three.js view: it builds the diorama, copies body transforms into meshes each frame and draws effects. It owns the renderer, the loop and every GPU resource.index.tsx wires input, the HTML interface (all text, menus, HUD and results are HTML) and the two sides together.
Physics decisions
- cannon-es (about 75 KB gzip before minification) instead of Rapier: Rapier's single-file build inlines its WebAssembly and weighs about 1.65 MB gzip, far over the 700 KB page budget.
- Every block starts asleep, so towers stand perfectly still until the first hit wakes them; cannon-es wakes neighbours as they are struck.
- Damage comes from collision events: impact speed along the contact normal times the square root of the other body's mass (the ground counts with the block's own weight). Glass breaks at a touch, wood takes a solid hit, stone needs the iron ball or a bomb. Broken blocks are removed after the step and burst into chips.
- A broken block is replaced by real physics pieces (cut along its longest sides, finer for glass) that tumble, settle and shrink away after 4.5 s; everything within 3 m wakes up, and anything moving wakes sleeping bodies near it, because cannon-es does not wake a body whose support slides away.
- The courtyard's curtain wall and towers are static bodies, and the top launch speed lands inside them, so no shot can leave the field.
- A king is down when tilted past 45°, dropped 0.6 m, or hit hard enough; his shape is top-heavy (small robe, big head). A shot ends when nothing has moved for half a second (or after 9 s).
- Bombs go off on a tap or Space in flight, or by themselves 1.1 s after their first contact: an impulse and damage that fall off over 3.6 m.
Look
- Materials avoid the plastic look: a sky dome with a sun is baked once through PMREM as image-based light, blocks have bevelled edges (RoundedBoxGeometry), colour textures double as bump maps, and UVs are box-projected in world units so grain and stones keep their size on every face.
- The king is a game character: chibi proportions, big eyes that blink, a moustache, a jewelled crown and a sceptre; scared eyes and an open mouth while a shot flies, X eyes and circling stars once down.
- Fire, dust and smoke are soft sprites (
fx.ts): torch flames with a halo, core and rising embers; the bomb is a fireball with embers and a smoke column; glass throws glints. - Clouds are painted cumulus sprites in the far sky. Lettering: Grenze Gotisch for names and titles, Grenze for the rest, loaded with next/font inside this experiment only; the game waits for them (max 3 s) before it reports ready.
- A toy diorama: meadow with painted vertex colours, a dirt road between rail fences, instanced trees, rocks and distant hills, drifting clouds, a camp with a tent and a waving banner. Wood grain, cut stone and roof tiles are canvas textures drawn at start-up, at twice the resolution on the high tier, with the GPU's maximum anisotropic filtering.
- Toy kings are turned (lathe) robes with ermine trim, a belt, face, beard and a gold crown; roofs carry pennants that ripple.
- The slingshot has a forked stem, leather wraps and grip, sandbags, rubber bands that stretch to the pouch, and a crate showing the shots still to come.
- The interface is painted wood: signboards hung on two nails, peg buttons that press in, a red pennant for the name, gold stars and a carved power gauge.
- Effects are pooled instanced meshes: chips that bounce and shrink, dust puffs, the blast flash with a point light, and the dotted trail of the last shot.
Shots
- Splitter: a tap or Space in flight turns it into three stones fanning out 0.1 rad apart.
- Fire pot: breaks on first contact and sets unburnt wood within 1.9 m alight. Burning wood loses strength each second, can spread to wood within 1.5 m every half second (seeded chance, so tests replay it), and singes kings standing close; stone and glass do not burn. Burnt wood chars dark. A shot with fire runs until the fire is out (max 15 s).
- Boulder: 0.62 m radius and 34 kg: it rolls on and crushes.
- The solver test covers all ten castles, splitting split shots 0.5 s after launch as a player would.
Camera
Menu and results orbit the castle; aiming sits high behind the slingshot; the camera follows the shot and then settles on the castle. Under reduced motion there is one fixed wide framing, no shake, fewer chips and no drifting clouds or flags; the physics is the game and stays.
Performance
- Quality tiers:
low turns off antialiasing and shadows, uses DPR 1 and fewer trees. - The loop stops when the game is off screen or the tab is hidden; leaving the tab mid-level pauses the game.
- Shaders for every shot type are compiled before
onReady, so the first shot never hitches. - Lost WebGL context shows the poster; the canvas stays mounted so the browser can restore it.