Skip to content

Instantly share code, notes, and snippets.

@lesleh
Created May 4, 2026 22:15
Show Gist options
  • Select an option

  • Save lesleh/2f7bfcd3eb87766fd7d647a633879eef to your computer and use it in GitHub Desktop.

Select an option

Save lesleh/2f7bfcd3eb87766fd7d647a633879eef to your computer and use it in GitHub Desktop.

Boids Simulation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a Boids flocking simulation playground — 700 agents, spatial grid partitioning for O(n) neighbour lookup, four real-time sliders, dark canvas aesthetic.

Architecture: Simulation logic lives in pure _lib/ modules (spatialGrid.ts, boids.ts) with no React dependencies, making them fully testable. The canvas animation loop runs in a useEffect, reading slider values from a ref each frame so slider changes are instant without causing re-renders. A separate Controls component drives React state which stays in sync with the ref.

Tech Stack: React 19, Next.js 15 App Router, TypeScript, Tailwind CSS, Canvas 2D API, Jest + Happy DOM for tests.


File Map

Action Path Responsibility
Create src/app/playgrounds/boids/_lib/spatialGrid.ts Grid partitioning — O(1) insert, O(1) neighbor query
Create src/app/playgrounds/boids/_lib/spatialGrid.test.ts Unit tests for grid
Create src/app/playgrounds/boids/_lib/boids.ts Boid types, createBoids, updateBoids, DEFAULT_PARAMS
Create src/app/playgrounds/boids/_lib/boids.test.ts Unit tests for boid logic
Create src/app/playgrounds/boids/_components/BoidsCanvas/BoidsCanvas.tsx Canvas + rAF animation loop
Create src/app/playgrounds/boids/_components/BoidsCanvas/index.tsx Barrel export
Create src/app/playgrounds/boids/_components/Controls/Controls.tsx Four sliders
Create src/app/playgrounds/boids/_components/Controls/index.tsx Barrel export
Create src/app/playgrounds/boids/_components/Boids/Boids.tsx Params state + ref, composes canvas + controls
Create src/app/playgrounds/boids/_components/Boids/index.tsx Barrel export
Create src/app/playgrounds/boids/page.tsx Next.js metadata + renders <Boids />
Create src/app/playgrounds/boids/Preview.tsx Animated homepage card (150 boids, fixed params)
Create src/app/playgrounds/boids/opengraph-image.tsx OG image via shared createOgImage
Modify src/app/page.tsx Register boids in the playground grid

Task 1: Spatial Grid

Files:

  • Create: src/app/playgrounds/boids/_lib/spatialGrid.ts

  • Create: src/app/playgrounds/boids/_lib/spatialGrid.test.ts

  • Step 1: Write the failing tests

Create src/app/playgrounds/boids/_lib/spatialGrid.test.ts:

import { SpatialGrid } from "./spatialGrid";

describe("SpatialGrid", () => {
  it("returns an inserted index when queried from the same position", () => {
    const grid = new SpatialGrid(500, 500, 75);
    grid.insert(0, 100, 100);
    expect(grid.query(100, 100)).toContain(0);
  });

  it("returns an index from an adjacent cell", () => {
    const grid = new SpatialGrid(500, 500, 75);
    grid.insert(0, 74, 100); // sits in cell (0, 1)
    expect(grid.query(76, 100)).toContain(0); // query from cell (1, 1)
  });

  it("does not return an index from a non-adjacent cell", () => {
    const grid = new SpatialGrid(500, 500, 75);
    grid.insert(0, 0, 0);
    expect(grid.query(300, 300)).not.toContain(0);
  });

  it("clear removes all entries", () => {
    const grid = new SpatialGrid(500, 500, 75);
    grid.insert(0, 100, 100);
    grid.clear();
    expect(grid.query(100, 100)).not.toContain(0);
  });

  it("returns multiple indices from the same cell", () => {
    const grid = new SpatialGrid(500, 500, 75);
    grid.insert(0, 100, 100);
    grid.insert(1, 110, 110);
    const result = grid.query(100, 100);
    expect(result).toContain(0);
    expect(result).toContain(1);
  });
});
  • Step 2: Run tests — expect failure
pnpm test src/app/playgrounds/boids/_lib/spatialGrid.test.ts

Expected: module not found error.

  • Step 3: Implement spatialGrid.ts

