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>