[object Object]

← back to Living Fractal Tree

Extend Living Fractal Tree: wind control, manual/auto seasons, winter snow, ground leaf pile, richer UI

2d6124ebc5567c4a99e4f1cce27603ff513746fc · 2026-07-24 23:26:57 -0700 · Steve

Built from Grok 4.5's Model Arena attempt. Prior session's build already fixed
Grok's core defects (leaf strobing via deterministic seed hash, HiDPI blur,
frame-rate-dependent motion). This commit extends it with a wind-strength slider,
Next-season/Auto-cycle/Regrow controls + 'S' key, distinct winter snow particles,
ground leaf accumulation, season badge, and README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 2d6124ebc5567c4a99e4f1cce27603ff513746fc
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 24 23:26:57 2026 -0700

    Extend Living Fractal Tree: wind control, manual/auto seasons, winter snow, ground leaf pile, richer UI
    
    Built from Grok 4.5's Model Arena attempt. Prior session's build already fixed
    Grok's core defects (leaf strobing via deterministic seed hash, HiDPI blur,
    frame-rate-dependent motion). This commit extends it with a wind-strength slider,
    Next-season/Auto-cycle/Regrow controls + 'S' key, distinct winter snow particles,
    ground leaf accumulation, season badge, and README.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 README.md  |  55 ++++++++++++++
 index.html | 246 +++++++++++++++++++++++++++++++++++++++++++++----------------
 2 files changed, 239 insertions(+), 62 deletions(-)

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..36c999f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,55 @@
+# Living Fractal Tree
+
+A single self-contained HTML file: a recursive fractal tree that **grows on load**,
+**sways in simulated wind**, and drops **seasonal leaf particles** (and snow in
+winter). Pure `<canvas>` + vanilla JS — no dependencies, no build step.
+
+Open `index.html` in any modern browser.
+
+## Controls
+
+| Control | What it does |
+|---|---|
+| **Branch depth** slider | Recursion depth (4–12). Changing it regrows the tree. |
+| **Wind** slider | Wind strength 0.0–2.0 — affects branch sway *and* particle drift. |
+| **Next season** button / press **S** | Advance Spring → Summer → Autumn → Winter. |
+| **Auto cycle** button | Toggle automatic ~13s season rotation on/off. |
+| **Regrow** button | Replay the growth animation and clear the ground. |
+
+## What each season does
+
+- **Spring** — light greens with pink blossom leaves, a gentle leaf fall.
+- **Summer** — dense deep-green canopy, minimal shedding.
+- **Autumn** — warm oranges/reds, heavy leaf fall that piles on the ground.
+- **Winter** — near-bare branches with **snow** drifting down the whole frame.
+
+Fallen leaves accumulate as a pile along the ground; snow melts away on landing.
+
+## Origin & design notes
+
+This started from **Grok 4.5's** Model Arena attempt at the challenge. That version
+had the right idea but three real defects, all fixed here:
+
+1. **Strobing foliage.** Grok rebuilt the entire `leaves` array every frame with
+   fresh `Math.random()` offsets, so leaves jittered violently. Here the tree's
+   structure comes from a **deterministic seed hash** (`rnd(seed)`), so the shape
+   is rock-steady and only the time-based wind animates it.
+2. **Blurry on HiDPI.** Grok sized the canvas in CSS pixels. Here the canvas is
+   scaled by `devicePixelRatio` and rendered crisp on retina displays.
+3. **Frame-rate-dependent motion.** Growth and particles now advance by real
+   elapsed time (`dt`), so it looks the same at 30fps or 144fps.
+
+Extensions beyond the original prompt: wind-strength control, manual/auto season
+control, distinct winter snow, ground leaf accumulation, cross-fading season sky
+colours, and a smoothstep growth ease.
+
+## Structure
+
+Everything is in `index.html`:
+
+- `rnd(seed)` — deterministic hash powering the stable tree shape.
+- `branch()` — recursive draw with smoothstep grow-in + per-branch wind sway.
+- `drawLeaves()` — foliage at branch tips, colour-blended across seasons.
+- `spawnParticles()` / `updateParticles()` — falling leaves or snow, `dt`-timed.
+- `drawPile()` — settled leaves on the ground.
+- `currentSeason()` — season index + cross-blend from the season clock.
diff --git a/index.html b/index.html
index 39545be..cb55d8d 100644
--- a/index.html
+++ b/index.html
@@ -5,30 +5,73 @@
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>Living Fractal Tree</title>
 <style>
