Web-based prototype for a boids flocking simulation. The simulation is based on the classic separation, alignment and cohesion rules, extended with optional V-formation, beacon attraction and predator behaviours. The prototype shows a black-and-white canvas with a side panel for presets, behaviour toggles, simulation sliders, visual settings and actions. Presets cover flock types such as fish schools, murmurations, bird V-formations, midge clouds, ant trails, moths around a lantern, bat swarms, slime mold, cell colonies, sheep and sheepdog, hawk attacks, bait balls, mycelium, biofilms and sunflower seed packing. The prototype can be used for design exploration, teaching of swarm behaviour, visual studies and as a starting point for further tools.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SWARM PLAYGROUND v1.0</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<aside id="panel">
<header>
<h1>SWARM PLAYGROUND</h1>
<p class="sub">v1.0 — boids playground</p>
</header>
<section>
<h2>Presets</h2>
<div class="btn-grid" id="preset-buttons"></div>
<div id="preset-desc"></div>
</section>
<section>
<h2>Behaviour</h2>
<div id="behaviour-controls"></div>
<p class="hint">toggle behaviours on/off and adjust their weight to see the difference</p>
</section>
<section>
<h2>Simulation</h2>
<div id="sim-controls"></div>
<p class="name">Edges</p>
<div class="seg cols-2" id="edge-buttons"></div>
<p class="name">Mouse drag</p>
<div class="seg cols-3" id="mouse-buttons"></div>
</section>
<section>
<h2>Visual</h2>
<div id="visual-controls"></div>
</section>
<section>
<h2>Actions</h2>
<div class="btn-grid cols-3">
<button id="btn-random" title="randomize all parameters">Random</button>
<button id="btn-reset" title="scatter boids again">Scatter</button>
<button id="btn-pause" title="pause / resume">Pause</button>
</div>
<p class="hint">R — randomize · S — scatter · Space — pause</p>
</section>
<footer id="stats">—</footer>
</aside>
<main id="stage">
<canvas id="canvas"></canvas>
</main>
<script src="sim.js"></script>
</body>
</html>
'use strict';
/* =====================================================================
SWARM — boids playground
behaviours: separation / alignment / cohesion / v-formation
===================================================================== */
/* ---------- canvas ---------- */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const stage = document.getElementById('stage');
const statsEl = document.getElementById('stats');
let W = 0, H = 0;
let curDpr = 1;
function resize() {
const dpr = window.devicePixelRatio || 1;
curDpr = dpr;
W = stage.clientWidth;
H = stage.clientHeight;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
/* CSS size stays in CSS pixels — the backing store carries the pixel density */
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
}
window.addEventListener('resize', resize);
/* ---------- parameters ---------- */
const P = {
count: 300,
maxSpeed: 3.0,
maxForce: 0.06, // steering strength
perception: 60, // neighbour radius
sepDistance: 24, // separation radius
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 1.5, alignment: 1.0, cohesion: 1.0, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, // slot distance in the V
edgeMode: 'wrap', // 'wrap' | 'bounce'
mouseMode: 'off', // 'off' | 'attract' | 'repel'
traces: false,
traceFade: 0.04, // lower = longer trails
boidSize: 5,
paused: false,
};
/* ---------- helpers ---------- */
const rand = (a, b) => a + Math.random() * (b - a);
const randInt = (a, b) => Math.floor(rand(a, b + 1));
function angDiff(a, b) {
let d = a - b;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
return d;
}
/* shared steer result (avoids per-call allocations) */
const S = { x: 0, y: 0 };
/** steer towards direction (dirX, dirY), result in S — limited by maxForce */
function steer(b, dirX, dirY) {
const m = Math.hypot(dirX, dirY);
if (m < 1e-6) { S.x = 0; S.y = 0; return; }
const dvx = (dirX / m) * P.maxSpeed - b.vx;
const dvy = (dirY / m) * P.maxSpeed - b.vy;
const dm = Math.hypot(dvx, dvy);
if (dm < 1e-6) { S.x = 0; S.y = 0; return; }
const f = Math.min(dm, P.maxForce) / dm;
S.x = dvx * f;
S.y = dvy * f;
}
/* ---------- boids ---------- */
let boids = [];
let frame = 0;
function makeBoid() {
const a = Math.random() * Math.PI * 2;
const s = P.maxSpeed * rand(0.5, 1);
return {
x: Math.random() * W,
y: Math.random() * H,
vx: Math.cos(a) * s,
vy: Math.sin(a) * s,
h: a, // heading (used by the V-leader)
ph: Math.random() * Math.PI * 2, // wander phase
};
}
function scatter() {
boids = [];
for (let i = 0; i < P.count; i++) boids.push(makeBoid());
predator.init = false; // reposition on next update
}
function setCount(n) {
n = Math.max(2, Math.round(n));
while (boids.length < n) boids.push(makeBoid());
if (boids.length > n) boids.length = n;
P.count = n;
}
/* ---------- mouse ---------- */
const mouse = { x: 0, y: 0, active: false };
canvas.addEventListener('pointermove', (e) => {
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left;
mouse.y = e.clientY - r.top;
mouse.active = true;
});
canvas.addEventListener('pointerdown', (e) => {
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left;
mouse.y = e.clientY - r.top;
mouse.active = true;
});
canvas.addEventListener('pointerleave', () => { mouse.active = false; });
/* ---------- edges ---------- */
function handleEdges(b) {
if (P.edgeMode === 'wrap' && !P.enabled.vFormation) {
if (b.x < 0) b.x += W; else if (b.x >= W) b.x -= W;
if (b.y < 0) b.y += H; else if (b.y >= H) b.y -= H;
} else {
const m = 4;
if (b.x < m) { b.x = m; b.vx = Math.abs(b.vx); }
else if (b.x > W - m) { b.x = W - m; b.vx = -Math.abs(b.vx); }
if (b.y < m) { b.y = m; b.vy = Math.abs(b.vy); }
else if (b.y > H - m) { b.y = H - m; b.vy = -Math.abs(b.vy); }
}
}
/* ---------- beacon (light / attractor) ---------- */
const beacon = { x: 0, y: 0, t: Math.random() * 100 };
function updateBeacon() {
beacon.t += 0.005;
beacon.x = W * 0.5 + Math.sin(beacon.t * 0.7) * W * 0.33 + Math.sin(beacon.t * 1.9) * W * 0.07;
beacon.y = H * 0.5 + Math.cos(beacon.t * 0.9) * H * 0.30 + Math.sin(beacon.t * 2.3) * H * 0.07;
}
/* ---------- predator (hawk / sheepdog / shark) ---------- */
const predator = { x: 0, y: 0, vx: 0, vy: 0, init: false };
const FEAR_RADIUS = 180;
function updatePredator() {
if (!P.enabled.predator) { predator.init = false; return; }
if (!predator.init) {
predator.init = true;
predator.x = Math.random() * W;
predator.y = Math.random() * H;
const a = Math.random() * Math.PI * 2;
predator.vx = Math.cos(a) * P.maxSpeed;
predator.vy = Math.sin(a) * P.maxSpeed;
}
/* hunt the nearest boid */
let best = null, bd = Infinity;
for (const b of boids) {
const dx = b.x - predator.x, dy = b.y - predator.y;
const d2 = dx * dx + dy * dy;
if (d2 < bd) { bd = d2; best = b; }
}
if (best) {
steer(predator, best.x - predator.x, best.y - predator.y);
predator.vx += S.x * 3;
predator.vy += S.y * 3;
}
/* slightly faster than the flock */
const sp = Math.hypot(predator.vx, predator.vy);
const max = P.maxSpeed * 1.25;
if (sp > max) { predator.vx *= max / sp; predator.vy *= max / sp; }
predator.x += predator.vx;
predator.y += predator.vy;
/* predators always bounce off walls */
const m = 4;
if (predator.x < m) { predator.x = m; predator.vx = Math.abs(predator.vx); }
else if (predator.x > W - m) { predator.x = W - m; predator.vx = -Math.abs(predator.vx); }
if (predator.y < m) { predator.y = m; predator.vy = Math.abs(predator.vy); }
else if (predator.y > H - m) { predator.y = H - m; predator.vy = -Math.abs(predator.vy); }
}
/* ---------- update ---------- */
function update() {
const n = boids.length;
if (!n) return;
const en = P.enabled;
const w = P.weights;
const p2 = P.perception * P.perception;
const s2 = P.sepDistance * P.sepDistance;
/* update external actors */
if (en.beacon) updateBeacon();
updatePredator();
/* V-formation leader: flies a gently wandering course, avoids walls */
if (en.vFormation && n > 1) {
const L = boids[0];
L.h += Math.sin(frame / 90 + L.ph) * 0.012;
const margin = 100;
let cx = 0, cy = 0;
if (L.x < margin) cx = 1; else if (L.x > W - margin) cx = -1;
if (L.y < margin) cy = 1; else if (L.y > H - margin) cy = -1;
if (cx || cy) L.h += angDiff(Math.atan2(cy, cx), L.h) * 0.05;
L.vx = Math.cos(L.h) * P.maxSpeed;
L.vy = Math.sin(L.h) * P.maxSpeed;
L.x += L.vx;
L.y += L.vy;
handleEdges(L);
}
const start = en.vFormation ? 1 : 0;
for (let i = start; i < n; i++) {
const b = boids[i];
/* gather neighbours */
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0;
let nb = 0, sepN = 0;
for (let j = 0; j < n; j++) {
if (j === i) continue;
const o = boids[j];
const dx = o.x - b.x, dy = o.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > p2) continue;
aliX += o.vx; aliY += o.vy;
cohX += o.x; cohY += o.y;
nb++;
if (d2 < s2 && d2 > 1e-4) {
const d = Math.sqrt(d2);
const f = 1 - d / P.sepDistance; // stronger the closer
sepX -= (dx / d) * f;
sepY -= (dy / d) * f;
sepN++;
}
}
let fx = 0, fy = 0;
/* the three classic rules */
if (en.separation && sepN) {
steer(b, sepX, sepY);
fx += S.x * w.separation; fy += S.y * w.separation;
}
if (en.alignment && nb) {
steer(b, aliX, aliY);
fx += S.x * w.alignment; fy += S.y * w.alignment;
}
if (en.cohesion && nb) {
steer(b, cohX / nb - b.x, cohY / nb - b.y);
fx += S.x * w.cohesion; fy += S.y * w.cohesion;
}
/* V-formation: follow the slot behind your parent bird */
if (en.vFormation && n > 2) {
const pi = i <= 2 ? 0 : i - 2; // parent along the arm
const parent = boids[pi];
const side = (i % 2 === 1) ? -1 : 1; // alternate arms
const ph = Math.atan2(parent.vy, parent.vx);
const backX = -Math.cos(ph), backY = -Math.sin(ph);
const perpX = -backY * side, perpY = backX * side;
const tx = parent.x + backX * P.vGap + perpX * P.vGap * 0.9;
const ty = parent.y + backY * P.vGap + perpY * P.vGap * 0.9;
steer(b, tx - b.x, ty - b.y);
fx += S.x * w.vFormation * 2; fy += S.y * w.vFormation * 2;
}
/* mouse interaction */
if (mouse.active && P.mouseMode !== 'off') {
const dx = mouse.x - b.x, dy = mouse.y - b.y;
const d = Math.hypot(dx, dy) || 1;
if (d < 260) {
const sign = P.mouseMode === 'attract' ? 1 : -1;
const fall = 1 - d / 260;
steer(b, dx * sign, dy * sign);
fx += S.x * 3 * fall; fy += S.y * 3 * fall;
}
}
/* beacon attraction (moth to the light) */
if (en.beacon) {
steer(b, beacon.x - b.x, beacon.y - b.y);
fx += S.x * w.beacon * 1.5; fy += S.y * w.beacon * 1.5;
}
/* flee the predator */
if (en.predator && predator.init) {
const pdx = b.x - predator.x, pdy = b.y - predator.y;
const pd2 = pdx * pdx + pdy * pdy;
if (pd2 < FEAR_RADIUS * FEAR_RADIUS) {
const pd = Math.sqrt(pd2) || 1;
steer(b, pdx, pdy);
const fear = (1 - pd / FEAR_RADIUS) * 4;
fx += S.x * w.predator * fear; fy += S.y * w.predator * fear;
}
}
b.vx += fx;
b.vy += fy;
/* clamp speed: never faster than max, never slower than min */
const sp = Math.hypot(b.vx, b.vy);
const min = P.maxSpeed * 0.35;
if (sp > P.maxSpeed) {
b.vx *= P.maxSpeed / sp; b.vy *= P.maxSpeed / sp;
} else if (sp < min && sp > 1e-6) {
b.vx *= min / sp; b.vy *= min / sp;
}
b.x += b.vx;
b.y += b.vy;
handleEdges(b);
}
}
/* ---------- draw ---------- */
function draw() {
/* full clear, or a translucent white wash that slowly fades old frames into traces */
ctx.fillStyle = P.traces ? `rgba(255,255,255,${P.traceFade})` : '#ffffff';
ctx.fillRect(0, 0, W, H);
const s = P.boidSize;
ctx.fillStyle = '#000';
for (let i = 0; i < boids.length; i++) {
const b = boids[i];
const a = Math.atan2(b.vy, b.vx);
const c = Math.cos(a), sn = Math.sin(a);
ctx.beginPath();
ctx.moveTo(b.x + c * s, b.y + sn * s);
ctx.lineTo(b.x - c * s * 0.55 - sn * s * 0.5, b.y - sn * s * 0.55 + c * s * 0.5);
ctx.lineTo(b.x - c * s * 0.55 + sn * s * 0.5, b.y - sn * s * 0.55 - c * s * 0.5);
ctx.closePath();
ctx.fill();
}
/* leader marker */
if (P.enabled.vFormation && boids.length) {
const L = boids[0];
ctx.strokeStyle = 'rgba(0,0,0,0.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(L.x, L.y, P.boidSize * 2.4, 0, Math.PI * 2);
ctx.stroke();
}
/* beacon marker */
if (P.enabled.beacon) {
ctx.strokeStyle = 'rgba(0,0,0,0.45)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(beacon.x, beacon.y, 5, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(beacon.x - 10, beacon.y); ctx.lineTo(beacon.x + 10, beacon.y);
ctx.moveTo(beacon.x, beacon.y - 10); ctx.lineTo(beacon.x, beacon.y + 10);
ctx.stroke();
}
/* predator — bigger body with a ring */
if (P.enabled.predator && predator.init) {
const pa = Math.atan2(predator.vy, predator.vx);
const pc = Math.cos(pa), psn = Math.sin(pa);
const ps = P.boidSize * 2.2;
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.moveTo(predator.x + pc * ps, predator.y + psn * ps);
ctx.lineTo(predator.x - pc * ps * 0.55 - psn * ps * 0.5, predator.y - psn * ps * 0.55 + pc * ps * 0.5);
ctx.lineTo(predator.x - pc * ps * 0.55 + psn * ps * 0.5, predator.y - psn * ps * 0.55 - pc * ps * 0.5);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.beginPath();
ctx.arc(predator.x, predator.y, ps * 2, 0, Math.PI * 2);
ctx.stroke();
}
/* mouse radius indicator */
if (mouse.active && P.mouseMode !== 'off') {
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, 260, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
}
/* ---------- main loop + stats ---------- */
let frames = 0, lastStat = performance.now();
function loop() {
requestAnimationFrame(loop);
/* react to zoom / monitor changes (pixel density) */
if ((window.devicePixelRatio || 1) !== curDpr) resize();
if (P.paused) return;
frame++;
update();
draw();
frames++;
const now = performance.now();
if (now - lastStat >= 500) {
const fps = Math.round((frames * 1000) / (now - lastStat));
statsEl.textContent = `${boids.length} boids · ${fps} fps${P.paused ? ' · paused' : ''}`;
frames = 0;
lastStat = now;
}
}
/* =====================================================================
UI
===================================================================== */
const uiSync = []; // fns that push P back into the controls
/* --- slider row --- */
function addSlider(parent, { label, min, max, step, get, set, fmt }) {
const row = document.createElement('div');
row.className = 'row';
const name = document.createElement('p');
name.className = 'name';
name.textContent = label;
const val = document.createElement('span');
val.className = 'val';
const input = document.createElement('input');
input.type = 'range';
input.min = min; input.max = max; input.step = step;
input.addEventListener('input', () => { set(parseFloat(input.value)); val.textContent = fmt(get()); });
row.append(name, val, input);
parent.appendChild(row);
uiSync.push(() => { input.value = get(); val.textContent = fmt(get()); });
return row;
}
/* --- behaviour row: checkbox + slider --- */
function addBehaviour(parent, { label, weightKey, weightGet, weightSet }) {
const row = document.createElement('div');
row.className = 'beh-row';
const chk = document.createElement('label');
chk.className = 'chk';
const box = document.createElement('input');
box.type = 'checkbox';
const txt = document.createElement('span');
txt.textContent = label;
chk.append(box, txt);
const sliderPart = document.createElement('div');
sliderPart.className = 'beh-slider';
const input = document.createElement('input');
input.type = 'range';
input.min = 0; input.max = 4; input.step = 0.05;
const val = document.createElement('span');
val.className = 'val';
sliderPart.append(input, val);
box.addEventListener('change', () => {
P.enabled[weightKey] = box.checked;
row.classList.toggle('off', !box.checked);
});
input.addEventListener('input', () => {
weightSet(parseFloat(input.value));
val.textContent = weightGet().toFixed(2);
});
row.append(chk, sliderPart);
parent.appendChild(row);
uiSync.push(() => {
box.checked = P.enabled[weightKey];
row.classList.toggle('off', !box.checked);
input.value = weightGet();
val.textContent = weightGet().toFixed(2);
});
}
/* --- segmented buttons --- */
function addSeg(parent, { options, get, set }) {
const buttons = [];
for (const opt of options) {
const btn = document.createElement('button');
btn.textContent = opt.label;
btn.addEventListener('click', () => { set(opt.value); syncUI(); });
parent.appendChild(btn);
buttons.push({ btn, value: opt.value });
}
uiSync.push(() => {
for (const { btn, value } of buttons) btn.classList.toggle('active', get() === value);
});
}
const $ = (id) => document.getElementById(id);
/* --- behaviour --- */
const behBox = $('behaviour-controls');
addBehaviour(behBox, {
label: 'Separation', weightKey: 'separation',
weightGet: () => P.weights.separation, weightSet: (v) => { P.weights.separation = v; },
});
addBehaviour(behBox, {
label: 'Alignment', weightKey: 'alignment',
weightGet: () => P.weights.alignment, weightSet: (v) => { P.weights.alignment = v; },
});
addBehaviour(behBox, {
label: 'Cohesion / Follow', weightKey: 'cohesion',
weightGet: () => P.weights.cohesion, weightSet: (v) => { P.weights.cohesion = v; },
});
addBehaviour(behBox, {
label: 'V-Formation', weightKey: 'vFormation',
weightGet: () => P.weights.vFormation, weightSet: (v) => { P.weights.vFormation = v; },
});
addBehaviour(behBox, {
label: 'Beacon / Light', weightKey: 'beacon',
weightGet: () => P.weights.beacon, weightSet: (v) => { P.weights.beacon = v; },
});
addBehaviour(behBox, {
label: 'Predator', weightKey: 'predator',
weightGet: () => P.weights.predator, weightSet: (v) => { P.weights.predator = v; },
});
/* --- simulation --- */
const simBox = $('sim-controls');
addSlider(simBox, {
label: 'Boids', min: 2, max: 800, step: 1,
get: () => P.count, set: (v) => setCount(v), fmt: (v) => Math.round(v),
});
addSlider(simBox, {
label: 'Max speed', min: 0.5, max: 8, step: 0.1,
get: () => P.maxSpeed, set: (v) => { P.maxSpeed = v; }, fmt: (v) => v.toFixed(1),
});
addSlider(simBox, {
label: 'Turn force', min: 0.01, max: 0.4, step: 0.005,
get: () => P.maxForce, set: (v) => { P.maxForce = v; }, fmt: (v) => v.toFixed(3),
});
addSlider(simBox, {
label: 'Perception radius', min: 10, max: 150, step: 1,
get: () => P.perception, set: (v) => { P.perception = v; }, fmt: (v) => Math.round(v),
});
addSlider(simBox, {
label: 'Separation distance', min: 6, max: 60, step: 1,
get: () => P.sepDistance, set: (v) => { P.sepDistance = v; }, fmt: (v) => Math.round(v),
});
addSlider(simBox, {
label: 'V gap', min: 10, max: 44, step: 1,
get: () => P.vGap, set: (v) => { P.vGap = v; }, fmt: (v) => Math.round(v),
});
addSeg($('edge-buttons'), {
options: [{ label: 'Wrap', value: 'wrap' }, { label: 'Bounce', value: 'bounce' }],
get: () => P.edgeMode,
set: (v) => { P.edgeMode = v; },
});
addSeg($('mouse-buttons'), {
options: [
{ label: 'Off', value: 'off' },
{ label: 'Attract', value: 'attract' },
{ label: 'Repel', value: 'repel' },
],
get: () => P.mouseMode,
set: (v) => { P.mouseMode = v; },
});
/* --- visual --- */
const visBox = $('visual-controls');
const traceChk = document.createElement('label');
traceChk.className = 'chk';
const traceBox = document.createElement('input');
traceBox.type = 'checkbox';
const traceTxt = document.createElement('span');
traceTxt.textContent = 'Traces';
traceChk.append(traceBox, traceTxt);
traceChk.style.marginBottom = '8px';
traceBox.addEventListener('change', () => { P.traces = traceBox.checked; syncUI(); });
visBox.appendChild(traceChk);
addSlider(visBox, {
label: 'Trace fade (low = long)', min: 0.005, max: 0.2, step: 0.005,
get: () => P.traceFade, set: (v) => { P.traceFade = v; }, fmt: (v) => v.toFixed(3),
});
addSlider(visBox, {
label: 'Boid size', min: 2, max: 10, step: 0.5,
get: () => P.boidSize, set: (v) => { P.boidSize = v; }, fmt: (v) => v.toFixed(1),
});
uiSync.push(() => { traceBox.checked = P.traces; });
/* --- actions --- */
function syncUI() { for (const fn of uiSync) fn(); }
$('btn-random').addEventListener('click', randomize);
$('btn-reset').addEventListener('click', () => { scatter(); syncUI(); });
const pauseBtn = $('btn-pause');
pauseBtn.addEventListener('click', togglePause);
function togglePause() {
P.paused = !P.paused;
pauseBtn.textContent = P.paused ? 'Resume' : 'Pause';
if (!P.paused) lastStat = performance.now();
}
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') randomize();
else if (e.key === 's' || e.key === 'S') { scatter(); syncUI(); }
});
/* =====================================================================
presets & randomize
===================================================================== */
const PRESETS = {
basic: {
label: 'Basic',
desc: 'the classic three rules, balanced',
how: 'separation 1.5 · alignment 1.0 · cohesion 1.0 — balanced tension between the three rules produces the canonical flock.',
vals: {
count: 300, maxSpeed: 3.0, maxForce: 0.06, perception: 60, sepDistance: 24,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 1.5, alignment: 1.0, cohesion: 1.0, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'wrap', traces: false, traceFade: 0.04, boidSize: 5,
},
},
flies: {
label: 'Fly Swarm',
desc: 'fast, twitchy, weak bonds — chaotic cloud',
how: 'huge turn force + weak alignment 0.25: bonds break constantly, the cloud never settles — twitchy chaos.',
vals: {
count: 450, maxSpeed: 5.5, maxForce: 0.28, perception: 34, sepDistance: 16,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 3.2, alignment: 0.25, cohesion: 0.7, vFormation: 0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: false, traceFade: 0.04, boidSize: 3.5,
},
},
fish: {
label: 'Fish School',
desc: 'tight, highly aligned, flowing bands',
how: 'alignment 2.0 + cohesion 1.8 at low turn force: neighbours copy each other and flow in long fluent bands.',
vals: {
count: 500, maxSpeed: 3.4, maxForce: 0.05, perception: 85, sepDistance: 18,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 1.0, alignment: 2.0, cohesion: 1.8, vFormation: 0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'wrap', traces: false, traceFade: 0.04, boidSize: 4.5,
},
},
murmur: {
label: 'Murmuration',
desc: 'big starling flock with fading traces',
how: '600 boids at medium weights, bounce edges — fading traces expose the wheeling density waves of a starling flock.',
vals: {
count: 600, maxSpeed: 4.5, maxForce: 0.14, perception: 70, sepDistance: 22,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 1.8, alignment: 1.3, cohesion: 1.4, vFormation: 0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: true, traceFade: 0.03, boidSize: 3,
},
},
geese: {
label: 'Bird V-Formation',
desc: 'leader bird with two trailing arms',
how: 'v-formation module: each bird seeks the slot behind its parent, arms alternate left/right behind the wandering leader.',
vals: {
count: 29, maxSpeed: 4.0, maxForce: 0.09, perception: 70, sepDistance: 16,
enabled: { separation: true, alignment: true, cohesion: false, vFormation: true, beacon: false, predator: false },
weights: { separation: 0.8, alignment: 0.8, cohesion: 0.0, vFormation: 2.2, beacon: 1.5, predator: 1.5 },
vGap: 26, edgeMode: 'bounce', traces: false, traceFade: 0.04, boidSize: 6,
},
},
midge: {
label: 'Midge Dance',
desc: 'tiny males dancing in a hovering cloud over a marker',
how: 'slow speed + huge turn force + tight separation + mild beacon: the cloud hovers and dances around a drifting marker.',
vals: {
count: 280, maxSpeed: 1.2, maxForce: 0.30, perception: 26, sepDistance: 10,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: true, predator: false },
weights: { separation: 2.5, alignment: 0.5, cohesion: 1.8, vFormation: 2.0, beacon: 0.6, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: false, traceFade: 0.04, boidSize: 2,
},
},
locust: {
label: 'Locust Front',
desc: 'dense rolling swarm marching across the field',
how: 'alignment 3.0 dominates — everyone marches one way, low separation packs the swarm into a rolling front.',
vals: {
count: 550, maxSpeed: 6.0, maxForce: 0.12, perception: 55, sepDistance: 12,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 1.0, alignment: 3.0, cohesion: 0.5, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'wrap', traces: false, traceFade: 0.04, boidSize: 4,
},
},
ants: {
label: 'Ant Trails',
desc: 'slow foragers weaving long faint scent trails',
how: 'all rules weak + very long traces: near-random walks slowly paint the trail network the colony follows.',
vals: {
count: 340, maxSpeed: 1.6, maxForce: 0.10, perception: 30, sepDistance: 10,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 1.2, alignment: 0.15, cohesion: 0.2, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: true, traceFade: 0.012, boidSize: 2.5,
},
},
moths: {
label: 'Moths & Lantern',
desc: 'a cloud orbiting a slowly wandering lantern',
how: 'beacon 1.0 vs separation 2.0 cancel at orbit distance — attraction bends paths toward the light, repulsion keeps them circling.',
vals: {
count: 240, maxSpeed: 3.2, maxForce: 0.14, perception: 50, sepDistance: 14,
enabled: { separation: true, alignment: true, cohesion: false, vFormation: false, beacon: true, predator: false },
weights: { separation: 2.0, alignment: 0.3, cohesion: 1.0, vFormation: 2.0, beacon: 1.0, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: false, traceFade: 0.04, boidSize: 3,
},
},
bats: {
label: 'Bat Cave',
desc: 'fast swirling vortex pouring out of the hollow',
how: 'max speed with strong turn force at bounce edges: reflected waves fold the swarm into vortices.',
vals: {
count: 480, maxSpeed: 7.0, maxForce: 0.30, perception: 50, sepDistance: 16,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 2.0, alignment: 1.6, cohesion: 1.3, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: false, traceFade: 0.04, boidSize: 2.5,
},
},
slime: {
label: 'Slime Mold',
desc: 'Physarum — slow veins weaving an organic network',
how: 'physarum model: slow crawlers, tiny perception, near-zero fade traces weave a pseudopodial vein network.',
vals: {
count: 450, maxSpeed: 0.8, maxForce: 0.05, perception: 24, sepDistance: 8,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 0.5, alignment: 0.3, cohesion: 0.25, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'wrap', traces: true, traceFade: 0.008, boidSize: 2,
},
},
cells: {
label: 'Cell Colony',
desc: 'a wobbling micro-colony drifting like under a microscope',
how: 'cohesion 2.4 vs separation 2.2 with heavy turn damping: a coherent cell mass that slowly deforms — no beacon, no traces.',
vals: {
count: 260, maxSpeed: 1.1, maxForce: 0.15, perception: 45, sepDistance: 20,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 2.2, alignment: 0.6, cohesion: 2.4, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'wrap', traces: false, traceFade: 0.04, boidSize: 5,
},
},
sheep: {
label: 'Sheep & Dog',
desc: 'grazing herd scattering from the sheepdog',
how: 'the predator hunts the nearest sheep; fear force vs cohesion squeezes the herd into fleeing clumps.',
vals: {
count: 150, maxSpeed: 2.2, maxForce: 0.10, perception: 70, sepDistance: 20,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: true },
weights: { separation: 1.6, alignment: 1.2, cohesion: 1.6, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: false, traceFade: 0.04, boidSize: 5,
},
},
hawk: {
label: 'Hawk Attack',
desc: 'flock wheeling away from a hunting raptor',
how: 'fast raptor vs fast flock: fear ripples cascade through alignment — the wheeling escapes of a starling mob.',
vals: {
count: 350, maxSpeed: 5.5, maxForce: 0.18, perception: 80, sepDistance: 18,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: true },
weights: { separation: 2.0, alignment: 1.8, cohesion: 1.4, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: false, traceFade: 0.04, boidSize: 4,
},
},
baitball: {
label: 'Bait Ball',
desc: 'sardines crowding into a swirling sphere under attack',
how: 'predator pressure + cohesion 2.2 compresses sardines into a swirling sphere around the attacker.',
vals: {
count: 500, maxSpeed: 4.5, maxForce: 0.20, perception: 60, sepDistance: 14,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: true },
weights: { separation: 2.4, alignment: 1.5, cohesion: 2.2, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: false, traceFade: 0.04, boidSize: 3,
},
},
mycelium: {
label: 'Mycelium',
desc: 'fungal hyphae slowly branching into a web',
how: 'ultra-slow tip growth, tiny perception, near-permanent traces: exploratory runs harden into a fungal network.',
vals: {
count: 320, maxSpeed: 0.55, maxForce: 0.06, perception: 20, sepDistance: 7,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 0.9, alignment: 0.2, cohesion: 0.3, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'wrap', traces: true, traceFade: 0.006, boidSize: 2,
},
},
biofilm: {
label: 'Bacterial Biofilm',
desc: 'a near-static crust creeping outward from its rim',
how: 'slow seeders: separation pushes the rim outward while cohesion holds the crust — traces record the growth rings.',
vals: {
count: 280, maxSpeed: 0.4, maxForce: 0.05, perception: 26, sepDistance: 9,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: false, predator: false },
weights: { separation: 1.6, alignment: 0.2, cohesion: 1.4, vFormation: 2.0, beacon: 1.5, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: true, traceFade: 0.02, boidSize: 2.5,
},
},
sunflower: {
label: 'Sunflower Head',
desc: 'seeds packing into a phyllotaxis disc',
how: 'beacon pulls to the centre while separation 2.6 enforces radial spacing — the swarm settles into a packed seed spiral.',
vals: {
count: 420, maxSpeed: 1.4, maxForce: 0.25, perception: 22, sepDistance: 9,
enabled: { separation: true, alignment: true, cohesion: true, vFormation: false, beacon: true, predator: false },
weights: { separation: 2.6, alignment: 0.2, cohesion: 0.4, vFormation: 2.0, beacon: 1.2, predator: 1.5 },
vGap: 24, edgeMode: 'bounce', traces: false, traceFade: 0.04, boidSize: 3.5,
},
},
};
/* preset description panel */
const descBox = $('preset-desc');
function showPresetInfo(key) {
const p = PRESETS[key];
descBox.textContent = '';
const title = document.createElement('p');
title.className = 'desc-title';
title.textContent = p.label;
const d = document.createElement('p');
d.textContent = p.desc;
const h = document.createElement('p');
h.className = 'desc-how';
h.textContent = p.how;
descBox.append(title, d, h);
}
function applyPreset(key) {
const p = PRESETS[key];
Object.assign(P, p.vals);
P.enabled = { ...p.vals.enabled };
P.weights = { ...p.vals.weights };
setCount(P.count);
scatter();
syncUI();
showPresetInfo(key);
}
/* build preset buttons */
const presetBox = $('preset-buttons');
for (const [key, p] of Object.entries(PRESETS)) {
const btn = document.createElement('button');
btn.textContent = p.label;
btn.title = p.desc;
btn.addEventListener('click', () => applyPreset(key));
presetBox.appendChild(btn);
}
function randomize() {
P.count = randInt(60, 420);
P.maxSpeed = +rand(1.5, 6).toFixed(2);
P.maxForce = +rand(0.03, 0.3).toFixed(3);
P.perception = randInt(30, 110);
P.sepDistance = randInt(10, 40);
P.enabled.separation = Math.random() < 0.92;
P.enabled.alignment = Math.random() < 0.88;
P.enabled.cohesion = Math.random() < 0.88;
P.enabled.vFormation = Math.random() < 0.15;
P.enabled.beacon = Math.random() < 0.2;
P.enabled.predator = Math.random() < 0.08;
P.weights.separation = +rand(0.2, 3.5).toFixed(2);
P.weights.alignment = +rand(0.1, 3).toFixed(2);
P.weights.cohesion = +rand(0.1, 3).toFixed(2);
P.weights.vFormation = +rand(1, 3).toFixed(2);
P.weights.beacon = +rand(0.5, 3).toFixed(2);
P.weights.predator = 1.5;
P.vGap = randInt(14, 34);
P.edgeMode = Math.random() < 0.5 ? 'wrap' : 'bounce';
P.traces = Math.random() < 0.3;
P.traceFade = +rand(0.015, 0.08).toFixed(3);
P.boidSize = +rand(3, 7).toFixed(1);
setCount(P.count);
scatter();
syncUI();
}
/* ---------- start ---------- */
resize();
applyPreset('basic');
loop();
/* ============ SWARM — minimal all-white UI ============ */
:root {
--line: #000;
--line-soft: #e4e4e4;
--muted: rgba(0, 0, 0, 0.45);
--mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
display: flex;
background: #fff;
color: #000;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 10px;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ---------- panel ---------- */
#panel {
width: 240px;
min-width: 240px;
height: 100vh;
border-right: 1px solid var(--line);
overflow-y: auto;
scrollbar-width: thin;
}
#panel::-webkit-scrollbar { width: 4px; }
#panel::-webkit-scrollbar-thumb { background: #ccc; }
header {
padding: 14px 14px 12px;
border-bottom: 1px solid var(--line);
}
h1 { font-size: 13px; font-weight: 700; letter-spacing: 4px; }
.sub {
margin-top: 3px;
font-size: 8px;
letter-spacing: 2.5px;
text-transform: uppercase;
color: var(--muted);
}
section {
padding: 10px 14px 12px;
border-bottom: 1px solid var(--line-soft);
}
h2 {
font-size: 9px;
font-weight: 700;
letter-spacing: 2.5px;
text-transform: uppercase;
margin-bottom: 8px;
}
.name {
font-size: 8px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--muted);
margin: 8px 0 5px;
}
.hint {
margin-top: 8px;
font-size: 8px;
letter-spacing: 0.5px;
line-height: 1.7;
text-transform: uppercase;
color: var(--muted);
}
/* ---------- buttons ---------- */
button {
font: inherit;
background: #fff;
color: #000;
border: 1px solid var(--line);
border-radius: 0;
padding: 4px 6px;
font-size: 8px;
font-weight: 600;
letter-spacing: 1.5px;
text-transform: uppercase;
cursor: pointer;
transition: background 0.08s, color 0.08s;
}
button:hover { background: #000; color: #fff; }
button.active { background: #000; color: #fff; }
.btn-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.btn-grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
.btn-grid > button:last-child:nth-child(odd) { grid-column: 1 / -1; }
.seg { display: grid; gap: 5px; }
.seg.cols-2 { grid-template-columns: 1fr 1fr; }
.seg.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
/* ---------- sliders ---------- */
.row {
display: grid;
grid-template-columns: 1fr 40px;
gap: 2px 8px;
align-items: center;
margin-bottom: 6px;
}
.row .name { margin: 0; }
.row .val {
font-family: var(--mono);
font-size: 9px;
text-align: right;
color: var(--muted);
}
.row input[type="range"] { grid-column: 1 / -1; }
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 12px;
background: transparent;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track { height: 1px; background: #000; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 7px; height: 7px;
margin-top: -3px;
background: #000;
border-radius: 0;
}
input[type="range"]::-moz-range-track { height: 1px; background: #000; }
input[type="range"]::-moz-range-thumb {
width: 7px; height: 7px;
background: #000;
border: none;
border-radius: 0;
}
/* ---------- behaviour rows ---------- */
.beh-row { margin-bottom: 9px; }
.beh-row:last-of-type { margin-bottom: 0; }
.chk {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 9px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 3px;
user-select: none;
}
.chk input {
appearance: none;
-webkit-appearance: none;
width: 9px;
height: 9px;
border: 1px solid var(--line);
border-radius: 0;
cursor: pointer;
flex-shrink: 0;
}
.chk input:checked { background: #000; box-shadow: inset 0 0 0 2px #fff; }
.beh-row.off .beh-slider { opacity: 0.22; pointer-events: none; }
.beh-slider { display: grid; grid-template-columns: 1fr 40px; gap: 8px; align-items: center; }
.beh-slider .val {
font-family: var(--mono);
font-size: 9px;
text-align: right;
color: var(--muted);
}
.beh-row.off .val { opacity: 0.22; }
/* ---------- preset description ---------- */
#preset-desc {
margin-top: 9px;
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
#preset-desc p {
font-size: 8px;
letter-spacing: 0.5px;
line-height: 1.7;
text-transform: uppercase;
color: var(--muted);
}
#preset-desc .desc-title {
color: #000;
font-weight: 700;
letter-spacing: 1.5px;
margin-bottom: 3px;
}
#preset-desc .desc-how { margin-top: 5px; }
/* ---------- footer ---------- */
#stats {
padding: 10px 14px;
font-family: var(--mono);
font-size: 9px;
letter-spacing: 1px;
color: var(--muted);
}
/* ---------- stage ---------- */
#stage { flex: 1; position: relative; background: #fff; }
#canvas { display: block; }