Skip to content

Showcase: Spacetime lattice

A 2.5D gravity ballet: planets and a black hole orbit on a projected wireframe lattice whose curvature is the summed gravitational potential. A companion spirals in, the LIGO chirp emerges for free, and the merger flashes through the post-FX. It is the most code of the three simulation showcases — and the least engine-dependent — so this guide is all about the bespoke physics and geometry: by now the shared spine is second nature.

The least of the three: GameLoop + CoroutineManager (@flare-engine/core), SeededRNG + clamp/lerp (@flare-engine/math), a merger ParticlePool (@flare-engine/particles), and the post-FX finish (@flare-engine/postfx).

Everything else is yours. Velocity-Verlet integration, Plummer softening, GR precession, the inspiral drag, the warp lattice, perspective projection, gravitational-wave ripples, gravitational lensing — all bespoke, zero-import, bun-tested TypeScript across spacetime/{nbody,lattice,ripples,starfield}.ts. It runs the loop and adds the glow; the lattice, projection, and lensing are pure application code. That this seam can carry a simulation this heavy is the showcase.

nbody.ts integrates up to a dozen bodies with velocity-Verlet (leapfrog) under Plummer-softened gravity — softening removes the singularity at r → 0, and the symplectic integrator keeps energy bounded over minutes instead of drifting:

// acceleration on body i from body j, softened: a ∝ 1 / (r² + ε²)^(3/2)
function accel(dx: number, dy: number, mj: number, eps2: number): number {
const r2 = dx * dx + dy * dy + eps2;
return (G * mj) / (r2 * Math.sqrt(r2));
}

An optional position-only 1/r⁴ precession term adds the relativistic rosette without breaking the symplectic property. It’s all pure and tested — nbody.test.ts asserts total energy stays bounded, the kind of invariant you want under unit test, not eyeballing.

loop.onFixedUpdate.add((dt) => nbody.step(dt)); // deterministic integrator on the fixed step

lattice.ts is the geometry core. Each frame it zeroes a height field and adds the summed Plummer potential wells (mass-referenced, so a heavy hole curves the sheet a deliberate, art-directed amount), a broad funnel under the black hole, and the gravitational-wave ripple height. Then it flattens a band behind the headline text, computes |∇z| for the curvature, and projects every vertex through a real pitched-perspective camera:

lattice.reset();
for (const b of nbody.bodies) lattice.addWell(b.x, b.y, b.mass); // curvature = summed potential
lattice.addRipples(ripples);
lattice.flattenBand(headlineRect);
lattice.computeCurvature(); // |∇z| per vertex → line weight + brightness
lattice.project(camera); // world → screen (unproject() inverts it for the ground plane)

The curvature drives the look: where the sheet bends hardest, the renderer draws the lattice segments bolder and brighter; far rows are depth-dimmed.

The companion spirals in via an honest drag that removes only angular momentum (a Peters da/dt ∝ a⁻³ cadence), not energy by fiat. Because Keplerian ω ∝ r^-1.5, as the orbit tightens the frequency rises on its own — the LIGO chirp emerges from the physics rather than being scripted. At the throat, a momentum- and mass-conserving inelastic absorb merges the bodies, with mass bleeding back so the hole never grows without bound.

Two more pure modules carry the spectacle:

  • ripples.ts — a RipplePool of analytic expanding wave packets (≈1/√r attenuation, decaying envelope), emitted faster and stronger as the binary tightens, summed into the lattice height.
  • starfield.ts — gravitational lensing of a background star field by the point-mass map: deflection ∝ 1/r piles images into an Einstein ring, plus a Doppler-beamed photon ring, a lensed accretion-disk arc, and gravitational redshift on infall.

The black hole’s shadow is the one non-additive draw in the whole piece — a SrcOver fill, because additive Plus blending can only lighten, and a shadow has to darken.

Raw CanvasKit drawLine/drawOval/drawCircle, painted back to front onto the shared surface, then composited. The merger pushes a windup-darken then an impact² spike into the post-FX so the flash blows past the bloom threshold:

this.surface.fadeTrail(BG, 0.12);
drawLensedStarfield(sc); drawLattice(sc, curvature); // depth-dimmed segments
drawShadowVoid(sc); drawPhotonRing(sc); drawDisk(sc); // the SrcOver shadow + rings
drawPlanets(sc); drawTrails(sc); drawMergerFx(sc);
this.surface.composite(sim.glowWithImpactSpike()); // engine bloom + aberration → present

Planet identities (size, mass, color) live in planets.ts and are deliberately off the brand palette — real planetary hues read better than the prism here.

A CoroutineManager drives calm → inspiral → merger → ringdown, gated on orbital separation, and fires the merger’s “DEVOURER” envelope stack (windup inhale, impact stab, a damped-cosine lattice rebound, swell, gulp) plus a tangential debris burst from a ParticlePool. Drag-to-rotate and a pitch slider feed lattice.setCamera live, so the whole projected sheet tilts in real time:

loop.onUpdate.add((dt) => { coro.update(dt); });
// pointer drag / slider → lattice.setCamera({ yaw, pitch })

The cycle doesn’t end at the merger. After the DEVOURER eats a body, the count is held steady by injecting a fresh companion far out — but it never just pops into frame. A freshly injected planet starts with a birth envelope birth = 0 and condenses into being over birthTime (≈0.9 s): it’s the gentle inverse of the violent absorption. Where the merger is an outward flash, birth is an inward implosion

  • a bright shell collapses inward from birthShell × the planet’s radius onto the core;
  • a converging debris cloud (a ParticlePool burst, starting at birthInrush × the frame’s short side, count scaled by birthSparks) rushes inward and fades — the mirror of the merger’s outward tangential spray;
  • over the final stretch a brief achromatic pop flashes as the body snaps solid with a soft eased overshoot, blooming through the post-FX;
  • a summed birthGlow lifts the global bloom a touch while any planet is forming, and the lattice takes an extra birth-splash dent as the new mass condenses into the sheet.
injectSatellite(); // spawn far out (INJECT_R_FRAC of the frame) with birth = 0
// each frame: birth = min(1, birth + dt / birthTime) → shell contracts, debris converges, then pop

Because the mass that the hole absorbs bleeds back into the injected body, the system conserves its budget and loops seamlessly — a body leaves in a flash and a new one implodes into being, with no visible teleport. All four birth knobs (birthTime, birthInrush, birthShell, birthSparks) are live in the dev panel.

  1. A symplectic velocity-Verlet + Plummer N-body core, energy-bounded and bun-tested.
  2. A warp lattice whose height is the summed potential; project every vertex through a pitched camera.
  3. An honest angular-momentum-only inspiral — the chirp falls out of Kepler’s law.
  4. Analytic GW ripples + 1/r lensing (Einstein ring, photon ring, disk) — pure modules.
  5. Layered raw-CanvasKit render + the SrcOver shadow; merger spike routed into the engine post-FX.

Five small pure modules, each unit-tested, wrapped by the engine’s loop and post-FX. That division — heavy custom simulation, thin high-quality engine seam — is the whole thesis of these showcases.