Create src/app/playgrounds/boids/_lib/spatialGrid.ts:

export class SpatialGrid {
  private cells: Map<number, number[]> = new Map();
  private cellSize: number;
  private cols: number;

  constructor(width: number, height: number, cellSize: number) {
    this.cellSize = cellSize;
    this.cols = Math.ceil(width / cellSize);
  }

  private key(cx: number, cy: number): number {
    return cy * this.cols + cx;
  }

  clear(): void {
    this.cells.clear();
  }

  insert(index: number, x: number, y: number): void {
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    const k = this.key(cx, cy);
    const cell = this.cells.get(k);
    if (cell) cell.push(index);
    else this.cells.set(k, [index]);
  }

  query(x: number, y: number): number[] {
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    const result: number[] = [];
    for (let dy = -1; dy <= 1; dy++) {
      for (let dx = -1; dx <= 1; dx++) {
        const cell = this.cells.get(this.key(cx + dx, cy + dy));
        if (cell) result.push(...cell);
      }
    }
    return result;
  }
}
  • Step 4: Run tests — expect pass
pnpm test src/app/playgrounds/boids/_lib/spatialGrid.test.ts

Expected: 5 tests passing.

  • Step 5: Commit
git add src/app/playgrounds/boids/_lib/spatialGrid.ts src/app/playgrounds/boids/_lib/spatialGrid.test.ts
git commit -m "feat(boids): add spatial grid with O(1) neighbour lookup"

Task 2: Boids Simulation Module

Files:

  • Create: src/app/playgrounds/boids/_lib/boids.ts

  • Create: src/app/playgrounds/boids/_lib/boids.test.ts

  • Step 1: Write the failing tests

Create src/app/playgrounds/boids/_lib/boids.test.ts:

import { createBoids, updateBoids, type Boid, type BoidParams } from "./boids";
import { SpatialGrid } from "./spatialGrid";

describe("createBoids", () => {
  it("creates the requested number of boids", () => {
    const boids = createBoids(700, 800, 600);
    expect(boids).toHaveLength(700);
  });

  it("places boids within canvas bounds", () => {
    const boids = createBoids(100, 800, 600);
    for (const b of boids) {
      expect(b.x).toBeGreaterThanOrEqual(0);
      expect(b.x).toBeLessThan(800);
      expect(b.y).toBeGreaterThanOrEqual(0);
      expect(b.y).toBeLessThan(600);
    }
  });
});

describe("updateBoids — toroidal wrapping", () => {
  const noForce: BoidParams = { separation: 0, alignment: 0, cohesion: 0, speed: 10 };

  it("wraps a boid past the right edge", () => {
    const boid: Boid = { x: 798, y: 300, vx: 5, vy: 0 };
    const grid = new SpatialGrid(800, 600, 75);
    updateBoids([boid], grid, noForce, 800, 600);
    // 798 + 5 = 803, wraps to 3
    expect(boid.x).toBeCloseTo(3);
    expect(boid.y).toBeCloseTo(300);
  });

  it("wraps a boid past the left edge", () => {
    const boid: Boid = { x: 2, y: 300, vx: -5, vy: 0 };
    const grid = new SpatialGrid(800, 600, 75);
    updateBoids([boid], grid, noForce, 800, 600);
    // 2 - 5 = -3, + 800 = 797
    expect(boid.x).toBeCloseTo(797);
  });

  it("wraps a boid past the bottom edge", () => {
    const boid: Boid = { x: 300, y: 598, vx: 0, vy: 5 };
    const grid = new SpatialGrid(800, 600, 75);
    updateBoids([boid], grid, noForce, 800, 600);
    // 598 + 5 = 603, wraps to 3
    expect(boid.y).toBeCloseTo(3);
  });
});
  • Step 2: Run tests — expect failure
pnpm test src/app/playgrounds/boids/_lib/boids.test.ts

Expected: module not found error.

  • Step 3: Implement boids.ts

Create src/app/playgrounds/boids/_lib/boids.ts:

import { SpatialGrid } from "./spatialGrid";

export interface Boid {
  x: number;
  y: number;
  vx: number;
  vy: number;
}

export interface BoidParams {
  separation: number;
  alignment: number;
  cohesion: number;
  speed: number;
}

export const DEFAULT_PARAMS: BoidParams = {
  separation: 1.5,
  alignment: 1.0,
  cohesion: 1.0,
  speed: 2.0,
};

