The Undermining

Artificial Noodles ·

Inspired by Internal erosion on Wikipedia

Built with Canvas 2D · Offscreen strata compositing · Spatial grid neighbour lookup · Density grid void detection

Techniques Packed-grain initial state · Cursor-driven fluid drag · Support-check cascade unlocking · Column sag from depth-weighted voidness · Stress-propagating collapse

Direction A sinkhole is invisible until it isn’t — the grass holds until the roof is too thin, then the ground simply stops being ground

Result A soil cross-section where your cursor cuts tunnels, grains migrate downstream, and eventually the moss surface breaks open into dark sinkhole pits

The Story

Internal erosion is the quiet kind of catastrophe. Water seeping through a dam, an embankment, or the soil under a road doesn’t announce itself. It carries individual grains, one at a time, along a flow path. A cavity forms below the surface. The grains above it lose their support and migrate down into the hollow. The cavity rises. The roof thins. And then — one day, with no warning visible from above — the ground simply isn’t there anymore. Roads swallow cars. Fields open into pits. Dams fail overnight.

The violence of this failure mode is that the damage is invisible until the moment it becomes total. A dam showing surface distress gets engineered attention. A dam being hollowed out from within by a one-millimetre seepage channel looks fine until it collapses into its reservoir. Every sinkhole, every cavern under a levee, every karst collapse is the same story told by different geology: grains went somewhere they shouldn’t have, and nobody could see it until the roof fell.

The experience puts you in the position of the erosive agent. You are not watching the failure — you are causing it, slowly, with a cursor acting as a channel of flowing water below a field of moss.


The Take

The view is a vertical cross-section: grass at the top, four strata of packed earth below, and 22,000 grains of soil rendered as a physics field within the strata. Drag the cursor below the grass line and you become the flow. Grains within your radius inherit your velocity and are pushed downstream. The path you trace darkens as the density of grains drops below the expected baseline — a void, visible only to you, the agent cutting it.

Keep digging in one place and something new happens: the columns above the void start to sag. Hairline cracks open in the grass. The HUD cycles from intact through sagging and subsiding into cracking. And then — if you concentrate your flow on a single span of maybe five metres — the roof fails. One column snaps into a collapsed state, unlocks its neighbours by lateral stress, and within a frame or two you have a cluster of columns that have broken through. The moss parts. A dark vertical shaft appears where the grass used to be. A sinkhole.

The creative connection to internal erosion is the agency. Normally, a sinkhole is something that happens to you. Here you are the cause and the observer both — and the experience is calibrated so that a slow sweep never collapses the surface, but a focused dig always does. You have to commit to breaking it.


The Tech

Packed Grain Initial State

22,000 grains (14,000 on mobile) are distributed uniformly across the soil region at boot time. The critical detail is that every grain is seeded with a locked: 100 counter. A locked grain skips gravity integration entirely — it is at rest in its packed cell, exerting no motion, indistinguishable from the offscreen strata canvas behind it. This matters because if you let gravity run on an unsupported field of 22k particles, the entire cross-section slumps to the bottom of the canvas within one frame. The locked state is what makes the soil feel packed rather than a pile.

Grains only unlock when they are touched by the cursor’s flow radius (direct contact) or when the cells immediately below them lose most of their expected density (support failure, propagating upward). Both mechanisms feed the same collapse: cursor carves a tunnel, gravity pulls the roof grains into it, new grains lose their support, the void climbs.

The Offscreen Strata Canvas

The soil cross-section isn’t rendered from the grain positions — it’s a pre-baked offscreen canvas drawn once at startup. Four colour bands represent dark loam, sandy loam, transitional silt, and slate clay, each with a gentle vertical gradient. On top of that: thousands of dark-and-light speckle pixels for texture, pebble inclusions at 2–5 px radius with a random dark fill, and faint handmade horizontal stratum boundaries drawn as jagged polylines with ±2.5 px of vertical noise every 40 pixels.

The grain field and the strata canvas are two different visual layers. The strata gives the cross-section its “packed” appearance. The grains just track what moves. Voids are shown by comparing the current grain count per cell against the initial count, and painting dark rectangles over the strata where the deficit exceeds 18%.

Density Grid: Expected vs Current

A 7×7 px density grid covers the canvas. Two parallel Uint16Array buffers track grain counts: expectedGrid captures the initial (packed) state, densityGrid is rebuilt every frame from current grain positions. The ratio 1 - current/expected is the cell’s voidness.

This is the key architecture decision. Early versions tried to detect voids by scanning the grain positions directly — looking for gaps in the top rows, tracking a “deepest unfilled y” per column. Every approach had failure modes: grains shift slightly under repulsion and register false gaps, the top row is noisy, single-pixel scans miss wide tunnels. Comparing against a frozen expected grid is robust. Cells don’t need to be empty to register a void; they need to be emptier than they started.

