← Mike Higgins Home
Custom Code Experiment •

Faction Mosaic 2

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
  <title>Faction Mosaic 2</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
      user-select: none;
    }
    :root {
      color-scheme: light dark;
      background-color: light-dark(#eaeef3, #0a0c12);
    }
    body {
      margin: 0;
      padding: 0;
      overflow: hidden;
      background-color: inherit;
      height: 100vh;
      width: 100vw;
      position: fixed;
      top: 0;
      left: 0;
    }
    canvas {
      display: block;
      cursor: pointer;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
  </style>
</head>
<body>
<script>
  (function(){
    // ========== FLUID BLOBS WITH FORCES ==========
    let FactionCount = 4;
    const simSize = 28;                 // 28x28 = 784 large, visible blobs
    const temperature = 3.8;            // soft probabilistic transitions
    const mutationRate = 35;            // ~2.8% chance per step
    const clusterMutations = 1;
    const mutationRadius = 2;

    // Smoothing: faction changes blend over many frames (higher = slower, smoother)
    const SMOOTHING = 0.92;             // 92% old, 8% new each step → very gradual morphing

    // Force parameters for attraction/repulsion (offsets)
    const ATTRACT_STRENGTH = 0.008;
    const REPEL_STRENGTH = 0.018;
    const DAMPING = 0.92;
    const MAX_OFFSET = 22;              // max pixel shift from original cell center
    const FORCE_RADIUS = 1.5;           // consider neighbors within 1.5 cells (Moore + a bit)

    // Timing: simulation steps at lower FPS for stable evolution, drawing at full 60fps
    const simFPS = 24;
    const drawFPS = 60;

    // ----- utilities -----
    const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1) + min);
    const idx2coord = (index, width = simSize) => [index % width, Math.floor(index / width)];
    const coord2idx = (x, y, width = simSize) => (x + width) % width + ((y + width) % width) * width;

    // vibrant palette
    const initPalette = (length, hueOffset = randomInt(0, 240)) => {
      return Array.from({ length }, (_, i) => {
        const lightness = Math.round(48 + (i / length) * 28);
        const chroma = 62 + Math.floor(Math.random() * 12);
        const hue = (hueOffset + (360 / length) * i) % 360;
        return `oklch(${lightness}% ${chroma}% ${hue}deg / 0.85)`;
      });
    };

    const initWins = (length) => Array.from({ length }, (_, i) => (i - 1 + length) % length);

    // 8-direction offsets
    const neighborOffsets = [
      [-1,-1], [0,-1], [1,-1],
      [-1, 0],         [1, 0],
      [-1, 1], [0, 1], [1, 1]
    ];

    // ----- simulation state -----
    let currentGrid = Array.from({ length: simSize**2 }, () => randomInt(0, FactionCount-1));
    let targetGrid = [...currentGrid];
    let Palette = initPalette(FactionCount);
    let Wins = initWins(FactionCount);

    // Offsets for each cell (smooth motion due to forces)
    let offsets = new Array(simSize**2);
    let velocities = new Array(simSize**2);
    for (let i = 0; i < offsets.length; i++) {
      offsets[i] = { dx: (Math.random() - 0.5) * 4, dy: (Math.random() - 0.5) * 4 };
      velocities[i] = { dx: 0, dy: 0 };
    }

    // ----- canvas -----
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    document.body.appendChild(canvas);

    function resizeCanvasToDisplay() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      draw();
    }
    window.addEventListener('resize', () => resizeCanvasToDisplay());

    // reset simulation (also resets offsets)
    const randomize = () => {
      FactionCount = randomInt(3, 5);
      const newVals = Array.from({ length: simSize**2 }, () => randomInt(0, FactionCount-1));
      currentGrid = [...newVals];
      targetGrid = [...newVals];
      Palette = initPalette(FactionCount);
      Wins = initWins(FactionCount);
      // reset offsets to avoid sudden jumps
      for (let i = 0; i < offsets.length; i++) {
        offsets[i] = { dx: (Math.random() - 0.5) * 4, dy: (Math.random() - 0.5) * 4 };
        velocities[i] = { dx: 0, dy: 0 };
      }
      draw();
    };

    let interval = setInterval(() => randomize(), 18000);
    canvas.addEventListener('click', () => {
      randomize();
      if (interval) { clearInterval(interval); interval = null; }
    });

    // ---------- UPDATE FORCES (attraction/repulsion on offsets) ----------
    function updateForces() {
      // First, clear velocities?
      // Actually we accumulate forces then apply damping.
      const forces = new Array(offsets.length);
      for (let i = 0; i < forces.length; i++) forces[i] = { dx: 0, dy: 0 };

      // For each cell, look at neighbors within FORCE_RADIUS (chebyshev distance <= radius)
      for (let i = 0; i < currentGrid.length; i++) {
        const [cx, cy] = idx2coord(i);
        const myFaction = currentGrid[i];
        const myOffset = offsets[i];

        // scan square of side (2*floor(radius)+1) around cell
        const rad = Math.floor(FORCE_RADIUS);
        for (let dy = -rad; dy <= rad; dy++) {
          for (let dx = -rad; dx <= rad; dx++) {
            if (dx === 0 && dy === 0) continue;
            const nx = (cx + dx + simSize) % simSize;
            const ny = (cy + dy + simSize) % simSize;
            const j = nx + ny * simSize;
            const nbFaction = currentGrid[j];
            const nbOffset = offsets[j];

            // distance in actual screen space? Use cell index distance, but weight by force radius
            const dist = Math.sqrt(dx*dx + dy*dy);
            if (dist > FORCE_RADIUS) continue;

            // direction from me to neighbor (in grid units, but we want to affect offsets)
            // Since offsets are added to cell positions, the force should be proportional to difference
            const dirX = (dx + (nbOffset.dx - myOffset.dx) * 0.1);  // small influence from offset difference
            const dirY = (dy + (nbOffset.dy - myOffset.dy) * 0.1);
            const len = Math.hypot(dirX, dirY);
            if (len < 0.01) continue;
            const normX = dirX / len;
            const normY = dirY / len;

            let strength = 0;
            if (myFaction === nbFaction) {
              // Attraction: pull offsets together
              strength = ATTRACT_STRENGTH * (1 - dist / FORCE_RADIUS);
            } else {
              // Repulsion: push offsets apart
              strength = -REPEL_STRENGTH * (1 - dist / FORCE_RADIUS);
            }

            forces[i].dx += normX * strength;
            forces[i].dy += normY * strength;
            // Newton's 3rd: equal opposite on neighbor? Not strictly needed for visual effect, but adds symmetry
            forces[j].dx -= normX * strength;
            forces[j].dy -= normY * strength;
          }
        }
      }

      // Update velocities and offsets
      for (let i = 0; i < offsets.length; i++) {
        velocities[i].dx = velocities[i].dx * DAMPING + forces[i].dx;
        velocities[i].dy = velocities[i].dy * DAMPING + forces[i].dy;
        offsets[i].dx += velocities[i].dx;
        offsets[i].dy += velocities[i].dy;
        // clamp to reasonable range (avoid flying away)
        const maxOff = MAX_OFFSET;
        offsets[i].dx = Math.min(maxOff, Math.max(-maxOff, offsets[i].dx));
        offsets[i].dy = Math.min(maxOff, Math.max(-maxOff, offsets[i].dy));
      }
    }

    // ---------- SIMULATION STEP (faction evolution + smoothing) ----------
    function stepSimulation() {
      // Compute next target grid using probabilistic softmax
      const scoresPerFaction = new Array(targetGrid.length);
      for (let i = 0; i < targetGrid.length; i++) {
        const neighbors = [];
        const [cx, cy] = idx2coord(i);
        for (let k = 0; k < neighborOffsets.length; k++) {
          const dx = neighborOffsets[k][0];
          const dy = neighborOffsets[k][1];
          const nx = (cx + dx + simSize) % simSize;
          const ny = (cy + dy + simSize) % simSize;
          neighbors.push(targetGrid[nx + ny * simSize]);
        }
        const factionScores = new Array(FactionCount).fill(0);
        for (let n = 0; n < neighbors.length; n++) {
          const nb = neighbors[n];
          for (let f = 0; f < FactionCount; f++) {
            if (f === nb) {
              factionScores[f] += 1.2;
            } else {
              factionScores[f] += (Wins[f] === nb) ? 0.9 : -0.7;
            }
          }
        }
        for (let f = 0; f < FactionCount; f++) {
          factionScores[f] += (Math.random() - 0.5) * 0.35;
        }
        scoresPerFaction[i] = factionScores;
      }

      const newTarget = new Array(targetGrid.length);
      for (let i = 0; i < targetGrid.length; i++) {
        const scores = scoresPerFaction[i];
        let maxScore = -Infinity;
        for (let f = 0; f < FactionCount; f++) if (scores[f] > maxScore) maxScore = scores[f];
        let sumExp = 0;
        const exps = new Array(FactionCount);
        for (let f = 0; f < FactionCount; f++) {
          const val = Math.exp((scores[f] - maxScore) / temperature);
          exps[f] = val;
          sumExp += val;
        }
        let r = Math.random() * sumExp;
        let chosen = 0;
        for (let f = 0; f < FactionCount; f++) {
          r -= exps[f];
          if (r <= 0) { chosen = f; break; }
        }
        newTarget[i] = chosen;
      }

      // Mutations (cluster)
      if (randomInt(0, mutationRate) === 0) {
        for (let m = 0; m < clusterMutations; m++) {
          const center = randomInt(0, newTarget.length-1);
          const newFaction = randomInt(0, FactionCount-1);
          const [cx, cy] = idx2coord(center);
          const rad = mutationRadius;
          for (let dy = -rad; dy <= rad; dy++) {
            for (let dx = -rad; dx <= rad; dx++) {
              const nx = (cx + dx + simSize) % simSize;
              const ny = (cy + dy + simSize) % simSize;
              const idx = nx + ny * simSize;
              const dist = Math.sqrt(dx*dx+dy*dy);
              if (dist <= rad && Math.random() < (1 - dist/rad)*0.8) {
                newTarget[idx] = newFaction;
              }
            }
          }
        }
      }

      targetGrid = newTarget;

      // Smooth blending: currentGrid moves toward targetGrid gradually
      for (let i = 0; i < currentGrid.length; i++) {
        if (currentGrid[i] !== targetGrid[i]) {
          if (Math.random() < (1 - SMOOTHING)) {
            currentGrid[i] = targetGrid[i];
          }
        }
      }

      // tiny random fizz to prevent complete stasis
      if (randomInt(0, 120) === 0) {
        const idx = randomInt(0, currentGrid.length-1);
        currentGrid[idx] = randomInt(0, FactionCount-1);
      }
    }

    // ----- DRAWING with offset positions (morphing blobs) -----
    function draw() {
      if (!ctx) return;
      const w = canvas.width;
      const h = canvas.height;
      if (w === 0 || h === 0) return;

      const cellW = w / simSize;
      const cellH = h / simSize;
      ctx.clearRect(0, 0, w, h);

      // Draw each cell as a soft circle, shifted by its offset
      for (let i = 0; i < currentGrid.length; i++) {
        const faction = currentGrid[i];
        const [col, row] = idx2coord(i);
        const baseX = col * cellW;
        const baseY = row * cellH;
        const off = offsets[i];
        const drawX = baseX + off.dx;
        const drawY = baseY + off.dy;

        // Radius bigger than cell for overlapping, organic look
        const baseRadius = Math.min(cellW, cellH) * 0.72;
        const radiusJitter = (Math.random() * 6) - 1.5;
        const radius = Math.max(4.5, baseRadius + radiusJitter);

        ctx.fillStyle = Palette[faction];
        ctx.beginPath();
        ctx.arc(drawX, drawY, radius, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    // ----- ANIMATION LOOP: separate timings for simulation and drawing -----
    let lastSimTime = 0;
    let lastDrawTime = 0;

    function animationLoop(now) {
      requestAnimationFrame(animationLoop);

      // Update simulation at lower framerate
      if (now - lastSimTime >= 1000 / simFPS) {
        lastSimTime = now;
        stepSimulation();
      }

      // Update forces and drawing at high framerate (drawFPS)
      if (now - lastDrawTime >= 1000 / drawFPS) {
        lastDrawTime = now;
        updateForces();   // update offsets smoothly, independent of faction changes
        draw();
      }
    }

    resizeCanvasToDisplay();
    draw();
    animationLoop(0);

    window.addEventListener('orientationchange', () => {
      setTimeout(() => { resizeCanvasToDisplay(); draw(); }, 30);
    });
  })();
</script>
</body>
</html>