export const VISUAL_RANGE = 75;
const SEPARATION_RANGE = 25;

export function createBoids(count: number, width: number, height: number): Boid[] {
  return Array.from({ length: count }, () => ({
    x: Math.random() * width,
    y: Math.random() * height,
    vx: (Math.random() - 0.5) * 2,
    vy: (Math.random() - 0.5) * 2,
  }));
}

export function updateBoids(
  boids: Boid[],
  grid: SpatialGrid,
  params: BoidParams,
  width: number,
  height: number
): void {
  grid.clear();
  for (let i = 0; i < boids.length; i++) {
    grid.insert(i, boids[i].x, boids[i].y);
  }

  for (let i = 0; i < boids.length; i++) {
    const b = boids[i];
    const neighbors = grid.query(b.x, b.y);

    let sepX = 0, sepY = 0;
    let avgVx = 0, avgVy = 0, alignCount = 0;
    let avgX = 0, avgY = 0, cohCount = 0;

    for (const j of neighbors) {
      if (j === i) continue;
      const n = boids[j];
      const dx = b.x - n.x;
      const dy = b.y - n.y;
      const distSq = dx * dx + dy * dy;

      if (distSq < SEPARATION_RANGE * SEPARATION_RANGE && distSq > 0) {
        const dist = Math.sqrt(distSq);
        sepX += (dx / dist) * params.separation * 0.3;
        sepY += (dy / dist) * params.separation * 0.3;
      }

      if (distSq < VISUAL_RANGE * VISUAL_RANGE) {
        avgVx += n.vx;
        avgVy += n.vy;
        alignCount++;
        avgX += n.x;
        avgY += n.y;
        cohCount++;
      }
    }

    b.vx += sepX;
    b.vy += sepY;

    if (alignCount > 0) {
      b.vx += (avgVx / alignCount - b.vx) * params.alignment * 0.05;
      b.vy += (avgVy / alignCount - b.vy) * params.alignment * 0.05;
    }

    if (cohCount > 0) {
      b.vx += (avgX / cohCount - b.x) * params.cohesion * 0.0008;
      b.vy += (avgY / cohCount - b.y) * params.cohesion * 0.0008;
    }

    const speed = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
    const maxSpeed = params.speed;
    const minSpeed = maxSpeed * 0.3;

    if (speed > maxSpeed) {
      b.vx = (b.vx / speed) * maxSpeed;
      b.vy = (b.vy / speed) * maxSpeed;
    } else if (speed > 0 && speed < minSpeed) {
      b.vx = (b.vx / speed) * minSpeed;
      b.vy = (b.vy / speed) * minSpeed;
    }

    b.x = (b.x + b.vx + width) % width;
    b.y = (b.y + b.vy + height) % height;
  }
}
  • Step 4: Run tests — expect pass
pnpm test src/app/playgrounds/boids/_lib/boids.test.ts

Expected: 5 tests passing.

  • Step 5: Commit
git add src/app/playgrounds/boids/_lib/boids.ts src/app/playgrounds/boids/_lib/boids.test.ts
git commit -m "feat(boids): add boid simulation with steering forces and toroidal wrapping"

Task 3: BoidsCanvas Component

Files:

  • Create: src/app/playgrounds/boids/_components/BoidsCanvas/BoidsCanvas.tsx

  • Create: src/app/playgrounds/boids/_components/BoidsCanvas/index.tsx

  • Step 1: Create BoidsCanvas.tsx

Create src/app/playgrounds/boids/_components/BoidsCanvas/BoidsCanvas.tsx:

"use client";

import { useEffect, useRef } from "react";
import { createBoids, updateBoids } from "../../_lib/boids";
import { SpatialGrid } from "../../_lib/spatialGrid";
import type { BoidParams } from "../../_lib/boids";

const BOID_COUNT = 700;
const VISUAL_RANGE = 75;

interface Props {
  paramsRef: React.RefObject<BoidParams>;
}