The density grid is rebuilt BEFORE the grain physics step each frame, not after. This was a subtle bug in the first working version: on the first frame, densityGrid was still all zeros, and the support-check logic would interpret every cell as empty, unlocking every grain at once and collapsing the entire cross-section before the user did anything. Reordering the passes so the density grid is populated first made the initial frame see a fully packed baseline.

Cursor As Fluid Flow

When the cursor is below the surface line and moving faster than 4 px/s, the drag logic fires. For every grain within a 52 px radius (38 px on mobile), a share factor is computed as (1 - d/R)² — quadratic falloff from the centre. The grain receives three impulses:

  1. Downstream push: scaled by cursor speed along the normalised flow direction, with a floor of 140 px/s². Even a slow hand carves, because the floor guarantees a minimum erosive force regardless of speed.
  2. Perpendicular spread: a signed impulse away from the cursor’s flow line, opening the tunnel radius wider than a single grain wide.
  3. Gravity drop: a constant downward kick so that unlocked grains don’t just shoot sideways — they start falling as they leave the flow.

The combination produces a tunnel that is wider than the cursor, slumps downward over time, and accumulates grains at its bottom as they settle into new resting spots. The drag also sets locked = 0 on every contacted grain, removing them from the at-rest pool until they come to rest again naturally.

Cascade Unlocking

A locked grain checks the cell directly below it and the cell 8 px further below it. If both cells have lost most of their expected density (first below 25%, second below 40%), the grain’s support is gone and it unlocks itself. This is what propagates the void upward: as the cursor carves a tunnel, grains above the tunnel lose their support, fall into it, leaving new voids above themselves, which unlock more grains above, and so on.

Requiring TWO consecutive empty cells rather than just one prevents runaway cascades — a single noisy cell with a minor density deficit won’t trigger unlocking. The void has to be a real channel.

Column Sag and Collapse

The surface is divided into columns 10 px wide. Each column has a sag value and a state (intact, sagging, cracked, collapsed). Every frame, for each column, the density grid cells in the upper 320 px of soil beneath that column are scanned. Each cell contributes to a voidness sum weighted by depth — shallower cells count more heavily, because roof thickness is what matters for sinkhole formation.

The target sag is voidness × 130, and the current sag lerps toward it. A neighbourhood average (the column plus its two neighbours, with the column double-weighted) smooths out single-column anomalies. Then lateral stress is added: if an adjacent column is already in state 3 (collapsed), a stress of +14 is added to the effective sag. This is what makes sinkholes spread sideways — once one column has broken, its neighbours are boosted toward their own collapse threshold, and within a few frames the break widens from a single 10 px column to a 60 px cluster.

Collapse itself requires both an effective > 52 threshold and a shallowVoid > 12 gate (6 when adjacent to an existing collapsed column). On collapse, all grains in that column within 100 px of the original surface are unlocked and given a downward impulse of +260 px/s — they rain into the void, visually opening the hole.

Drawing the Sinkhole

A collapsed column is rendered in two parts. First, a dark vertical gradient rectangle is painted from the original surface line down 180 px — near-black at the top fading to transparent at the bottom, representing the pit. Second, a jagged irregular black line is drawn along the top edge at ±1.5 px of vertical noise, representing the broken lip of the grass. The moss band itself skips the collapsed columns, so where there was grass there is now a hole, and where there was hidden soil there is now darkness. The sinkhole reads as a visual punch-through rather than a missing segment.

HUD and Narrative

The left-side HUD shows four readouts: flow rate (derived from pointer speed), void volume (from accumulated totalVoidDepth, scaled to m³), subsidence (max column sag in cm), and status (intacterodingsaggingsubsidingcrackingcollapsed). The status colour shifts from neutral green through amber warn to red crit.

A narrative log in the bottom-left fades in timestamped messages at milestones: flow path established, void initiation, cavity expanding, roof thickness critical, surface subsidence observed, roof failure catastrophic sinkhole. Each line appears only once. The font throughout is JetBrains Mono — the monospace feels like a field instrument readout, not a magazine headline. The colour palette is cool moss-green on dark earth, a deliberate break from the warm amber/gold that the archive had been leaning on for several experiences prior.

Mobile

Mobile automatically cuts grain count to 14,000 and drag radius to 38 px. The experience also runs an idle demo on mobile: after 1.4 seconds, if no touch has fired, an automated cursor starts tracing a horizontal arc just below the moss line at 130 px/s, carving a visible tunnel within four seconds. This ensures that a passive mobile viewer sees the mechanic in action before deciding whether to touch the screen themselves. The idle demo terminates the moment a real touch event fires.


Experience: The Undermining


This blog post was AI generated with Claude Code. Authored by Artificial Noodles.