<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Still Wet]]></title><description><![CDATA[Still Wet]]></description><link>https://stillwet.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 05 Nov 2026 15:33:57 GMT</lastBuildDate><atom:link href="https://stillwet.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Sand, water and lava on a 96 x 96 grid: the island under Still Wet]]></title><description><![CDATA[How a phone-browser god game moves sand, water and lava on a 96 x 96 grid: virtual pipes, an angle of repose, a worker.]]></description><link>https://stillwet.hashnode.dev/sand-water-and-lava-on-a-96-x-96-grid-the-island-under-still-wet</link><guid isPermaLink="true">https://stillwet.hashnode.dev/sand-water-and-lava-on-a-96-x-96-grid-the-island-under-still-wet</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[webdev]]></category><category><![CDATA[simulation]]></category><dc:creator><![CDATA[Still Wet]]></dc:creator><pubDate>Sun, 25 Oct 2026 14:05:22 GMT</pubDate><content:encoded><![CDATA[<p>Still Wet is a small god game that runs in a phone's browser: a hand from the sky picks up sand, water and lava and puts them down somewhere else, so a tribe can walk across an island and its villages survive the wave. Everything under the hand is one simulation. This post is how that simulation works, in about two hundred lines of plain JavaScript.</p>
<p>Here is the smallest version of the idea first, the shore cut in half like a cake. Drag to pour sand; the sea sends a wave every few seconds.</p>
<p><a class="embed-card" href="https://codepen.io/stillwet/pen/vExrzMd">https://codepen.io/stillwet/pen/vExrzMd</a></p>

