A 1D spring water surface, wind gusts drawn as one polyline, and an aurora faked with three additive strokes — plus the one comment in the file that explains a bug I would otherwise have shipped."
tags: javascript, canvas, gamedev, webdev
I built a small Chrome arcade game called Prism Cascade. You throw light-orbs at crystal prisms, the shards fall into a pool, and every splash throws up bubbles carrying points. Thirty-second rounds. The gameplay is deliberately tiny.
The part I want to write about is the part that does nothing.
Underneath the game there is a layer of wind, mist, aurora, drifting motes, fireflies and fish. It is the first thing anyone comments on and it has zero effect on play. That was the design constraint, and it turned out to be the thing that made the whole layer cheap to build and impossible to break.
Here is the entire declaration:
// ============================== Ambient world (visual only — no gameplay physics) ==============================
const fish = [], gusts = [], mist = [], foamBlobs = [], fireflies = [], motes = [], spires = [], drops = [];
Eight arrays. No classes, no entity system, no update graph. Let me go through the three that were actually interesting to write.
1. The water surface is a row of springs, and it wants to explode
The pool is not a sine wave. It is a 1D array of columns, each with a height and a velocity, coupled to its neighbours — the classic Hugo Elias water model that has been floating around demoscene articles since about 2000.
const water = { cols: [], n: 0, spacing: 6 };
function initWater() {
water.n = Math.max(40, Math.floor(W / water.spacing));
water.cols = new Array(water.n).fill(0).map(() => ({ h: 0, v: 0 }));
}
function disturbWater(x, power) {
const i = waterIndex(x);
water.cols[i].v += power;
if (water.cols[i - 1]) water.cols[i - 1].v += power * 0.6;
if (water.cols[i + 1]) water.cols[i + 1].v += power * 0.6;
}
Each frame, every column gets a Hooke restoring force toward zero, gets damped, and then leaks energy into its neighbours over two passes:
function stepWater(dt) {
const k = 0.025, damp = 0.976, spread = 0.14;
const hMax = 34 * S, vMax = 46;
const c = water.cols;
for (let i = 0; i < water.n; i++) {
const col = c[i];
col.v += -k * col.h;
col.v *= damp;
if (col.v > vMax) col.v = vMax; else if (col.v < -vMax) col.v = -vMax;
col.h += col.v * dt * 60;
if (col.h > hMax) col.h = hMax; else if (col.h < -hMax) col.h = -hMax;
}
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i < water.n; i++) {
if (i > 0) { const d = (c[i].h - c[i - 1].h) * spread; c[i - 1].v += d * 0.5; c[i].v -= d * 0.5; }
if (i < water.n - 1) { const d = (c[i].h - c[i + 1].h) * spread; c[i + 1].v += d * 0.5; c[i].v -= d * 0.5; }
}
}
This looks fine and is not fine. Run it for a while with a lot of splashes and the surface develops a shimmering sawtooth — every column high, its neighbour low, alternating all the way across the pool. It does not decay, because the neighbour-coupling term is what is feeding it: at that wavelength each column is always being pushed away from both of its neighbours simultaneously.
That is the Nyquist mode. It is the highest frequency the grid can represent — one full wave every two columns — and an explicit integrator on a coupled grid will happily pump energy into it forever.
The fix is three lines and a comment I left in on purpose:
// spatial smoothing — kills the alternating-column (Nyquist) instability mode
// while preserving long-wavelength waves
let prev = c[0].h;
for (let i = 1; i < water.n - 1; i++) {
const smoothed = (prev + 2 * c[i].h + c[i + 1].h) * 0.25;
prev = c[i].h;
c[i].h += (smoothed - c[i].h) * 0.55;
}
}
A 1-2-1 kernel is a low-pass filter. At the Nyquist wavelength it has almost no gain, so the sawtooth is annihilated. At the long wavelengths you actually want — the roll of a wave crossing the pool after a big splash — it barely does anything. Blending at 0.55 rather than replacing outright keeps a little crispness.
The prev variable matters more than it looks. Reading c[i-1].h directly would read the already-smoothed value from this same pass, which turns a symmetric filter into a directional one and makes waves drift left. Caching the pre-smoothing value is the difference between a filter and a bug.
Two clamps are also doing quiet work. vMax stops a single enormous splash from launching one column into orbit, and hMax bounds the visual amplitude so the surface can never draw outside the pool. Neither is physical. Both mean the sim cannot produce a frame that looks broken, no matter what the game throws at it.
2. Everything couples through exactly one function
Fish leaping, waterfall droplets landing, crystal debris hitting the pool — all of it reaches the water through disturbWater(x, power) and nothing else.
// a fish landing
disturbWater(f.x, 4);
// a waterfall droplet, only sometimes
if (d.y > G.waterY) {
drops.splice(i, 1);
if (Math.random() < 0.12) disturbWater(d.x, 0.7);
}
// debris from a shattered crystal
disturbWater(x, Math.min(26, 6 + energy * 9));
One integer of API surface. The ambient systems do not know about each other, do not know about the game, and cannot be broken by either. When I later changed how splash energy was calculated, the water sim needed no edits — it only ever sees a number.
That Math.random() < 0.12 on the droplets is worth calling out. Ninety pixel-streaks a second all ringing the surface produces mush, not rain. Letting roughly one in eight actually strike gives you distinguishable individual rings and, incidentally, cuts the work by 88%. Sparse is both cheaper and better looking, which is not a trade-off you get very often.
3. Wind is one polyline, and the aurora is three strokes
I did not want a particle system for wind. Particles for wind look like snow.
A gust is a single object with a lifetime, and it is drawn as one 26-segment polyline whose vertical offset is a sine wave — with the amplitude tapering along the tail so it looks like it is trailing off rather than ending:
const segs = 26;
for (let i2 = 0; i2 <= segs; i2++) {
const px = g.x - g.dir * i2 * 13;
const py = g.y + Math.sin((g.x - g.dir * i2 * 13) / g.wl + g.age * 2)
* g.amp * (1 - i2 / segs * 0.4);
i2 === 0 ? cx.moveTo(px, py) : cx.lineTo(px, py);
}
cx.stroke();
// little curl at the head
cx.beginPath();
cx.arc(g.x, g.y + Math.sin(g.x / g.wl + g.age * 2) * g.amp, 7, Math.PI * 0.2, Math.PI * 1.5);
cx.stroke();
One stroke, one arc, per gust. A new gust arrives every 4 to 10 seconds, 70% of the time from the left, and self-culls when it leaves the screen. There are usually one or two on screen and often none — the gaps are what makes it read as weather rather than as an effect that is always on.
The aurora is the same trick applied to glow. No shader, no blur filter, no offscreen canvas:
cx.globalCompositeOperation = 'lighter';
for (let rib = 0; rib < 2; rib++) {
const baseY = H * (0.1 + rib * 0.07);
const hueA = rib ? 280 : 160;
for (let pass = 0; pass < 3; pass++) {
cx.globalAlpha = (game.reduceFx ? 0.03 : 0.05) * (3 - pass);
cx.lineWidth = 18 + pass * 16;
// ... two summed sines, sampled every 36px
}
}
Three passes: progressively wider, progressively fainter, drawn additively on top of each other. That is a bloom. It costs six strokes a frame sampled every 36 pixels, and it is convincing because real glow is a stack of decreasing intensities at increasing radii — you do not need to compute it, you can just draw it that way.
The ribbon shape is two summed sines at different frequencies and opposite time directions. One sine reads as a mechanical wobble. Two never quite repeat.
The rule that made all of it safe
Every one of these populations is bounded:
if (mist.length < 40 && Math.random() < 0.5) { /* spawn */ }
if (drops.length < 90) { /* spawn */ }
36 motes, 8 fireflies, 5 fish, 15 spires, 1400 particles hard cap. Not one of them grows with time, level, score or how long the tab has been open. The frame cost of the ambient layer at minute one is the frame cost at hour three, which is precisely why I never had to profile it.
There is one deliberate asymmetry. The rock spires on the horizon are generated from a seeded RNG:
const srng = L.mulberry32(0xA17A);
while everything else uses Math.random. The skyline is the same every single session; the weather in front of it never repeats. That split is the whole feel of the thing — a fixed place, with something moving through it.
Where reduced-motion actually landed
prefers-reduced-motion maps to a game.reduceFx flag, and I will be honest about how far it goes, because "we support reduced motion" is a claim people make loosely.
It gates the things that move fast or arrive suddenly: wind gusts stop spawning entirely, waterfall droplets stop spawning, fish stop leaping, and the aurora drops from 0.05 to 0.03 alpha. It does not gate the slow continuous drift — mist, motes and fireflies keep going.
That is a judgement call, not an oversight. The vestibular-trigger guidance is about large, fast, unexpected motion, and a mote drifting four pixels a second is not that. But it is a judgement call, and if someone tells me it is the wrong one, the flag is already threaded through every spawn site — it is a one-line change per system.
Prism Cascade is a 30-second physics arcade for Chrome. It makes zero network requests, asks for exactly one permission (storage), and the Chrome Web Store listing is still in preparation at the time of writing.
- Watch it in motion: https://www.youtube.com/shorts/6oBcZolFoDQ
- Full write-up, mechanics and privacy policy: https://dhseadev.online/projects/prism-cascade/
- More things I have built: https://dhseadev.online/projects/
Built solo — physics, rendering, sound and the level generator are all hand-written, no engine and no third-party libraries.
Top comments (0)