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.
| 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 |
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.tsExpected: 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.tsExpected: 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"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.tsExpected: 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.tsExpected: 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"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"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"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"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 devNavigate 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"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"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"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 testExpected: 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"