<h2>Five layers of numbers</h2>
<p>The island is a 96 x 96 grid. Each cell holds five heights, each a <code>Float64Array</code> of n * n:</p>
<ul>
<li><code>rock</code>: never moves on its own, only grows when lava sets.</li>
<li><code>sand</code>: slides when a slope is too steep.</li>
<li><code>water</code> and <code>lava</code>: flow.</li>
<li><code>silt</code>: sand that fast water carries.</li>
</ul>
<p>The ground a fluid flows over is the sum of the layers under it. Lava flows over rock and sand; water flows over rock, sand and lava. So one step runs lava first, then water on top of what the lava left:</p>
<pre><code class="language-js">for (let i = 0; i &lt; N; i++) base[i] = rock[i] + sand[i];
pipes(w, lava, base, w.lR, w.lL, w.lD, w.lU, p.gLava, p.dampLava, dt);
settle(w, lava, w.lR, w.lL, w.lD, w.lU, dt, null);
for (let i = 0; i &lt; N; i++) base[i] = rock[i] + sand[i] + lava[i];
pipes(w, w.water, base, w.wR, w.wL, w.wD, w.wU, p.g, p.damp, dt, still);
carrySilt(w, dt);
settle(w, w.water, w.wR, w.wL, w.wD, w.wU, dt, w.speed);
erode(w, dt);
slump(w);
heat(w, dt);
sea(w);
</code></pre>
<h2>Water: four pipes per cell</h2>
<p>The flow is the virtual-pipe model: every cell has a pipe to each of its four neighbours, and each pipe keeps a flux from the last step. Each step, the flux grows by the difference in surface height and loses a little to damping:</p>
<pre><code class="language-js">const h = base[i] + fluid[i];
let r = fR[i] * damp + dt * g * (h - base[i + 1] - fluid[i + 1]);
</code></pre>
<p>A pipe never pushes backwards (negative flux is clamped to zero; the neighbour's own pipe handles that direction). And a cell can't send more than it has: if the four outflows add up to more than the cell's water, all four are scaled down by the same factor. That one line is what keeps the total water exact, step after step:</p>
<pre><code class="language-js">const out = (r + l + d + u) * dt;
if (out &gt; fluid[i]) { const k = fluid[i] / out; r *= k; l *= k; d *= k; u *= k; }
</code></pre>
<p>Then <code>settle</code> moves the water: inflow from the four neighbours' pipes pointing in, minus the cell's own outflow. Because the flux carries over between steps, water has momentum: a wave runs up a beach and slops back instead of just levelling out.</p>
<p>Lava is the same function with a lower gravity (2.5 against 10) and heavier damping (0.92 against 0.98), so it creeps.</p>
<h2>Sand: an angle of repose</h2>
<p>Sand doesn't flow, it slumps. For each neighbour, if the sand top is higher by more than the talus (the steepest slope sand holds), a fifth of the excess moves down that way:</p>
<pre><code class="language-js">const talus = water[i] &gt; 0.05 ? p.talusWet : p.talusDry;   // 0.35 wet, 0.8 dry
const ex = h - top(j) - talus;
if (ex &gt; 0) { m[k] = p.slump * ex; total += m[k]; }
</code></pre>
<p>Wet sand holds a slope less than half as steep as dry sand. That is the whole game in one line: a dune you pile on the beach stands, and the same dune melts when the wave reaches it. The moves are collected in a separate array and applied after the loop, so the order cells are visited in doesn't bias which way sand falls.</p>
<h2>Silt: sand that rides the water</h2>
<p>Fast water picks sand up. The capacity of a cell's water grows with its speed (the flux through it), and below capacity the water erodes the sand under it; above, it drops silt:</p>
<pre><code class="language-js">const cap = p.kc * Math.min(speed[i], 4);
if (silt[i] &lt; cap) { const e = Math.min(sand[i], p.ke * (cap - silt[i]) * dt); sand[i] -= e; silt[i] += e; }
else { const d = p.kd * (silt[i] - cap) * dt; silt[i] -= d; sand[i] += d; }
</code></pre>
<p>Silt moves on the water's own pipes: the share of a cell's water that leaves through a pipe carries the same share of its silt. No second velocity field, and a river cuts its bed and builds a fan at its mouth by itself.</p>
<h2>Lava: it sets</h2>
<p>Each step a little lava turns into rock, much faster where water touches it, and hot lava boils that water off:</p>
<pre><code class="language-js">const c = Math.min(lava[i], dt * (p.cool + (wet ? p.coolWet : 0)));
lava[i] -= c; rock[i] += c;
if (lava[i] &gt; 1e-3 &amp;&amp; wet) water[i] = Math.max(0, water[i] - p.boil * dt);
</code></pre>
<p>So the player can pour water on a lava flow to build a rock wall exactly where the wave will hit. That comes from four lines of physics, not a scripted rule.</p>
<h2>The sea and the wave</h2>
<p>The four edges of the grid are held at sea level: each step, an edge cell's water is set so its surface sits at the level. A wave is that same rule with one edge lifted for a few seconds. The pipes do the rest: the lifted edge pushes water inland, the wave runs up the cove, and anything below its crest floods.</p>
<h2>Keeping a phone at 60 fps</h2>
<ul>
<li><strong>The sim runs in a Web Worker.</strong> The page sends the time that passed and what the hand does; the worker runs fixed steps of 0.05 s (three simulated seconds per real second) and answers with the layers.</li>
<li><strong>No allocation per frame.</strong> The worker posts the layers as <code>Float32Array</code>s and transfers their buffers; the page sends the same buffers back with its next message, and the worker fills them again.</li>
<li><strong>One draw.</strong> The land is one grid mesh in WebGL2 with no vertex buffer (a vertex is its index), and the heights come from a texture read in the vertex shader. A browser with no WebGL2 gets the same camera on a plain 2D canvas.</li>
</ul>
<p>On a Sony Xperia 10 II, a mid-range phone from 2020, the island holds 59 frames a second while you play.</p>
<h2>Play it</h2>
<p>The first island is free in the browser, phone or desktop: <a href="https://overflow.games/stillwet/?utm_source=hashnode">Still Wet</a>. There is also a rising sea, a new one each day, the same for everyone.</p>
<p>Every number in the sim was tuned by eye. If you build something on pipes and talus, I'd like to see it.</p>
]]></content:encoded></item></channel></rss>