Project 5: Koi Pond Screensaver

Difficulty: Expert    Version: V41

V41 - Forty-one iterations! This screensaver went through massive refinement. Each version tweaked one behavior. Look at what 41 versions of "make it feel better" produces. Don't be afraid to iterate!

What's New

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).

No Touch Input!

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.


Install Extra Library

This project uses a tiny font for the overlay:

  1. The TomThumb font is included with Adafruit GFX - no extra install needed!
  2. It's accessed via: #include <Fonts/TomThumb.h>

The Code

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 &current = 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);
}

The Three Rules of Flocking (Boids)

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!


How I Built This (V1 to V41)

Phase Versions What Changed
The KoiV1-8Body segments following head, basic movement
The MinnowsV9-15Spawning, drifting, flocking behaviors
The HuntV16-25Target selection, speed boost on eat, panic
The FeelV26-35Tail animation, smile, eye squint, fins
The PolishV36-41Wall sliding, edge hunting strategy, timing
41 versions of tiny adjustments. That's how you make something feel alive.

Want to Build This Yourself?

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"

Key Concepts


Challenges to Try