-  html, body { margin: 0; height: 100%; overflow: hidden; background: #0d1420; }
+  html, body { margin: 0; height: 100%; overflow: hidden; background: #0d1420;
+    font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
   canvas { display: block; }
+
+  /* season badge — top centre */
+  #season {
+    position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 3;
+    background: rgba(10, 16, 28, 0.66); backdrop-filter: blur(8px);
+    border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 999px;
+    padding: 7px 18px; color: #eef3fa; letter-spacing: 0.09em; text-transform: uppercase;
+    display: flex; align-items: center; gap: 9px; user-select: none;
+    box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35);
+  }
+  #season .dot { width: 9px; height: 9px; border-radius: 50%;
+    box-shadow: 0 0 10px currentColor; transition: color .8s; }
+
+  #hint { position: fixed; top: 20px; right: 20px; z-index: 3;
+    color: rgba(255, 255, 255, 0.28); font-size: 11px; letter-spacing: 0.04em; user-select: none; }
+
+  /* control dock — bottom centre */
   #ui {
-    position: fixed; top: 16px; left: 16px; z-index: 2;
-    background: rgba(10, 16, 28, 0.72); backdrop-filter: blur(6px);
-    border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 10px;
-    padding: 12px 16px; color: #dfe8f2;
-    font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-    user-select: none;
+    position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); z-index: 3;
+    background: rgba(10, 16, 28, 0.78); backdrop-filter: blur(12px);
+    border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 16px;
+    padding: 13px 20px; color: #dfe8f2;
+    display: flex; align-items: center; gap: 20px; flex-wrap: wrap; justify-content: center;
+    max-width: 94vw; box-shadow: 0 10px 38px rgba(0, 0, 0, 0.45); user-select: none;
+  }
+  #ui .grp { display: flex; align-items: center; gap: 9px; }
+  #ui label { color: #c5cfda; white-space: nowrap; }
+  #ui .val { min-width: 2.4ch; text-align: right; font-variant-numeric: tabular-nums; color: #9fd977; }
+  #ui .sep { width: 1px; height: 24px; background: rgba(255, 255, 255, 0.1); }
+  input[type="range"] { width: 130px; accent-color: #7fb26a; cursor: pointer; vertical-align: middle; }
+  #ui button { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.13);
+    color: #dfe8f2; padding: 7px 13px; border-radius: 9px; cursor: pointer; letter-spacing: 0.02em;
+    transition: background .15s, border-color .15s; white-space: nowrap; font-size: 12.5px; }
+  #ui button:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.22); }
+  #ui button.on { background: rgba(127, 178, 106, 0.22); border-color: rgba(127, 178, 106, 0.5); color: #bfe6bf; }
+  @media (max-width: 560px) {
+    #ui { gap: 11px; padding: 11px 13px; } input[type="range"] { width: 92px; } #hint { display: none; }
   }
