Difficulty: Expert Version: V41
This project introduces steering behaviors - the AI technique that makes game characters feel alive. The Koi fish hunts minnows, which flee and school together.
| Concept | What It Means |
|---|---|
| Steering Behaviors | Forces that guide movement: seek, flee, arrive, wander. |
| Velocity/Acceleration | Objects have momentum - they don't just teleport. |
| Flocking | Separation + Alignment + Cohesion = realistic group movement. |
| Target Selection | Scoring system decides which minnow to chase. |
| State Machines | Different behaviors for different situations (hunting vs rushing). |
This is a screensaver - it runs autonomously. Just watch the Koi hunt! This demonstrates that interactive input is optional; complex behavior can emerge from simple rules.
This project uses a tiny font for the overlay:
#include <Fonts/TomThumb.h>This is a substantial sketch. Study the comments - each section handles one behavior.
/* * KoiScreenSaver.ino * V41: Slippery Walls (Tangential Projection) * * A Koi fish hunts 50 schooling minnows. * Demonstrates: Steering behaviors, flocking, hunting AI. */ #include <Adafruit_GFX.h> #include <Adafruit_SH110X.h> #include <Fonts/TomThumb.h> #include <Wire.h> Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire, -1); #define NUM_SEGMENTS 4 #define SEGMENT_SPACING 6 #define NUM_MINNOWS 50 struct Vector2 { float x, y; }; struct Minnow { float x, y; float dx, dy; bool active; uint8_t blinkTimer; float speedMult; int age; }; // Koi body segments Vector2 segments[NUM_SEGMENTS]; float segmentRadii[NUM_SEGMENTS] = {5.5, 5.0, 3.5, 2.0}; // Koi physics float koiVx = 0, koiVy = 0; float KOI_MAX_FORCE = 0.08; float koiTargetDist = 100.0; uint8_t koiSpeedBoostTimer = 0; Minnow minnows[NUM_MINNOWS]; float timeStep = 0.0; float BASE_SPEED = 1.5; unsigned long lastSmileTime = 0; bool isSmiling = false; uint8_t koiEatTimer = 0; // Edge preference for hunting strategy int topEdgeCount = 0, botEdgeCount = 0; bool preferTop = true; void setup() { Serial.begin(115200); Wire.begin(5, 6); Wire.setClock(400000); display.begin(0x3C, true); display.clearDisplay(); display.display(); // Initialize Koi segments for (int i = 0; i < NUM_SEGMENTS; i++) { segments[i].x = 64 - (i * SEGMENT_SPACING); segments[i].y = 32; } // Initialize minnows as inactive for (int i = 0; i < NUM_MINNOWS; i++) { minnows[i].active = false; } } // Body segment follows its leader void applyPhysicsAndFollow(int i, float targetX, float targetY) { Vector2 ¤t = segments[i]; float dx = targetX - current.x; float dy = targetY - current.y; float angle = atan2(dy, dx); current.x = targetX - cos(angle) * SEGMENT_SPACING; current.y = targetY - sin(angle) * SEGMENT_SPACING; } void updatePhysics() { timeStep += 0.08; // Koi smile animation if (millis() - lastSmileTime > 500) { isSmiling = !isSmiling; lastSmileTime = millis(); } if (koiEatTimer > 0) koiEatTimer--; if (koiSpeedBoostTimer > 0) koiSpeedBoostTimer--; // Update edge preference preferTop = (topEdgeCount >= botEdgeCount); int nextTopCount = 0, nextBotCount = 0; // Find best target minnow float minScore = 100000; int targetIndex = -1; for (int i = 0; i < NUM_MINNOWS; i++) { if (minnows[i].active) { minnows[i].age++; // Count minnows near edges if (minnows[i].y < 20) nextTopCount++; if (minnows[i].y > 44) nextBotCount++; // Distance to Koi head float dToHead = sqrt(pow(segments[0].x - minnows[i].x, 2) + pow(segments[0].y - minnows[i].y, 2)); // Scoring: prefer closer + edge preference float score = dToHead + (preferTop ? minnows[i].y * 2.5 : (64.0 - minnows[i].y) * 2.5); if (minnows[i].x > 120) score += 500.0; // Avoid chasing off-screen // Can target if old enough or very close bool canTarget = (minnows[i].age > 180) || (dToHead < 15); if (canTarget && minnows[i].x > 0 && minnows[i].x < 200) { if (score < minScore) { minScore = score; targetIndex = i; } } // Minnow movement float spd = BASE_SPEED * minnows[i].speedMult; if (dToHead < 25.0) spd *= 2.5; // PANIC! // Base drift left float targetDx = -0.5, targetDy = 0.0; // Flee from Koi if (dToHead < 50) { float angle = atan2(minnows[i].y - segments[0].y, minnows[i].x - segments[0].x); targetDx += cos(angle) * 2.5; targetDy += sin(angle) * 2.5; } // FLOCKING: Alignment, Cohesion, Separation float avgDx = 0, avgDy = 0, centerMx = 0, centerMy = 0; float sepDx = 0, sepDy = 0; int neighborCount = 0; for (int j = 0; j < NUM_MINNOWS; j++) { if (i != j && minnows[j].active) { float d = sqrt(pow(minnows[j].x - minnows[i].x, 2) + pow(minnows[j].y - minnows[i].y, 2)); if (d < 25) { // Neighbor range avgDx += minnows[j].dx; avgDy += minnows[j].dy; centerMx += minnows[j].x; centerMy += minnows[j].y; neighborCount++; } if (d < 8.0 && d > 0) { // Separation range float push = (8.0 - d) / 8.0; float angleToN = atan2(minnows[i].y - minnows[j].y, minnows[i].x - minnows[j].x); sepDx += cos(angleToN) * push * 2.5; sepDy += sin(angleToN) * push * 2.5; } } } if (neighborCount > 0) { // Alignment: match neighbor velocity avgDx /= neighborCount; avgDy /= neighborCount; targetDx += avgDx * 1.5; targetDy += avgDy * 1.5; // Cohesion: move toward center of neighbors centerMx /= neighborCount; centerMy /= neighborCount; float cohAngle = atan2(centerMy - minnows[i].y, centerMx - minnows[i].x); targetDx += cos(cohAngle) * 0.8; targetDy += sin(cohAngle) * 0.8; } // Apply separation targetDx += sepDx; targetDy += sepDy; // High inertia steering (smooth turns) minnows[i].dx = minnows[i].dx * 0.95 + targetDx * 0.05; minnows[i].dy = minnows[i].dy * 0.95 + targetDy * 0.05; // Normalize float len = sqrt(minnows[i].dx * minnows[i].dx + minnows[i].dy * minnows[i].dy); if (len > 0) { minnows[i].dx /= len; minnows[i].dy /= len; } // Apply movement minnows[i].x += minnows[i].dx * spd; minnows[i].y += minnows[i].dy * spd; // Blink animation if (minnows[i].blinkTimer > 0) minnows[i].blinkTimer--; else if (random(0, 100) < 2) minnows[i].blinkTimer = 5; // Eaten? if (dToHead < 6.0) { minnows[i].active = false; koiEatTimer = 62; koiSpeedBoostTimer = 120; // 2 seconds rush } // Wall bounces if (minnows[i].y < 2) { minnows[i].y = 2; minnows[i].dy = abs(minnows[i].dy); } if (minnows[i].y > 62) { minnows[i].y = 62; minnows[i].dy = -abs(minnows[i].dy); } if (minnows[i].age > 30 && minnows[i].x > 125) { minnows[i].x = 125; minnows[i].dx = -abs(minnows[i].dx); } if (minnows[i].x < -10) minnows[i].active = false; } else { // Spawn new minnow if (random(0, 1000) < 30) { minnows[i].active = true; minnows[i].age = 0; minnows[i].x = 135; minnows[i].y = random(10, 54); minnows[i].dx = -1.0; minnows[i].dy = 0.0; int r = random(0, 3); minnows[i].speedMult = (r == 0) ? 0.7 : (r == 1) ? 1.0 : 1.3; } } } topEdgeCount = nextTopCount; botEdgeCount = nextBotCount; // KOI STEERING float tx = segments[0].x, ty = segments[0].y; if (targetIndex != -1) { // Predict where minnow will be tx = minnows[targetIndex].x + minnows[targetIndex].dx * 8.0; ty = minnows[targetIndex].y + minnows[targetIndex].dy * 8.0; } else { // Wander tx += 2 * sin(timeStep * 0.5); ty += 2 * cos(timeStep * 0.7); } float currentMaxSpeed = (koiSpeedBoostTimer > 0) ? 3.0 : 1.5; float dx = tx - segments[0].x; float dy = ty - segments[0].y; float d = sqrt(dx*dx + dy*dy); koiTargetDist = d; float steerX = 0, steerY = 0; if (d > 0) { dx /= d; dy /= d; dx *= currentMaxSpeed; dy *= currentMaxSpeed; steerX = dx - koiVx; steerY = dy - koiVy; } // Limit steering force float steerLen = sqrt(steerX*steerX + steerY*steerY); if (steerLen > KOI_MAX_FORCE) { steerX = (steerX / steerLen) * KOI_MAX_FORCE; steerY = (steerY / steerLen) * KOI_MAX_FORCE; } koiVx += steerX; koiVy += steerY; // Speed limit float speed = sqrt(koiVx*koiVx + koiVy*koiVy); if (speed > currentMaxSpeed) { koiVx = (koiVx / speed) * currentMaxSpeed; koiVy = (koiVy / speed) * currentMaxSpeed; } // SLIPPERY WALLS - remove velocity toward wall float margin = 8.0; if (segments[0].x < margin && koiVx < 0) koiVx = 0; if (segments[0].x > 128-margin && koiVx > 0) koiVx = 0; if (segments[0].y < margin && koiVy < 0) koiVy = 0; if (segments[0].y > 64-margin && koiVy > 0) koiVy = 0; segments[0].x += koiVx; segments[0].y += koiVy; // Hard clamp if (segments[0].x < 2) segments[0].x = 2; if (segments[0].x > 126) segments[0].x = 126; if (segments[0].y < 2) segments[0].y = 2; if (segments[0].y > 62) segments[0].y = 62; // Body follows head for (int i = 1; i < NUM_SEGMENTS; i++) applyPhysicsAndFollow(i, segments[i-1].x, segments[i-1].y); } void draw() { display.clearDisplay(); // Draw minnows for (int i = 0; i < NUM_MINNOWS; i++) { if (minnows[i].active) { int mx = (int)minnows[i].x, my = (int)minnows[i].y; display.drawPixel(mx, my, minnows[i].blinkTimer > 0 ? SH110X_BLACK : SH110X_WHITE); display.drawLine(mx, my, mx-(int)(minnows[i].dx*3), my-(int)(minnows[i].dy*3), SH110X_WHITE); // Fins float pdx = -minnows[i].dy, pdy = minnows[i].dx; display.drawLine(mx, my, mx+(int)(pdx*2), my+(int)(pdy*2), SH110X_WHITE); display.drawLine(mx, my, mx-(int)(pdx*2), my-(int)(pdy*2), SH110X_WHITE); } } // Draw Koi body for (int i = 0; i < NUM_SEGMENTS; i++) { int r = (int)segmentRadii[i]; display.fillCircle((int)segments[i].x, (int)segments[i].y, r, SH110X_BLACK); display.drawCircle((int)segments[i].x, (int)segments[i].y, r, SH110X_WHITE); // Smile if (i == 0 && isSmiling) { display.drawPixel((int)segments[i].x+2, (int)segments[i].y+2, SH110X_WHITE); display.drawPixel((int)segments[i].x+3, (int)segments[i].y+3, SH110X_WHITE); display.drawPixel((int)segments[i].x+2, (int)segments[i].y+4, SH110X_WHITE); } // Fins on segment 1 if (i == 1) { float flap = 5 * sin(timeStep * 3.5); display.drawLine((int)segments[i].x, (int)segments[i].y-r, (int)segments[i].x-4, (int)segments[i].y-r-6-(int)flap, SH110X_WHITE); display.drawLine((int)segments[i].x, (int)segments[i].y+r, (int)segments[i].x-4, (int)segments[i].y+r+6+(int)flap, SH110X_WHITE); } } // Tail Vector2 &tail = segments[3]; Vector2 &prev = segments[2]; float tailAngle = atan2(tail.y - prev.y, tail.x - prev.x); // Dramatic tail: lazy normally, thrashing when hunting float wagIntensity = (koiTargetDist < 30.0 || koiSpeedBoostTimer > 0) ? 12.0 : 2.0; float wag = 0.5 * sin(timeStep * wagIntensity); for (float a = -0.5; a <= 0.5; a += 0.15) { display.drawLine((int)tail.x, (int)tail.y, (int)(tail.x + cos(tailAngle+wag+a)*11), (int)(tail.y + sin(tailAngle+wag+a)*11), SH110X_WHITE); } // Eyes int ex = (int)segments[0].x + 1; int ey1 = (int)segments[0].y - 2; int ey2 = (int)segments[0].y + 2; if (koiEatTimer > 0) { display.drawLine(ex-1, ey1, ex+1, ey1, SH110X_WHITE); display.drawLine(ex-1, ey2, ex+1, ey2, SH110X_WHITE); } else { display.drawPixel(ex, ey1, SH110X_WHITE); display.drawPixel(ex, ey2, SH110X_WHITE); } // Runtime display display.setFont(&TomThumb); display.setTextSize(1); display.setTextColor(SH110X_WHITE); unsigned long runMillis = millis(); if (runMillis < 7000) { display.setCursor(0, 6); display.print("Koi v41"); } unsigned long allSeconds = runMillis / 1000; int runMins = (allSeconds / 60) % 60; int runSecs = allSeconds % 60; display.setCursor(0, 63); if (runMins > 0) { display.print(runMins); display.print(":"); if (runSecs < 10) display.print("0"); } display.print(runSecs); display.display(); display.setFont(); } void loop() { updatePhysics(); draw(); delay(16); }
Craig Reynolds discovered in 1986 that realistic flocking emerges from just three simple rules:
| Rule | What It Does | In Code |
|---|---|---|
| Separation | Steer away from neighbors that are too close | If distance < 8, push away |
| Alignment | Match velocity with nearby neighbors | Average neighbor dx/dy |
| Cohesion | Steer toward the center of nearby neighbors | Move toward average position |
Combined with flee-from-predator, these rules create the panicking school behavior!
| Phase | Versions | What Changed |
|---|---|---|
| The Koi | V1-8 | Body segments following head, basic movement |
| The Minnows | V9-15 | Spawning, drifting, flocking behaviors |
| The Hunt | V16-25 | Target selection, speed boost on eat, panic |
| The Feel | V26-35 | Tail animation, smile, eye squint, fins |
| The Polish | V36-41 | Wall sliding, edge hunting strategy, timing |
Build in layers. Get each part working before combining:
| Layer | Gemini Prompt |
|---|---|
| 1. Koi body | "Create a chain of circles that follow each other like a snake" |
| 2. Steering | "Explain steering behaviors and write a 'seek' function with velocity and acceleration" |
| 3. One minnow | "Add a single target dot that the Koi chases" |
| 4. Many minnows | "Create an array of 50 minnow structs with position and velocity" |
| 5. Flocking | "Implement Boids flocking: separation, alignment, and cohesion" |
| 6. Hunting | "Score minnows by distance and edge position to pick the best target" |
value = value * 0.95 + target * 0.05 makes turning smooth.koiSpeedBoostTimer creates temporary "rush" behavior.