export function BoidsCanvas({ paramsRef }: Props) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const { width, height } = canvas.getBoundingClientRect();
    canvas.width = width;
    canvas.height = height;

    const boids = createBoids(BOID_COUNT, width, height);
    const grid = new SpatialGrid(width, height, VISUAL_RANGE);
    let animId: number;

    const frame = () => {
      ctx.fillStyle = "#0d0d0d";
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      updateBoids(boids, grid, paramsRef.current, canvas.width, canvas.height);

      ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
      for (const b of boids) {
        ctx.beginPath();
        ctx.arc(b.x, b.y, 2.5, 0, Math.PI * 2);
        ctx.fill();
      }

      animId = requestAnimationFrame(frame);
    };

    animId = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(animId);
  }, [paramsRef]);

  return <canvas ref={canvasRef} className="w-full h-full" />;
}
  • Step 2: Create barrel export

Create src/app/playgrounds/boids/_components/BoidsCanvas/index.tsx:

export { BoidsCanvas } from "./BoidsCanvas";
  • Step 3: Commit
git add src/app/playgrounds/boids/_components/BoidsCanvas/
git commit -m "feat(boids): add BoidsCanvas component with rAF animation loop"

Task 4: Controls Component

Files:

  • Create: src/app/playgrounds/boids/_components/Controls/Controls.tsx

  • Create: src/app/playgrounds/boids/_components/Controls/index.tsx

  • Step 1: Create Controls.tsx

Create src/app/playgrounds/boids/_components/Controls/Controls.tsx:

"use client";

import type { BoidParams } from "../../_lib/boids";

const SLIDERS: { label: string; key: keyof BoidParams; min: number; max: number; step: number }[] = [
  { label: "Separation", key: "separation", min: 0, max: 3, step: 0.05 },
  { label: "Alignment",  key: "alignment",  min: 0, max: 3, step: 0.05 },
  { label: "Cohesion",   key: "cohesion",   min: 0, max: 3, step: 0.05 },
  { label: "Speed",      key: "speed",      min: 0.5, max: 4, step: 0.1 },
];

interface Props {
  params: BoidParams;
  onChange: (key: keyof BoidParams, value: number) => void;
}

export function Controls({ params, onChange }: Props) {
  return (
    <div className="flex flex-wrap gap-6 justify-center px-6 py-4 bg-slate-900 border-t border-slate-800">
      {SLIDERS.map(({ label, key, min, max, step }) => (
        <div key={key} className="flex flex-col gap-1.5 min-w-36">
          <div className="flex justify-between text-xs text-slate-400 font-mono">
            <span>{label}</span>
            <span>{params[key].toFixed(2)}</span>
          </div>
          <input
            type="range"
            min={min}
            max={max}
            step={step}
            value={params[key]}
            onChange={(e) => onChange(key, parseFloat(e.target.value))}
            className="w-full accent-slate-300"
          />
        </div>
      ))}
    </div>
  );
}
  • Step 2: Create barrel export

Create src/app/playgrounds/boids/_components/Controls/index.tsx:

export { Controls } from "./Controls";
  • Step 3: Commit
git add src/app/playgrounds/boids/_components/Controls/
git commit -m "feat(boids): add Controls component with four sliders"

Task 5: Boids Orchestrating Component

Files:

  • Create: src/app/playgrounds/boids/_components/Boids/Boids.tsx

  • Create: src/app/playgrounds/boids/_components/Boids/index.tsx

  • Step 1: Create Boids.tsx

Create src/app/playgrounds/boids/_components/Boids/Boids.tsx:

"use client";

import { useCallback, useRef, useState } from "react";
import { BoidsCanvas } from "../BoidsCanvas";
import { Controls } from "../Controls";
import { DEFAULT_PARAMS } from "../../_lib/boids";
import type { BoidParams } from "../../_lib/boids";

export function Boids() {
  const [params, setParams] = useState<BoidParams>(DEFAULT_PARAMS);
  const paramsRef = useRef<BoidParams>(DEFAULT_PARAMS);

  const handleChange = useCallback((key: keyof BoidParams, value: number) => {
    setParams((prev) => {
      const next = { ...prev, [key]: value };
      paramsRef.current = next;
      return next;
    });
  }, []);

  return (
    <div className="flex flex-col h-screen bg-[#0d0d0d]">
      <div className="flex-1 overflow-hidden">
        <BoidsCanvas paramsRef={paramsRef} />
      </div>
      <Controls params={params} onChange={handleChange} />
    </div>
  );
}
  • Step 2: Create barrel export

Create src/app/playgrounds/boids/_components/Boids/index.tsx:

export { Boids } from "./Boids";
  • Step 3: Commit
git add src/app/playgrounds/boids/_components/Boids/
git commit -m "feat(boids): add Boids component composing canvas and controls"