-  #ui label { display: flex; align-items: center; gap: 10px; }
-  #depthVal { min-width: 2ch; text-align: right; font-variant-numeric: tabular-nums; }
-  input[type="range"] { width: 160px; accent-color: #7fb26a; }
-  #season { margin-top: 6px; font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; opacity: 0.75; }
 </style>
 </head>
 <body>
+<div id="season"><span class="dot" id="seasonDot"></span><span id="seasonName">Spring</span></div>
+<div id="hint">press&nbsp;<b>S</b>&nbsp;for next season</div>
+
 <div id="ui">
-  <label>Branch depth
+  <div class="grp">
+    <label>Branch depth</label>
     <input type="range" id="depth" min="4" max="12" step="1" value="9">
-    <span id="depthVal">9</span>
-  </label>
-  <div id="season">Spring</div>
+    <span class="val" id="depthVal">9</span>
+  </div>
+  <div class="sep"></div>
+  <div class="grp">
+    <label>Wind</label>
+    <input type="range" id="wind" min="0" max="200" step="1" value="100">
+    <span class="val" id="windVal">1.0</span>
+  </div>
+  <div class="sep"></div>
+  <div class="grp">
+    <button id="seasonBtn">Next season</button>
+    <button id="autoBtn" class="on">Auto cycle</button>
+    <button id="regrowBtn">Regrow</button>
+  </div>
 </div>
+
 <canvas id="c"></canvas>
 <script>
 'use strict';
@@ -36,7 +79,10 @@ const canvas = document.getElementById('c');
 const ctx = canvas.getContext('2d');
 const depthSlider = document.getElementById('depth');
 const depthVal = document.getElementById('depthVal');
-const seasonEl = document.getElementById('season');
+const windSlider = document.getElementById('wind');
+const windVal = document.getElementById('windVal');
+const seasonNameEl = document.getElementById('seasonName');
+const seasonDotEl = document.getElementById('seasonDot');
 
 let W = 0, H = 0;
 function resize() {
@@ -50,39 +96,45 @@ window.addEventListener('resize', resize);
 resize();
 
 // ---------- deterministic per-branch randomness (stable tree shape) ----------
+// Hashing the branch seed (instead of Math.random each frame) is what keeps the
+// tree from strobing: identical structure every frame, only wind animates it.
 function rnd(seed) { const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }
 
 // ---------- tunables ----------
 let maxDepth = 9;
+let windStrength = 1.0;
 const GROW_SPEED = 1.9;        // depth levels per second
 const LEN_RATIO = 0.72;
 const SPREAD = 0.52;           // base branching angle (radians)
-const SEASON_LEN = 12;         // seconds per season
+const SEASON_LEN = 13;         // seconds per season (auto cycle)
 const SEASONS = ['Spring', 'Summer', 'Autumn', 'Winter'];
+const SEASON_DOT = ['#9fd977', '#4e9e46', '#ef7d2a', '#cfe4f2'];
 
 // leaf palettes per season [light, dark]
 const LEAF_COLORS = [
   ['#9fd977', '#6fae4e'],      // spring
   ['#4e9e46', '#2f7a35'],      // summer
   ['#e8a13c', '#c65b28'],      // autumn
-  ['#b8c4c9', '#8fa0a8'],      // winter (sparse, pale)
+  ['#c3d0d6', '#9aabb3'],      // winter (sparse, pale)
 ];
-// leaf density + fall rate (particles/sec) per season
-const LEAF_DENSITY = [0.85, 1.0, 0.8, 0.12];
-const FALL_RATE = [2, 4, 46, 8];
-
-depthSlider.addEventListener('input', () => {
-  maxDepth = +depthSlider.value;
-  depthVal.textContent = depthSlider.value;
-});
+// leaf density on the tree + fall rate (particles/sec) per season
+const LEAF_DENSITY = [0.85, 1.0, 0.8, 0.10];
+const FALL_RATE    = [3, 4, 48, 34];    // winter is high but spawns SNOW, not leaves
+const SNOW_SEASON  = 3;
 
 // ---------- state ----------
 let grow = 0;                  // rises toward maxDepth+1; new depths animate in
 let tips = [];                 // leaf tip positions collected each frame
-const particles = [];          // falling leaves
-const MAX_PARTICLES = 420;
+const particles = [];          // falling leaves / snow
+const pile = [];               // leaves settled on the ground
+const MAX_PARTICLES = 460;
+const MAX_PILE = 460;
 let spawnCarry = 0;
 
+// season timeline — advances only while auto-cycling, so a manual pick holds
+let seasonClock = 0;
+let autoCycle = true;
+
 function lerp(a, b, t) { return a + (b - a) * t; }
 function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
 function hexToRgb(h) { return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; }
@@ -94,7 +146,7 @@ function mixHex(a, b, t) {
 // ---------- wind ----------
 let gust = 0;
 function windAt(t) {
-  return Math.sin(t * 0.55) * 0.45 + Math.sin(t * 0.23 + 1.7) * 0.35 + Math.sin(t * 1.4 + 0.4) * 0.12;
+  return (Math.sin(t * 0.55) * 0.45 + Math.sin(t * 0.23 + 1.7) * 0.35 + Math.sin(t * 1.4 + 0.4) * 0.12) * windStrength;
 }
 
 // ---------- tree ----------
@@ -156,26 +208,34 @@ function drawLeaves(t, seasonIdx, seasonMix) {
   ctx.globalAlpha = 1;
 }
 
-// ---------- falling leaf particles ----------
+// ---------- falling particles: leaves shed from tips, or snow from the sky ----
 function spawnParticles(dt, seasonIdx, seasonMix) {
-  if (tips.length === 0) return;
+  const isSnow = seasonIdx === SNOW_SEASON;
+  if (!isSnow && tips.length === 0) return;
   const rate = lerp(FALL_RATE[seasonIdx], FALL_RATE[(seasonIdx + 1) % 4], seasonMix);
   spawnCarry += rate * dt;
   while (spawnCarry >= 1 && particles.length < MAX_PARTICLES) {
     spawnCarry -= 1;
-    const i = (Math.random() * (tips.length / 2) | 0) * 2;
-    const [li, da] = LEAF_COLORS[seasonIdx];
-    particles.push({
-      x: tips[i], y: tips[i + 1],
-      vx: (Math.random() - 0.5) * 12,
-      vy: 6 + Math.random() * 14,
-      rot: Math.random() * Math.PI * 2,
-      vr: (Math.random() - 0.5) * 4,
-      size: 2.4 + Math.random() * 3,
-      color: Math.random() > 0.5 ? li : da,
-      phase: Math.random() * Math.PI * 2,
-      life: 1,
-    });
+    if (isSnow) {
+      particles.push({
+        x: Math.random() * W, y: -10,
+        vx: (Math.random() - 0.5) * 8, vy: 18 + Math.random() * 22,
+        rot: 0, vr: 0, size: 1.3 + Math.random() * 2.4,
+        color: Math.random() > 0.5 ? '#eef5fb' : '#cddced',
+        phase: Math.random() * Math.PI * 2, life: 1, snow: true,
+      });
+    } else {
+      const i = (Math.random() * (tips.length / 2) | 0) * 2;
+      const [li, da] = LEAF_COLORS[seasonIdx];
+      particles.push({
+        x: tips[i], y: tips[i + 1],
+        vx: (Math.random() - 0.5) * 12, vy: 6 + Math.random() * 14,
+        rot: Math.random() * Math.PI * 2, vr: (Math.random() - 0.5) * 4,
+        size: 2.4 + Math.random() * 3,
+        color: Math.random() > 0.5 ? li : da,
+        phase: Math.random() * Math.PI * 2, life: 1, snow: false,
+      });
+    }
   }
   spawnCarry = Math.min(spawnCarry, 4);
 }
@@ -185,51 +245,89 @@ function updateParticles(dt, t) {
   for (let i = particles.length - 1; i >= 0; i--) {
     const p = particles[i];
     if (p.y < ground) {
-      p.vy = Math.min(p.vy + 26 * dt, 46);
-      p.vx += (gust * 34 + Math.sin(t * 3 + p.phase) * 16) * dt; // wind drift + flutter
+      const term = p.snow ? 40 : 46;
+      p.vy = Math.min(p.vy + (p.snow ? 12 : 26) * dt, term);
+      p.vx += (gust * (p.snow ? 20 : 34) + Math.sin(t * 3 + p.phase) * (p.snow ? 8 : 16)) * dt;
       p.vx *= 0.985;
       p.x += p.vx * dt;
-      p.y += p.vy * dt + Math.sin(t * 4 + p.phase) * 0.35;      // fluttering descent
+      p.y += p.vy * dt + Math.sin(t * 4 + p.phase) * (p.snow ? 0.2 : 0.35);
       p.rot += p.vr * dt;
     } else {
-      p.life -= dt * 0.4;                                        // settle and fade
+      // landed: leaves join the ground pile, snow just melts away
+      if (!p.snow && pile.length < MAX_PILE) {
+        pile.push({ x: p.x, y: ground - Math.random() * 5, size: p.size, color: p.color });
+      }
+      particles.splice(i, 1);
+      continue;
     }
-    if (p.life <= 0 || p.x < -30 || p.x > W + 30) particles.splice(i, 1);
+    if (p.x < -30) p.x = W + 30; else if (p.x > W + 30) p.x = -30;
   }
 }
 
 function drawParticles() {
   for (const p of particles) {
     ctx.save();
-    ctx.translate(p.x, Math.min(p.y, H - 8));
-    ctx.rotate(p.rot);
-    ctx.globalAlpha = clamp(p.life, 0, 1) * 0.9;
+    ctx.translate(p.x, p.y);
+    ctx.globalAlpha = 0.9;
     ctx.fillStyle = p.color;
     ctx.beginPath();
-    ctx.ellipse(0, 0, p.size, p.size * 0.55, 0, 0, Math.PI * 2);
+    if (p.snow) {
+      ctx.arc(0, 0, p.size, 0, Math.PI * 2);
+    } else {
+      ctx.rotate(p.rot);
+      ctx.ellipse(0, 0, p.size, p.size * 0.55, 0, 0, Math.PI * 2);
+    }
     ctx.fill();
     ctx.restore();
   }
   ctx.globalAlpha = 1;
 }
 
+function drawPile() {
+  ctx.globalAlpha = 0.6;
+  for (const p of pile) {
+    ctx.fillStyle = p.color;
+    ctx.beginPath();
+    ctx.ellipse(p.x, p.y, p.size * 1.25, p.size * 0.5, 0, 0, Math.PI * 2);
+    ctx.fill();
+  }
+  ctx.globalAlpha = 1;
+}
+
 // ---------- background ----------
+const SKY_TOP = ['#1a2a45', '#1c3050', '#2a2438', '#141c2b'];
+const SKY_BOT = ['#3c5a72', '#41628a', '#6a4a4c', '#2c3a4d'];
 function drawBackground(seasonIdx, seasonMix) {
-  const skyTop = ['#1a2a45', '#1c3050', '#2a2438', '#141c2b'];
-  const skyBot = ['#3c5a72', '#41628a', '#6a4a4c', '#2c3a4d'];
   const g = ctx.createLinearGradient(0, 0, 0, H);
-  g.addColorStop(0, mixHex(skyTop[seasonIdx], skyTop[(seasonIdx + 1) % 4], seasonMix));
-  g.addColorStop(1, mixHex(skyBot[seasonIdx], skyBot[(seasonIdx + 1) % 4], seasonMix));
+  g.addColorStop(0, mixHex(SKY_TOP[seasonIdx], SKY_TOP[(seasonIdx + 1) % 4], seasonMix));
+  g.addColorStop(1, mixHex(SKY_BOT[seasonIdx], SKY_BOT[(seasonIdx + 1) % 4], seasonMix));
   ctx.fillStyle = g;
   ctx.fillRect(0, 0, W, H);
-  // ground
   ctx.fillStyle = 'rgba(20, 26, 22, 0.85)';
   ctx.fillRect(0, H - 8, W, 8);
 }
 
+// ---------- season helpers ----------
+function currentSeason() {
+  const phase = (seasonClock / SEASON_LEN) % 4;
+  const idx = phase | 0;
+  // cross-blend only in the last 20% of a season, and only while auto-cycling
+  const mix = autoCycle ? clamp((phase - idx - 0.8) / 0.2, 0, 1) : 0;
+  return { idx, mix };
+}
+function updateSeasonBadge(idx) {
+  seasonNameEl.textContent = SEASONS[idx];
+  seasonDotEl.style.color = SEASON_DOT[idx];
+}
+function nextSeason() {
+  // snap to the start of the next season so the pick reads cleanly
+  seasonClock = (Math.floor(seasonClock / SEASON_LEN) + 1) * SEASON_LEN;
+}
+
 // ---------- main loop ----------
 const t0 = performance.now();
 let last = t0;
+let badgeIdx = -1;
 function frame(now) {
   const t = (now - t0) / 1000;
   const dt = Math.min((now - last) / 1000, 0.05);
@@ -237,13 +335,13 @@ function frame(now) {
 
   grow = Math.min(grow + GROW_SPEED * dt, maxDepth + 1);
   gust = windAt(t);
+  if (autoCycle) seasonClock += dt;
 
-  const seasonPhase = (t / SEASON_LEN) % 4;
-  const seasonIdx = seasonPhase | 0;
-  const seasonMix = clamp((seasonPhase - seasonIdx - 0.8) / 0.2, 0, 1); // blend in last 20%
-  seasonEl.textContent = SEASONS[seasonIdx];
+  const { idx: seasonIdx, mix: seasonMix } = currentSeason();
+  if (seasonIdx !== badgeIdx) { badgeIdx = seasonIdx; updateSeasonBadge(seasonIdx); }
 
   drawBackground(seasonIdx, seasonMix);
+  drawPile();
 
   tips = [];
   ctx.lineCap = 'round';
@@ -258,6 +356,30 @@ function frame(now) {
   requestAnimationFrame(frame);
 }
 requestAnimationFrame(frame);
+
+// ---------- controls ----------
+depthSlider.addEventListener('input', () => {
+  maxDepth = +depthSlider.value;
+  depthVal.textContent = depthSlider.value;
+  grow = 0;                       // regrow so new depths animate in cleanly
+  pile.length = 0;
+});
+windSlider.addEventListener('input', () => {
+  windStrength = +windSlider.value / 100;
+  windVal.textContent = windStrength.toFixed(1);
+});
+document.getElementById('seasonBtn').addEventListener('click', nextSeason);
+const autoBtn = document.getElementById('autoBtn');
+autoBtn.addEventListener('click', () => {
+  autoCycle = !autoCycle;
+  autoBtn.classList.toggle('on', autoCycle);
+});
+document.getElementById('regrowBtn').addEventListener('click', () => {
+  grow = 0; particles.length = 0; pile.length = 0; spawnCarry = 0;
+});
+window.addEventListener('keydown', (e) => {
+  if (e.key === 's' || e.key === 'S') nextSeason();
+});
 </script>
 </body>
 </html>

← ffc15ae Living Fractal Tree — growing, wind-swaying canvas tree with  ·  back to Living Fractal Tree  ·  (newest)