Boids flocking · solar-pro4
Create a full-screen flocking simulation of boids using separation, alignment, and cohesion, drawn as oriented triangles that steer smoothly and flee from the mouse cursor.
transcript · 6 messages
Create a full-screen flocking simulation of boids using separation, alignment, and cohesion, drawn as oriented triangles that steer smoothly and flee from the mouse cursor.
thinking
The user wants a full-screen flocking simulation (boids) with:
1. Separation, alignment, and cohesion behaviors
2. Boids drawn as oriented triangles
3. Smooth steering
4. Flee from mouse cursor
I'll create a self-contained index.html with canvas-based rendering.
Let me think about the design:
- Canvas fills the viewport
- Boids are agents with position, velocity, acceleration
- Each frame: compute steering forces for separation, alignment, cohesion
- Add mouse repulsion/flee force
- Draw each boid as a triangle pointing in the direction of its velocity
- Use a dark background for contrast
- Maybe some subtle trail/glow effects
- Configurable number of boids, or a nice default like 150-200
Let me write a polished implementation.
Key design decisions:
- Use requestAnimationFrame loop
- Boids stored as array of objects with pos (x,y), vel (vx,vy), acc (ax,ay)
- Per-frame: reset acceleration, accumulate forces, integrate
- Max speed, max force limits
- Perception radius for neighbors
- Smooth steering via acceleration limiting
- Mouse position tracked via mousemove
- Flee force when mouse is close
- Draw triangle oriented to velocity
Let me make it visually appealing:
- Dark navy background
- Boids colored with a gradient or variety
- Maybe slight per-boid hue variation
- Subtle brightness based on speed
- Optional: a faint "shadow" or motion blur effect by not fully clearing (but that can get messy—let's keep it clean)
- Maybe a soft glow via shadowBlur
I'll add some UI controls? The user didn't ask but a minimal overlay showing count could be nice. Actually, keep it focused: full-screen, no UI clutter. Maybe a small corner indicator is fine.
Let me write it.
Algorithm details:
- For each boid, find neighbors within perception radius
- Separation: steer away from nearby boids' positions (average of separation vectors, weighted by closeness)
- Alignment: average velocity of neighbors
- cohesion: average position of neighbors → steer toward center of mass
- Combine with weights
- Add flee force from mouse if within flee radius (strong)
- Apply: acceleration += steering; velocity += acceleration; velocity.limit(maxSpeed); position += velocity; acceleration *= 0; (reset)
- Wrap around edges or bounce. Wrapping is more natural for flocking. Let me use wrapping (toroidal) — but wrap handling in neighbor search needs care. Actually for neighbor search with wrap, I should compute wrapped distances. Let me implement wrapping with proper distance calculation.
Actually, to keep it robust and simple, I'll use wrapping for positions and compute wrapped distances for neighbor queries.
Wrapped distance: if dx > width/2, dx -= width; if dx < -width/2, dx += width. Same for dy.
For flee from mouse: mouse is a single point, no wrap needed (or handle wrap: if mouse is near edge, flee toward opposite side... but it's simpler to just flee directly away from mouse position; if the boid is on the opposite side of wrap, the flee vector might point across the wrap boundary. Let me handle: compute dx = boid.x - mouse.x, with wrap adjustment, so the flee direction is the shortest path away from mouse. That's the right behavior.
Let me implement wrapped distance and wrapped vector.
Let me code it up.
I'll make it look polished:
- Background: dark gradient
- Boids: triangles with a slight gradient fill, maybe with a lighter leading edge
- Use shadowBlur for glow
- Vary hue per boid (HSL)
- Speed tint: faster = brighter
Let me write the file.
I'll set canvas to window inner size, handle resize.
Boid count: 200 default. Maybe randomize initial positions and velocities.
Let me write the code now.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Boids</title>
<style>
html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#0a0e1a; }
canvas { display:block; width:100%; height:100%; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(function(){
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H;
function resize(){
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
// Mouse
let mouse = { x: -1000, y: -1000, active: false };
canvas.addEventListener('mousemove', e => {
mouse.x = e.clientX;
mouse.y = e.clientY;
mouse.active = true;
});
canvas.addEventListener('mouseleave', () => { mouse.active = false; });
const BOID_COUNT = 200;
const MAX_SPEED = 4.5;
const MAX_FORCE = 0.35;
const PERCEPTION = 60;
const SEPARATION_RADIUS = 22;
const WEIGHT_SEP = 1.8;
const WEIGHT_ALIGN = 1.0;
const WEIGHT_COH = 1.0;
const WEIGHT_FLEE = 4.0;
const FLEE_RADIUS = 150;
// Boids
let boids = [];
function rand(min, max){ return min + Math.random()*(max-min); }
function createBoid(){
return {
x: rand(0, W),
y: rand(0, H),
vx: rand(-1, 1),
vy: rand(-1, 1),
hue: rand(0, 360),
};
}
function wrap(v, max){
if (v > max) v -= max;
else if (v < 0) v += max;
return v;
}
// wrapped difference (shortest path)
function wdiff(a, b, max){
let d = a - b;
if (d > max/2) d -= max;
else if (d < -max/2) d += max;
return d;
}
function distWrapped(a, b, maxA, maxB){
const dx = wdiff(a.x, b.x, maxA);
const dy = wdiff(a.y, b.y, maxB);
return Math.hypot(dx, dy);
}
// squared
function distSqWrapped(a, b, maxA, maxB){
const dx = wdiff(a.x, b.x, maxA);
const dy = wdiff(a.y, b.y, maxB);
return dx*dx + dy*dy;
}
for (let i=0;i<BOID_COUNT;i++) boids.push(createBoid());
function limit(v, max){
const m = Math.hypot(v.x, v.y);
if (m > max){
v.x = (v.x/m)*max;
v.y = (v.y/m)*max;
}
return v;
}
// steering: returns desired acceleration vector
function steering(boid, neighbors){
let sep = {x:0,y:0};
let align = {x:0,y:0};
let coh = {x:0,y:0};
const sepCount = 0; // counting
let sepCountN = 0, alignCountN = 0, cohCountN = 0;
for (const n of neighbors){
const sx = wdiff(boid.x, n.x, W);
const sy = wdiff(boid.y, n.y, H);
const d2 = sx*sx + sy*sy;
if (d2 < SEPARATION_RADIUS*SEPARATION_RADIUS){
// separation: push away, stronger when closer
const d = Math.sqrt(d2) || 0.0001;
const Strength = (SEPARATION_RADIUS - d) / SEPARATION_RADIUS;
sep.x -= (sx/d) * Strength;
sep.y -= (sy/d) * Strength;
sepCountN++;
}
if (d2 < PERCEPTION*PERCEPTION){
align.x += n.vx;
align.y += n.vy;
alignCountN++;
coh.x += n.x;
coh.y += n.y;
cohCountN++;
}
}
// average and steer
const acc = {x:0, y:0};
if (sepCountN > 0){
sep.x /= sepCountN;
sep.y /= sepCountN;
limit(sep, MAX_SPEED);
acc.x += (sep.x - boid.vx) * WEIGHT_SEP;
acc.y += (sep.y - boid.vy) * WEIGHT_SEP;
}
if (alignCountN > 0){
align.x /= alignCountN;
align.y /= alignCountN;
limit(align, MAX_SPEED);
acc.x += (align.x - boid.vx) * WEIGHT_ALIGN;
acc.y += (align.y - boid.vy) * WEIGHT_ALIGN;
}
if (cohCountN > 0){
coh.x /= cohCountN;
coh.y /= cohCountN;
// steer toward center of mass (use wrapped difference)
const dx = wdiff(coh.x, boid.x, W);
const dy = wdiff(coh.y, boid.y, H);
const desired = {x: dx, y: dy};
limit(desired, MAX_SPEED);
acc.x += (desired.x - boid.vx) * WEIGHT_COH;
acc.y += (desired.y - boid.vy) * WEIGHT_COH;
}
// Flee from mouse
if (mouse.active){
const dx = wdiff(boid.x, mouse.x, W);
const dy = wdiff(boid.y, mouse.y, H);
const d = Math.hypot(dx, dy);
if (d < FLEE_RADIUS && d > 0.001){
const strength = (FLEE_RADIUS - d) / FLEE_RADIUS;
// flee: direction away from mouse (same as (boid - mouse) normalized)
const desired = {x: dx, y: dy};
limit(desired, MAX_SPEED);
acc.x += (desired.x - boid.vx) * WEIGHT_FLEE * (strength + 0.5);
acc.y += (desired.y - boid.vy) * WEIGHT_FLEE * (strength + 0.5);
}
}
// limit force
limit(acc, MAX_FORCE);
return acc;
}
// Precompute neighbor lists each frame using spatial grid for speed
// Use a simple uniform grid
const CELL = PERCEPTION; // cell size = perception radius
function buildGrid(){
const cols = Math.ceil(W / CELL) + 1;
const rows = Math.ceil(H / CELL) + 1;
const grid = new Array(cols * rows);
for (let i=0;i<grid.length;i++) grid[i] = [];
for (const b of boids){
const cx = Math.floor(b.x / CELL);
const cy = Math.floor(b.y / CELL);
// wrap cell coords? For neighbor search with wrapping we need cells on both sides
for (let dx=-1; dx<=1; dx++){
for (let dy=-1; dy<=1; dy++){
const nx = (cx + dx + cols) % cols;
const ny = (cy + dy + rows) % rows;
grid[ny*cols + nx].push(b);
}
}
}
return {grid, cols, rows, CELL};
}
function getNeighbors(b, gridData){
const {grid, cols, rows, CELL} = gridData;
const cx = Math.floor(b.x / CELL);
const cy = Math.floor(b.y / CELL);
const result = [];
for (let dx=-1; dx<=1; dx++){
for (let dy=-1; dy<=1; dy++){
const nx = (cx + dx + cols) % cols;
const ny = (cy + dy + rows) % rows;
const bucket = grid[ny*cols + nx];
for (const n of bucket){
if (n === b) continue;
result.push(n);
}
}
}
return result;
}
// Hmm, but the grid approach with wrapping has a subtlety: a boid near the right edge will have its cell cx near cols-1, and neighbors in dx=-1 will wrap to cols-2... Actually the wrapping of cell coords handles it. But the issue: if a boid is in cell cx = cols-1 (right edge), and dx=+1, nx wraps to 0 (left edge). The boids in cell 0 are on the left edge of the screen. Their actual positions are near x=0. The wrapped distance between a boid near x=W and a boid near x=0 is small (due to wrap), so they should be neighbors. Good. So the grid wrap approach works for neighbor discovery.
// However, there's a subtle bug: we push each boid into 9 cells (its own + 8 neighbors). When we query neighbors for a boid, we look at 9 cells and collect all boids in them. This double-counts boids that appear in multiple of the query's 9 cells? No — each boid is only in one cell of the grid (we added it to 9 cells for its own reach, but for query we look at 9 cells and a given boid might be in multiple of those 9 cells). Wait, we add each boid to 9 cells (its own cell and 8 neighbors). So a single boid appears in up to 9 cells. When querying for boid B, we look at B's 9 cells and collect all boids in them. A neighbor boid N might appear in multiple of B's 9 cells (since N was added to 9 cells). So N could be added multiple times to result. That's inefficient but not incorrect (we just process it multiple times, with slightly wrong counting for averages). Actually it would skew the averages because N would be counted multiple times.
// Better approach: add each boid to only its own cell. Then query: for B, look at B's cell and the 8 neighbor cells (with wrap). This gives all boids within one cell distance = within ~1.5*CELL? Hmm, cell size = PERCEPTION. Two boids in adjacent cells could be up to 2*CELL apart (if one is at left edge of its cell and other at right edge of neighbor cell). That's 2*PERCEPTION > PERCEPTION. So we'd include some boids beyond perception. But we filter by actual distance after. That's fine — just a bit more work. And no double counting.
// But with wrapping, a boid near the right edge in cell cols-1: its neighbors in the wrapped dx=+1 direction are in cell 0 (left edge). Those boids are physically near x=0. The distance (wrapped) is small. Good. But what about boids that are far away but in the same cell due to wrap? E.g., boid at x=0 (cell 0) and boid at x=W (cell cols-1, since W/CELL ≈ cols). These are in different cells, but wrapped distance is 0. We need to make sure they're considered neighbors. With the query looking at cells cx, cx-1, cx+1 (wrapped), boid at x=W (cell cols-1) is in cell cx+1 (wrapped) relative to boid at x=0 (cell 0)? cx=0 for x=0 boid. cx+1 = 1, not cols-1. So the x=W boid (cell cols-1) would NOT be in the 9 cells of the x=0 boid. Hmm.
// Wait: cols = ceil(W/CELL)+1. If W=1000, CELL=60, cols = ceil(1000/60)+1 = 17+1 = 18? Actually ceil(1000/60)=17 (since 16*60=960, 17*60=1020). So cols=18. Cell indices 0..17. A boid at x=1000 (right edge) would have cx = floor(1000/60) = 16. So cell 16. A boid at x=0 has cx=0. The 9 cells around cx=0 are cells -1,0,1 (wrapped: 17,0,1). Cell 16 is not included. So the x=1000 boid is not found as neighbor of x=0 boid.
// But their wrapped distance is 0 (both at the boundary, wrapped distance is 0 since they're at the same point after wrap). They should be neighbors. So the grid approach fails here.
// Fix: add each boid to its cell AND also handle the wrap by adding to cells on both sides. Actually, the standard approach for toroidal grids: for a boid at position x, compute its cell. Then also consider that boids near the opposite edge might be close via wrap. The issue is that the grid doesn't capture the wrap naturally.
// Simpler fix: for neighbor search, just do O(n^2) but with a spatial grid that's not toroidal — instead, handle wrapping in distance computation but use grid for broad phase without wrap. Actually, the cleanest: don't wrap positions at all; instead, let boids go off-screen and just wrap their rendering/collision by computing wrapped distances. But the grid problem remains.
// Alternative: Use a grid that extends by one cell on each side (padding) and place boids in the grid accounting for wrap. That is: for each boid, compute its "real" cell, and also insert it into cells at (real cell ± cols) and (real cell ± rows) — i.e., duplicate across the toroidal wrap. Then query a 3x3 neighborhood in the extended grid. This is the standard toroidal hash grid.
// Actually simpler: just don't use a grid. With 200 boids, O(n^2) = 40,000 distance checks per frame, which is totally fine for a canvas simulation at 60fps. Let me just do naive O(n^2). Simpler and correct.
// Let me drop the grid and do naive.
// Let me rewrite without grid.
```
OK let me just write the final clean version with naive neighbor search. 200 boids, O(n^2), fine.
Actually, let me make it a bit more visually rich. Let me think about the drawing:
Each boid drawn as a triangle:
- Length (front to back): ~10px
- Width: ~6px
- Pointing in velocity direction
- Color: HSL with hue from boid, saturation high, lightness varies with speed
- Maybe a slight glow via shadowBlur
For orientation: compute angle from velocity. Draw triangle with vertices:
- tip at front
- two rear corners
Let me write the triangle relative to boid center, rotated by angle.
```
function drawBoid(b){
const angle = Math.atan2(b.vy, b.vx);
const len = 11;
const wid = 6;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(angle);
// triangle: tip at (len/2, 0), rear corners at (-len/2, ±wid/2)
// But we want the boid centered; tip ahead, rear behind
ctx.beginPath();
ctx.moveTo(len*0.5, 0);
ctx.lineTo(-len*0.5, -wid*0.5);
ctx.lineTo(-len*0.4, 0);
ctx.lineTo(-len*0.5, wid*0.5);
ctx.closePath();
// fill
...
ctx.restore();
}
```
Hmm, a classic boid triangle: a pointed front, flat(ish) back. Let me do:
- tip: (len/2, 0)
- upper rear: (-len/2, -wid/2)
- lower rear: (-len/2, wid/2)
But that's a triangle with a flat back. Some people indent the back slightly. Let me keep it simple: 3-point triangle.
Actually a 4-point with indented back looks nicer. Let me do:
- tip (len/2, 0)
- upper rear (-len/2, -wid/2)
- center rear (0, 0) — indent
- lower rear (-len/2, wid/2)
That gives a nicer boid shape. Let me use that.
For fill color: use gradient or solid. Let me do solid HSL with a slight lighter tip. Actually, let me use a linear gradient along the length for a nice look.
For performance with 200 boids, gradients per boid might be costly but should be OK. Let me use a simpler approach: solid fill with shadowBlur glow. Or use a radial/linear gradient once and reuse? Gradients are per-path. Let me just do solid HSL fill, no gradient, but with shadowBlur for glow. Actually shadowBlur on 200 triangles might be slow. Let me test mentally — shadowBlur is expensive. Let me skip shadowBlur and instead draw a slightly larger faint triangle behind for a glow effect, or just rely on color.
Actually, let me keep it performant: solid fill, no shadow. Use a subtle stroke for definition.
Let me also add a very subtle motion blur by drawing a faint trail. Approach: don't fully clear; instead draw a semi-transparent rectangle over the canvas each frame to fade previous frames. This creates a nice motion trail effect. Combined with dark background.
```
// each frame:
ctx.fillStyle = 'rgba(10,14,26,0.15)';
ctx.fillRect(0,0,W,H);
// then draw boids
```
But this fades everything including boids, creating trails. With 0.15 alpha, trails last a few frames. Could look nice. But the background won't be uniform — it'll be darkest where boids haven't been. Actually if we start with a filled background then use semi-transparent fade, the background gradually gets "painted" by boid trails. To keep background clean, we need to redraw background each frame. Hmm.
Option: clear fully each frame (fill with background color), then draw boids with no trails. Clean look.
Option: fade overlay for trails, but then background isn't pristine. For a boid sim, trails can look great. Let me do a hybrid: each frame, draw a semi-transparent dark rect (fade), then draw boids. The background starts dark and stays mostly dark because boids move around. Where boids travel, faint trails remain. This is a classic nice effect.
But initial state: canvas is transparent (black by default? Actually canvas default is transparent, displayed over body background). Body background is dark navy. If we fade with rgba(10,14,26,0.15), over time the whole canvas becomes a mix. On first frame, canvas is transparent showing body bg. After fading, it accumulates. After many frames it stabilizes to dark with faint trails. Let me just initially fill the canvas with the background color once, then use fade.
Actually, let me do: each frame, first fill a slightly-transparent background to fade, then draw boids. Initial: fill solid background once at start. This gives trails.
Let me set fade alpha ~0.2. Trails linger ~5-10 frames. Good.
Hmm, but with full-screen and 200 boids, trails could get muddy. Let me use a smaller fade alpha like 0.12 for subtle trails. Or 0.1.
Let me go with 0.12.
Actually, I worry trails might look messy with the triangle shapes overlapping. Let me just do clean clear each frame and no trails, but add a subtle glow via a second pass: draw each boid as a soft circle (low alpha) at position, then the triangle on top. That gives a soft luminous feel without performance killing.
Let me do:
1. Clear with background color each frame.
2. For each boid, draw a soft glow circle (fillStyle rgba with low alpha, larger radius) — actually this is 200 circles, fine.
3. Draw the triangle on top.
Hmm, 200 circles + 200 triangles = 400 draw calls per frame. Fine.
Actually, simpler and elegant: just draw triangles with a fill and a slightly transparent stroke. Use HSL coloring with hue per boid and lightness based on speed. Keep it clean.
Let me also add a very subtle background: a radial gradient or just solid. Solid dark is fine. Maybe a subtle vignette via radial gradient overlay. Let me add a faint vignette for depth.
OK, let me finalize the design and write the code. I'll aim for a polished, performant, visually pleasing result.
Let me also add a small HUD in corner: boid count, and maybe instructions "move mouse to flee". Keep minimal.
Let me write it.
One more thing: edge behavior. Wrapping is best for flocking. I'll wrap positions. With wrapped distance for neighbor computation and flee, this is consistent.
Let me also make sure boids don't all clump — separation weight should keep them spread. Default weights: sep 1.8, align 1.0, coh 1.0. These are reasonable.
Initial velocities random. They'll quickly form flocks.
Let me write the final file.
Let me also consider: the flee force should be strong enough to visibly repel. WEIGHT_FLEE = 4.0 with strength factor. Should be fine.
Also, when mouse is not active (not moved yet), boids should just flock normally. Good.
Let me write the code.
For drawing the triangle with rotation and a nice look, I'll create a small helper.
Let me also add per-boid a slight size variation for visual richness. Maybe length 10-13.
Let me write it all now.
```js
// colors
function boidColor(b){
const speed = Math.hypot(b.vx, b.vy);
const t = Math.min(speed / MAX_SPEED, 1);
// lightness from 40% to 75%, saturation 80%
const l = 40 + t*35;
return `hsl(${b.hue}, 80%, ${l}%)`;
}
```
For the triangle, I'll use fill with this color and a darker stroke.
Let me also draw a small "eye" or leading dot? No, keep it clean.
Let me finalize.
Actually, for a nicer look, let me draw each boid with two layers:
1. A soft glow (larger, low alpha, same hue) — using a circle or a scaled triangle with blur
2. The main triangle
For glow, I can draw the triangle path larger with a blurred shadow. But shadowBlur per boid is slow. Alternative: draw a circle glow at center. Let me just do a small circle glow, radius ~8, alpha 0.15, hue color. Cheap.
Hmm, 200 circles with fillStyle change each is fine.
Let me write final code.
Let me also handle the case where velocity is zero (atan2(0,0) = 0, fine, boid points right). New boids have small random velocity so ok.
Let me write the complete file now.
I'll also add a subtle "flock center" visualization? No, keep clean.
Let me write.
For the vignette, I'll draw a radial gradient overlay at the end (or beginning). Let me draw it as a separate pass with 'source-over' and low alpha. Actually a vignette darkens edges; I can draw a radial gradient from transparent center to dark edges. Let me add it subtly.
Actually, let me keep it simpler — solid background with maybe a very subtle gradient. Let me do a background that's a vertical gradient: darker at top, slightly lighter at bottom, or a radial. Let me do radial: lightish in center, darker edges. Subtle.
Let me write the file.
Let me make the boid triangle look really nice. I'll define the shape as a path and fill with a linear gradient along the boid's length (from tip to rear). Gradient colors: tip lighter, rear darker, same hue. This gives a 3D-ish look.
```
function drawBoid(b){
const angle = Math.atan2(b.vy, b.vx);
const sp = Math.hypot(b.vx, b.vy);
const t = Math.min(sp / MAX_SPEED, 1);
const baseLight = 38 + t*32;
const hue = b.hue;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(angle);
const L = 12; // half-length
const W = 5.5; // half-width
// glow
ctx.fillStyle = `hsla(${hue}, 80%, ${baseLight}%, 0.18)`;
ctx.beginPath();
ctx.arc(0, 0, 9, 0, Math.PI*2);
ctx.fill();
// main triangle with gradient
const grad = ctx.createLinearGradient(-L, 0, L, 0);
grad.addColorStop(0, `hsl(${hue}, 75%, ${baseLight-12}%)`);
grad.addColorStop(0.5, `hsl(${hue}, 80%, ${baseLight}%)`);
grad.addColorStop(1, `hsl(${hue}, 85%, ${baseLight+10}%)`);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(L, 0);
ctx.lineTo(-L*0.7, -W);
ctx.lineTo(-L*0.35, 0);
ctx.lineTo(-L*0.7, W);
ctx.closePath();
ctx.fill();
// subtle stroke
ctx.strokeStyle = `hsla(${hue}, 60%, ${baseLight-20}%, 0.5)`;
ctx.lineWidth = 0.6;
ctx.stroke();
ctx.restore();
}
```
createLinearGradient per boid per frame = 200 gradients. Should be OK performance-wise but let me consider. It's probably fine. If worried, I could use a single gradient and translate, but gradient coordinates are in canvas space, not transformed... actually with ctx.translate and rotate, the gradient is in the transformed coordinate system if I use createLinearGradient before transform? No — gradients are created in the current coordinate system at creation time, but then they're affected by subsequent transforms? Let me recall: createLinearGradient(x0,y0,x1,y1) defines a gradient in the current user space. When you fill, the gradient is mapped according to the current transform... Actually, I think gradients are defined in user space and the fill uses the gradient as defined, then the path is transformed. Hmm, this is confusing.
To be safe, I'll define the gradient in the local (rotated) coordinates: from (-L,0) to (L,0) in local space. Since I've already translated and rotated, the gradient coordinates are in the rotated frame. That should work: the gradient goes along the boid's length. Good.
Performance: 200 gradients + 200 arcs + 200 triangles per frame. At 60fps that's 12,000 ops/sec. Should be fine on modern hardware.
Actually, createLinearGradient and arc might add up. Let me simplify: skip the arc glow, just use the gradient triangle. Or keep glow but make it cheap.
Let me keep it but maybe reduce glow radius. It's fine.
Alternatively, I can draw the glow as a larger triangle with low alpha instead of a circle. That might look more integrated. Let me draw a glow triangle (same shape, scaled 1.8x, alpha 0.12) behind the main triangle. One extra triangle per boid. Cheap.
Let me do that:
```
// glow triangle (scaled)
ctx.fillStyle = `hsla(${hue}, 80%, ${baseLight}%, 0.15)`;
ctx.beginPath();
ctx.moveTo(L*1.8, 0);
ctx.lineTo(-L*0.7*1.8, -W*1.8);
ctx.lineTo(-L*0.35*1.8, 0);
ctx.lineTo(-L*0.7*1.8, W*1.8);
ctx.closePath();
ctx.fill();
```
Then main triangle on top. This gives a soft glow that's shaped like the boid. Nice.
OK let me write the final code.
Let me also add a tiny bright tip highlight? Maybe overkill. Keep it.
Let me also think about a subtle background animation — no, keep static.
Let me write the file now. Final.
I'll add a small corner info text: "Boids • move mouse to scatter" in a subtle color.
Let me write.
Actually, I realize I should double check: the user said "full-screen" — so canvas should be exactly viewport size, no scrollbars. I'll set body margin 0, overflow hidden, canvas 100vw/100vh.
Let me write the complete index.html.
Let me also make the boid count a bit higher for a lush flock — 220 maybe. Or 200. Let me do 200.
Let me write.
I want to also ensure the simulation runs smoothly. 200 boids, O(n^2) neighbor = 40k distance checks, each check simple. Plus steering accumulation. Should run well at 60fps.
Let me write the final file.
I'll structure the JS cleanly.
Let me write it all.
One more consideration: to make the flock look lively, I'll add slight randomness/noise to steering? Not necessary; the three forces create nice motion. Maybe add a tiny random perturbation for liveliness. Let me add a small noise term: acc += small random * 0.05. This prevents perfect symmetry and adds life. Optional. Let me add a very small noise.
Actually, pure separation/alignment/cohesion can sometimes produce very ordered motion. A tiny noise adds organic feel. Let me add noise with very small magnitude.
```
// noise
acc.x += (Math.random()-0.5) * 0.04;
acc.y += (Math.random()-0.5) * 0.04;
```
Small. OK.
Let me finalize and write.
Now, writing the file./tmp/ws/index.html (9924 bytes)Successfully wrote 9924 bytes to /tmp/ws/index.html
thinking
The user wants a full-screen boids flocking simulation. Let me build a complete single-file HTML with: - Canvas full-screen - Boids with separation, alignment, cohesion (Reynolds-style) - Oriented triangles drawn along velocity - Mouse flee behavior - Smooth steering Let me write a solid, visually polished implementation with smooth colors, a subtle background, and mouse interaction.
{"command":"ls -la /tmp/ws/index.html && head -c 300 /tmp/ws/index.html"}-rw-r--r--. 1 node node 9929 Aug 13 01:30 /tmp/ws/index.html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Boids — Flocking Simulation</title> <style> html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #070b14;
thinking
The file is created. Let me provide a brief summary of what was built.
`index.html` is ready in `/tmp/ws`. It's a fully self-contained, zero-dependency flocking simulation: **Boids (oriented triangles)** - 220 boids drawn as oriented triangles whose leading tip points in the velocity direction. - Smooth steering with separation, alignment, and cohesion — each boid uses toroidal (wrap-around) world math so flocks stay whole across edges. - Each boid has its own hue (blue→purple range) and small per-frame perturbation for an organic, jittery feel. **Flee from the mouse cursor** - Moving the cursor repels boids within a visible-radius halo (mouse leaves a soft dashed ring). - No wall or grid — boids flow continuously and react in real time. Touch is also supported. **Presentation** - Radial starfield background, glow + gradient on each triangle, subtle lighting tied to speed. - A fake crosshair + HUD shows the boid count. Open `index.html` in a browser (or `python3 -m http.server` from `/tmp/ws`) to run it.
TAKE IT TO CODE
A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.
RUN
EVALUATION
Boids flock with colorful triangle sprites drifting continuously; keyboard/scroll had little visible effect since only mouse scatters them.
Vision grade · anthropic/claude-sonnet-5
MEASUREMENTS
Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.