Task 6: page.tsx

Files:

  • Create: src/app/playgrounds/boids/page.tsx

  • Step 1: Create page.tsx

Create src/app/playgrounds/boids/page.tsx:

import type { Metadata } from "next";
import { Boids } from "./_components/Boids";

export const metadata: Metadata = {
  title: "Boids | Playground",
  description: "700 agents follow three simple rules and emergent murmurations appear.",
  openGraph: {
    title: "Boids",
    description: "700 agents follow three simple rules and emergent murmurations appear.",
  },
};

export default function BoidsPage() {
  return (
    <div className="min-h-full">
      <Boids />
    </div>
  );
}
  • Step 2: Verify dev server renders the page
pnpm dev

Navigate to http://localhost:3000/playgrounds/boids and confirm the canvas fills the screen, the simulation runs, and sliders respond.

  • Step 3: Commit
git add src/app/playgrounds/boids/page.tsx
git commit -m "feat(boids): add page with metadata"

Task 7: Preview.tsx

Files:

  • Create: src/app/playgrounds/boids/Preview.tsx

  • Step 1: Create Preview.tsx

Create src/app/playgrounds/boids/Preview.tsx:

"use client";

import { useEffect, useRef } from "react";
import { useIntersectionObserver } from "../../_hooks";
import { createBoids, updateBoids, DEFAULT_PARAMS } from "./_lib/boids";
import { SpatialGrid } from "./_lib/spatialGrid";

const PREVIEW_COUNT = 150;
const VISUAL_RANGE = 75;

export function BoidsPreview() {
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const isVisible = useIntersectionObserver(containerRef);

  useEffect(() => {
    if (!isVisible) return;

    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const { width, height } = canvas.getBoundingClientRect();
    canvas.width = width;
    canvas.height = height;

    const boids = createBoids(PREVIEW_COUNT, width, height);
    const grid = new SpatialGrid(width, height, VISUAL_RANGE);
    let animId: number;

    const frame = () => {
      ctx.fillStyle = "#0d0d0d";
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      updateBoids(boids, grid, DEFAULT_PARAMS, canvas.width, canvas.height);

      ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
      for (const b of boids) {
        ctx.beginPath();
        ctx.arc(b.x, b.y, 2, 0, Math.PI * 2);
        ctx.fill();
      }

      animId = requestAnimationFrame(frame);
    };

    animId = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(animId);
  }, [isVisible]);

  return (
    <div ref={containerRef} className="w-full h-full bg-[#0d0d0d]">
      <canvas ref={canvasRef} className="w-full h-full" />
    </div>
  );
}
  • Step 2: Commit
git add src/app/playgrounds/boids/Preview.tsx
git commit -m "feat(boids): add animated homepage preview card"

Task 8: OG Image

Files:

  • Create: src/app/playgrounds/boids/opengraph-image.tsx

  • Step 1: Create opengraph-image.tsx

Create src/app/playgrounds/boids/opengraph-image.tsx:

import { createOgImage } from "../_og/createOgImage";

export const runtime = "edge";
export const alt = "Boids";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default createOgImage("Boids", "700 agents follow three simple rules and emergent murmurations appear.");
  • Step 2: Commit
git add src/app/playgrounds/boids/opengraph-image.tsx
git commit -m "feat(boids): add OG image"

Task 9: Register on Homepage

Files:

  • Modify: src/app/page.tsx

  • Step 1: Add the import

In src/app/page.tsx, add this import after the existing preview imports (around line 17):

import { BoidsPreview } from "./playgrounds/boids/Preview";
  • Step 2: Add the playground entry

In src/app/page.tsx, add this entry to the playgrounds array. Place it after the tetris-ai entry:

{
  id: "boids",
  title: "Boids",
  description: "700 agents follow three simple rules and emergent murmurations appear.",
  href: "/playgrounds/boids",
  preview: BoidsPreview,
},
  • Step 3: Verify the homepage

With pnpm dev running, navigate to http://localhost:3000 and confirm:

  • The Boids card appears in the grid with the animated preview

  • Clicking the card navigates to the playground

  • Sliders adjust the simulation in real time

  • Step 4: Run lint and all tests

pnpm lint
pnpm test

Expected: no lint errors, all tests pass.

  • Step 5: Final commit
git add src/app/page.tsx
git commit -m "feat(boids): register boids on homepage"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment