11

Patch Succession: Bare to Forest

tap to drop a disturbance

A patch-based cellular model of ecological succession on a grid. Each cell holds a state for bare ground, grass, shrub, or forest, and updates synchronously each step under simple stochastic rules. Bare cells colonize to grass with probability , accelerated by every grass neighbor (seed rain). Grass advances to shrub with probability but only when at least two of its eight neighbors are also grass โ€” competition and litter buildup gate the transition. Shrub matures to forest very slowly, , unless a forest neighbor supplies a seed source, in which case the rate jumps roughly fivefold per forest neighbor; this is what makes forest spread as a contiguous front rather than as isolated points. Every cell faces a small per-step disturbance probability โ€” fire, blowdown, gap dynamics โ€” that flips it back to bare. The disturbance rate is contagious: it scales with the number of recently-disturbed neighbors, so a small spark can cascade through dense fuel, and forest (more biomass) is more flammable than grass. The result is the classic patchy mosaic of all four stages, dominated by forest in stable interior regions, by grass at the edges of recent burns, and with shrubs lining the interfaces. The HUD shows the area fraction of each state slowly tracking a dynamic equilibrium. Click anywhere to drop a small disturbance pulse and watch succession recolonize the scar.

idle
204 lines ยท vanilla
view source
// Patch succession on a 120x80 grid.
// States: 0 bare, 1 grass, 2 shrub, 3 forest.
// Toroidal Moore neighborhood. Disturbance is contagious (cascading fires).

const GW = 120, GH = 80;
const BARE = 0, GRASS = 1, SHRUB = 2, FOREST = 3;

// Per-step base transition probabilities.
const P_BARE_TO_GRASS = 0.012;     // boosted per grass neighbor
const P_GRASS_TO_SHRUB = 0.004;    // requires >=2 grass neighbors
const P_SHRUB_TO_FOREST = 0.0015;  // boosted heavily by forest neighbors
const P_DIST_BASE = 0.0006;        // background disturbance
const P_DIST_CONTAGION = 0.06;     // per recently-disturbed neighbor

const stepMs = 1000 / 24;

let A, B;          // current / next state
let recent;        // bytes: 1 if disturbed in last step (used for cascading)
let recentNext;
let off, octx, img, data;
let acc = 0;
let frac = [0, 0, 0, 0];
let fracDisplay = [0.25, 0.25, 0.25, 0.25];
let stepCount = 0;

// Palette: sand, light grass, olive shrub, dark forest. RGB tuples.
const COLOR = [
  [212, 196, 156],  // bare / sand
  [148, 192, 102],  // grass
  [102, 132, 70],   // shrub / olive
  [42, 78, 48],     // forest / dark green
];

function idx(x, y) { return y * GW + x; }

function init({ width, height }) {
  A = new Uint8Array(GW * GH);
  B = new Uint8Array(GW * GH);
  recent = new Uint8Array(GW * GH);
  recentNext = new Uint8Array(GW * GH);

  // Seed with a noisy mix weighted toward earlier successional states.
  for (let i = 0; i < A.length; i++) {
    const r = Math.random();
    if (r < 0.45) A[i] = BARE;
    else if (r < 0.80) A[i] = GRASS;
    else if (r < 0.95) A[i] = SHRUB;
    else A[i] = FOREST;
  }

  off = new OffscreenCanvas(GW, GH);
  octx = off.getContext("2d");
  img = octx.createImageData(GW, GH);
  data = img.data;
  computeFractions();
  fracDisplay = frac.slice();
}

function computeFractions() {
  let c0 = 0, c1 = 0, c2 = 0, c3 = 0;
  for (let i = 0; i < A.length; i++) {
    const s = A[i];
    if (s === BARE) c0++;
    else if (s === GRASS) c1++;
    else if (s === SHRUB) c2++;
    else c3++;
  }
  const n = A.length;
  frac[0] = c0 / n;
  frac[1] = c1 / n;
  frac[2] = c2 / n;
  frac[3] = c3 / n;
}

function neighborsOf(x, y, arr) {
  const yN = (y - 1 + GH) % GH;
  const yS = (y + 1) % GH;
  const xW = (x - 1 + GW) % GW;
  const xE = (x + 1) % GW;
  return [
    arr[yN * GW + xW], arr[yN * GW + x], arr[yN * GW + xE],
    arr[y  * GW + xW],                    arr[y  * GW + xE],
    arr[yS * GW + xW], arr[yS * GW + x], arr[yS * GW + xE],
  ];
}

function stepCA() {
  for (let y = 0; y < GH; y++) {
    const yN = (y - 1 + GH) % GH;
    const yS = (y + 1) % GH;
    for (let x = 0; x < GW; x++) {
      const xW = (x - 1 + GW) % GW;
      const xE = (x + 1) % GW;
      const i = y * GW + x;
      const s = A[i];

      // Count neighbor states.
      const n0 = A[yN * GW + xW], n1 = A[yN * GW + x], n2 = A[yN * GW + xE];
      const n3 = A[y  * GW + xW],                       n4 = A[y  * GW + xE];
      const n5 = A[yS * GW + xW], n6 = A[yS * GW + x], n7 = A[yS * GW + xE];

      let nGrass = 0, nShrub = 0, nForest = 0;
      const ns = [n0, n1, n2, n3, n4, n5, n6, n7];
      for (let k = 0; k < 8; k++) {
        const v = ns[k];
        if (v === GRASS) nGrass++;
        else if (v === SHRUB) nShrub++;
        else if (v === FOREST) nForest++;
      }

      // Count recently-disturbed neighbors for cascading fires.
      let nRecent = 0;
      nRecent += recent[yN * GW + xW] + recent[yN * GW + x] + recent[yN * GW + xE];
      nRecent += recent[y  * GW + xW]                       + recent[y  * GW + xE];
      nRecent += recent[yS * GW + xW] + recent[yS * GW + x] + recent[yS * GW + xE];

      // Disturbance check first: fire/blowdown returns to bare.
      // Forest is the most flammable (more fuel), bare can't burn.
      let pDist = 0;
      if (s !== BARE) {
        const fuelMul = (s === GRASS) ? 0.6 : (s === SHRUB) ? 1.0 : 1.6;
        pDist = (P_DIST_BASE + P_DIST_CONTAGION * nRecent) * fuelMul;
      }
      if (Math.random() < pDist) {
        B[i] = BARE;
        recentNext[i] = 1;
        continue;
      }
      recentNext[i] = 0;

      // Succession transitions.
      if (s === BARE) {
        // Colonization by grass; faster with neighboring grass.
        const p = P_BARE_TO_GRASS * (1 + 0.6 * nGrass);
        B[i] = (Math.random() < p) ? GRASS : BARE;
      } else if (s === GRASS) {
        // Needs >=2 grass neighbors to produce a shrub seedling
        // (competition / litter buildup).
        if (nGrass >= 2 && Math.random() < P_GRASS_TO_SHRUB * (1 + 0.3 * nShrub)) {
          B[i] = SHRUB;
        } else {
          B[i] = GRASS;
        }
      } else if (s === SHRUB) {
        // Very slow without forest seed source nearby; fast at the
        // forest edge.
        const p = P_SHRUB_TO_FOREST * (1 + 4.0 * nForest);
        B[i] = (Math.random() < p) ? FOREST : SHRUB;
      } else {
        B[i] = FOREST;
      }
    }
  }
  const t = A; A = B; B = t;
  const r = recent; recent = recentNext; recentNext = r;
  stepCount++;
}

function applyDisturbance(gx, gy, radius) {
  const r2 = radius * radius;
  for (let dy = -radius; dy <= radius; dy++) {
    for (let dx = -radius; dx <= radius; dx++) {
      if (dx * dx + dy * dy > r2) continue;
      const x = ((gx + dx) % GW + GW) % GW;
      const y = ((gy + dy) % GH + GH) % GH;
      const i = y * GW + x;
      A[i] = BARE;
      recent[i] = 1;
    }
  }
}

function drawHUD(ctx, width, height) {
  // Smooth the displayed fractions a touch so the bar doesn't jitter.
  for (let k = 0; k < 4; k++) {
    fracDisplay[k] += (frac[k] - fracDisplay[k]) * 0.08;
  }

  const padX = Math.max(8, Math.round(width * 0.014));
  const padY = Math.max(8, Math.round(height * 0.02));
  const barW = Math.min(width - 2 * padX, Math.max(220, Math.round(width * 0.55)));
  const barH = Math.max(10, Math.round(height * 0.022));
  const labelH = Math.max(11, Math.round(height * 0.022));

  // Title strip background.
  ctx.fillStyle = 'rgba(0, 0, 0, 0.42)';
  ctx.fillRect(padX - 4, padY - 4, barW + 8, barH + labelH + 14);

  // Stacked bar.
  let x = padX;
  const total = fracDisplay[0] + fracDisplay[1] + fracDisplay[2] + fracDisplay[3] || 1;
  for (let k = 0; k < 4; k++) {
    const w = (fracDisplay[k] / total) * barW;
    const c = COLOR[k];
    ctx.fillStyle = `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
    ctx.fillRect(x, padY, w, barH);
    x += w;
  }
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
  ctx.lineWidth = 1;
  ctx.strokeRect(padX + 0.5, padY + 0.5, barW, barH);

  // Labels with percentages.
  ctx.font = `${labelH - 2}px ui-monospace, Menlo, monospace`;
  ctx.textBaseline = 'top';
  const names = ['bare', 'grass', 'shrub', 'forest'];
  const colW = barW / 4;
  for (let k = 0; k < 4; k++) {
    const c = COLOR[k];
    const lx = padX + k * colW;
    ctx.fillStyle = `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
    ctx.fillRect(lx, padY + barH + 6, 8, 8);
    ctx.fillStyle = 'rgba(240, 240, 230, 0.92)';
    const pct = (fracDisplay[k] * 100).toFixed(0) + '%';
    ctx.fillText(`${names[k]} ${pct}`, lx + 12, padY + barH + 4);
  }
}

function tick({ ctx, dt, width, height, input }) {
  const clicks = (input && input.consumeClicks) ? input.consumeClicks() : [];
  for (let c = 0; c < clicks.length; c++) {
    const cx = clicks[c].x, cy = clicks[c].y;
    const gx = Math.floor((cx / width) * GW);
    const gy = Math.floor((cy / height) * GH);
    if (gx >= 0 && gx < GW && gy >= 0 && gy < GH) {
      // Disturbance pulse: small fire scar that seeds a cascade.
      applyDisturbance(gx, gy, 5);
    }
  }

  acc += Math.min(0.05, dt) * 1000;
  let n = 0;
  while (acc >= stepMs && n < 3) {
    stepCA();
    acc -= stepMs;
    n++;
  }
  if (n > 0) computeFractions();

  // Render the grid into the offscreen image buffer.
  for (let i = 0, j = 0; i < A.length; i++, j += 4) {
    const c = COLOR[A[i]];
    data[j]     = c[0];
    data[j + 1] = c[1];
    data[j + 2] = c[2];
    data[j + 3] = 255;
  }
  octx.putImageData(img, 0, 0);
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(off, 0, 0, width, height);

  drawHUD(ctx, width, height);
}

Comments (0)

Log in to comment.