7

Swarm Foraging Without Pheromones

tap to drop a food patch

A ~150-agent swarm forages from a central nest with no chemical trail โ€” pure local rules and line-of-sight sensing, in the spirit of Reynolds (1987). Each agent runs a tiny state machine: while *roaming* it does a momentum-biased random walk with heading jitter ; if a food patch enters its sense radius px it locks on and homes in at task speed; on contact it picks up one bite (patches shrink as they're depleted) and switches to *carrying*, which steers it straight back to the nest at the center to deposit. On top of that everyone applies gentle Reynolds alignment and separation with neighbors inside px โ€” enough that the swarm forms loose lanes between nest and food without ever sharing a global signal. The HUD counts total deposits and active patches; when all food is exhausted the world reseeds 2โ€“3 new patches at random positions. Tap anywhere to drop a fresh patch at the cursor and watch the nearest agents discover it and recruit followers via alignment alone. The takeaway is the same one that drives a lot of real swarm robotics work: efficient foraging does not require pheromones or central control, only sensing radius, a two-state policy, and weak coupling between neighbors.

idle
261 lines ยท vanilla
view source
// Pheromone-free swarm foraging. Reynolds-style local rules + simple
// state machine (roam -> seek food -> return to nest -> deposit -> roam).
// No trail field; agents discover food purely by sensing radius + alignment.

const N_AGENTS = 150;
const SENSE_R = 70;          // food detection radius
const NEIGH_R = 28;          // alignment / separation radius
const NEIGH_R2 = NEIGH_R * NEIGH_R;
const SEP_R = 14;
const SEP_R2 = SEP_R * SEP_R;
const CELL = NEIGH_R;

const ROAM_SPEED = 55;
const TASK_SPEED = 95;       // faster when seeking food or returning home
const MAX_TURN = 4.5;        // rad/sec
const ROAM_JITTER = 2.2;     // rad/sec random walk amplitude

const NEST_R = 18;
const DEPOSIT_R = 22;
const PICKUP_R = 7;
const FOOD_MAX = 28;
const FOOD_MIN = 12;
const FOOD_PER_BITE = 1;

let agents = [];
let foods = [];
let nest;
let W, H;
let cols, rows, grid;
let collected = 0;
let frameCount = 0;

function spawnFood(x, y, amount) {
  foods.push({ x, y, amount: amount ?? (FOOD_MIN + Math.random() * (FOOD_MAX - FOOD_MIN)) });
}

function reseedFood() {
  const k = 2 + Math.floor(Math.random() * 2);
  for (let i = 0; i < k; i++) {
    const margin = 60;
    const x = margin + Math.random() * (W - 2 * margin);
    const y = margin + Math.random() * (H - 2 * margin);
    // keep food at least one nest-radius away from the nest
    const dx = x - nest.x, dy = y - nest.y;
    if (dx * dx + dy * dy < 120 * 120) {
      i--;
      continue;
    }
    spawnFood(x, y);
  }
}

function init({ canvas, ctx, width, height }) {
  W = width;
  H = height;
  nest = { x: W * 0.5, y: H * 0.5 };

  agents = [];
  for (let i = 0; i < N_AGENTS; i++) {
    const a = Math.random() * Math.PI * 2;
    const r = NEST_R + Math.random() * 40;
    agents.push({
      x: nest.x + Math.cos(a) * r,
      y: nest.y + Math.sin(a) * r,
      heading: Math.random() * Math.PI * 2,
      carrying: false,
      next: null,
    });
  }

  foods = [];
  reseedFood();
  collected = 0;
  frameCount = 0;

  cols = Math.max(1, Math.ceil(W / CELL));
  rows = Math.max(1, Math.ceil(H / CELL));
  grid = new Array(cols * rows);

  ctx.fillStyle = '#0a0d14';
  ctx.fillRect(0, 0, W, H);
}

function rebuildGrid() {
  for (let i = 0; i < grid.length; i++) grid[i] = null;
  for (let i = 0; i < agents.length; i++) {
    const a = agents[i];
    const gx = Math.min(cols - 1, Math.max(0, Math.floor(a.x / CELL)));
    const gy = Math.min(rows - 1, Math.max(0, Math.floor(a.y / CELL)));
    const k = gy * cols + gx;
    a.next = grid[k];
    grid[k] = a;
  }
}

function nearestFood(x, y) {
  let best = -1;
  let bestD2 = SENSE_R * SENSE_R;
  for (let i = 0; i < foods.length; i++) {
    const f = foods[i];
    const dx = f.x - x, dy = f.y - y;
    const d2 = dx * dx + dy * dy;
    if (d2 < bestD2) {
      bestD2 = d2;
      best = i;
    }
  }
  return best;
}

function angleDelta(target, current) {
  let d = target - current;
  while (d > Math.PI) d -= Math.PI * 2;
  while (d < -Math.PI) d += Math.PI * 2;
  return d;
}

function tick({ ctx, dt, width, height, input }) {
  if (dt > 0.05) dt = 0.05;
  frameCount++;

  if (width !== W || height !== H) {
    W = width; H = height;
    nest.x = W * 0.5; nest.y = H * 0.5;
    cols = Math.max(1, Math.ceil(W / CELL));
    rows = Math.max(1, Math.ceil(H / CELL));
    grid = new Array(cols * rows);
  }

  // Click drops a new food patch at the cursor.
  for (const c of input.consumeClicks()) {
    spawnFood(c.x, c.y, FOOD_MAX);
  }

  rebuildGrid();

  // ---- agent update ----
  for (let i = 0; i < agents.length; i++) {
    const a = agents[i];

    let desiredHeading = a.heading;
    let speed = ROAM_SPEED;
    let lockedTarget = false;

    if (a.carrying) {
      // Head straight home.
      desiredHeading = Math.atan2(nest.y - a.y, nest.x - a.x);
      speed = TASK_SPEED;
      lockedTarget = true;

      const dx = nest.x - a.x, dy = nest.y - a.y;
      if (dx * dx + dy * dy < DEPOSIT_R * DEPOSIT_R) {
        a.carrying = false;
        collected++;
        // turn around so we don't immediately re-enter the nest
        a.heading += Math.PI + (Math.random() - 0.5) * 0.6;
        desiredHeading = a.heading;
      }
    } else {
      // Look for food within sense radius.
      const fi = nearestFood(a.x, a.y);
      if (fi >= 0) {
        const f = foods[fi];
        desiredHeading = Math.atan2(f.y - a.y, f.x - a.x);
        speed = TASK_SPEED;
        lockedTarget = true;

        const dx = f.x - a.x, dy = f.y - a.y;
        if (dx * dx + dy * dy < PICKUP_R * PICKUP_R) {
          a.carrying = true;
          f.amount -= FOOD_PER_BITE;
          if (f.amount <= 0) foods.splice(fi, 1);
          a.heading = Math.atan2(nest.y - a.y, nest.x - a.x);
          desiredHeading = a.heading;
        }
      } else {
        // Roam: random-walk heading.
        a.heading += (Math.random() - 0.5) * ROAM_JITTER * dt;
        desiredHeading = a.heading;
      }
    }

    // ---- Reynolds: gentle alignment + separation from neighbors. No cohesion. ----
    const gx = Math.min(cols - 1, Math.max(0, Math.floor(a.x / CELL)));
    const gy = Math.min(rows - 1, Math.max(0, Math.floor(a.y / CELL)));
    let alignVX = 0, alignVY = 0, nAlign = 0;
    let sepX = 0, sepY = 0;
    for (let oy = -1; oy <= 1; oy++) {
      for (let ox = -1; ox <= 1; ox++) {
        const nx = gx + ox, ny = gy + oy;
        if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
        let o = grid[ny * cols + nx];
        while (o) {
          if (o !== a) {
            const dx = o.x - a.x, dy = o.y - a.y;
            const d2 = dx * dx + dy * dy;
            if (d2 < NEIGH_R2) {
              alignVX += Math.cos(o.heading);
              alignVY += Math.sin(o.heading);
              nAlign++;
              if (d2 < SEP_R2 && d2 > 0.0001) {
                sepX -= dx / d2;
                sepY -= dy / d2;
              }
            }
          }
          o = o.next;
        }
      }
    }

    // Blend Reynolds influence into desired heading.
    let bx = Math.cos(desiredHeading);
    let by = Math.sin(desiredHeading);
    const alignW = lockedTarget ? 0.15 : 0.6;
    const sepW = lockedTarget ? 60 : 120;
    if (nAlign > 0) {
      bx += (alignVX / nAlign) * alignW;
      by += (alignVY / nAlign) * alignW;
    }
    bx += sepX * sepW;
    by += sepY * sepW;
    desiredHeading = Math.atan2(by, bx);

    // Clamp turn rate.
    const dH = angleDelta(desiredHeading, a.heading);
    const maxStep = MAX_TURN * dt;
    if (dH > maxStep) a.heading += maxStep;
    else if (dH < -maxStep) a.heading -= maxStep;
    else a.heading = desiredHeading;

    // Move.
    a.x += Math.cos(a.heading) * speed * dt;
    a.y += Math.sin(a.heading) * speed * dt;

    // Soft walls: reflect heading instead of wrapping; the world is bounded.
    if (a.x < 4) { a.x = 4; a.heading = Math.PI - a.heading; }
    else if (a.x > W - 4) { a.x = W - 4; a.heading = Math.PI - a.heading; }
    if (a.y < 4) { a.y = 4; a.heading = -a.heading; }
    else if (a.y > H - 4) { a.y = H - 4; a.heading = -a.heading; }
  }

  // Reseed if everything has been eaten.
  if (foods.length === 0) reseedFood();

  // ---- render ----
  ctx.fillStyle = 'rgba(10,13,20,0.30)';
  ctx.fillRect(0, 0, W, H);

  // Nest: concentric rings + faint glow.
  ctx.save();
  const ring = (NEST_R + Math.sin(frameCount * 0.06) * 2.5);
  const grad = ctx.createRadialGradient(nest.x, nest.y, 0, nest.x, nest.y, ring * 3);
  grad.addColorStop(0, 'rgba(120, 180, 255, 0.35)');
  grad.addColorStop(1, 'rgba(120, 180, 255, 0.0)');
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(nest.x, nest.y, ring * 3, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = 'rgba(180,210,255,0.9)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(nest.x, nest.y, NEST_R, 0, Math.PI * 2);
  ctx.stroke();
  ctx.strokeStyle = 'rgba(180,210,255,0.4)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.arc(nest.x, nest.y, NEST_R - 6, 0, Math.PI * 2);
  ctx.stroke();
  ctx.restore();

  // Food patches: size proportional to remaining amount.
  for (let i = 0; i < foods.length; i++) {
    const f = foods[i];
    const r = 4 + Math.sqrt(Math.max(0, f.amount)) * 2.2;
    const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
    g.addColorStop(0, 'rgba(120,255,160,0.95)');
    g.addColorStop(1, 'rgba(60,160,90,0.0)');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = 'rgba(160,255,180,0.7)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
    ctx.stroke();
  }

  // Agents: small oriented triangles, tinted by state.
  for (let i = 0; i < agents.length; i++) {
    const a = agents[i];
    const cs = Math.cos(a.heading);
    const sn = Math.sin(a.heading);
    if (a.carrying) {
      ctx.fillStyle = '#ffd34d';
    } else {
      ctx.fillStyle = '#9aa3b2';
    }
    ctx.beginPath();
    ctx.moveTo(a.x + cs * 5, a.y + sn * 5);
    ctx.lineTo(a.x + (-cs * 3 - sn * 2.4), a.y + (-sn * 3 + cs * 2.4));
    ctx.lineTo(a.x + (-cs * 3 + sn * 2.4), a.y + (-sn * 3 - cs * 2.4));
    ctx.closePath();
    ctx.fill();
  }

  // HUD
  ctx.fillStyle = 'rgba(0,0,0,0.45)';
  ctx.fillRect(8, 8, 168, 44);
  ctx.fillStyle = '#e8edf5';
  ctx.font = '12px system-ui, sans-serif';
  ctx.textBaseline = 'top';
  ctx.fillText(`collected: ${collected}`, 16, 14);
  ctx.fillText(`active patches: ${foods.length}`, 16, 30);
}

Comments (0)

Log in to comment.