<footer><div id=version></div></footer>
html, body {
  height: 100%;
}

body {
  margin: 0;
  padding: 0;
  background: #111 url(https://labs.phaser.io/assets/sprites/phaser3-logo-small.png) no-repeat 0 20px;
  color: #eee;
  font: caption;
}

#version {
  position: absolute;
  left: 0;
  top: 0;
  padding: 0;
  background: rgba(0, 0, 0, 0.5)
}
/* global colors, Phaser, Tweakpane */

const TIMESCALE = 1;
const ZOOM = 1;
const PHYSICS_STEPS_PER_SEC = 60;
const DEBUG = false;
const DEBUG_PHYSICS = false;

const options = {
  alignWeight: 0.8,
  cohereWeight: 0.4,
  containRadius: 512,
  containTime: 1,
  containWeight: 0.8,
  maxForce: 120,
  maxSpeed: 60,
  nearRange: 60,
  quantity: 100,
  separateRange: 15,
  separateWeight: 0.2,
  wanderRadius: 0.25,
  wanderSpeed: 360,
  wanderStrength: 0.5,
  wanderWeight: 1
};

const debugOptions = {
  acceleration: false,
  align: true,
  cohere: true,
  contain: true,
  nearRange: false,
  separate: true,
  separateRange: false,
  velocity: false,
  wander: true,
  wanderRadius: true
};

const { hexColors } = colors;

const RandomAngle = Phaser.Math.Angle.Random;
const FloatBetween = Phaser.Math.FloatBetween;
const RotateTo = Phaser.Math.RotateTo;
const DegToRad = Phaser.Math.DegToRad;
const Remove = Phaser.Utils.Array.Remove;
const Vector2 = Phaser.Math.Vector2;
const Circle = Phaser.Geom.Circle;
const Line = Phaser.Geom.Line;
const ZERO = Phaser.Math.Vector2.ZERO;

const bodyIsEnabled = function (body) {
  return body.enable === true;
};

const bodyIsFish = function (body) {
  return body.gameObject.name === "fish";
};

const getBodyCenter = function (body) {
  return body.center;
};

const getAvg = function (vectors, out) {
  out.reset();

  for (const v of vectors) {
    out.add(v);
  }

  out.scale(1 / vectors.length);

  return out;
};

const projectBody = function (body, time, pOut) {
  const { center, velocity } = body;

  return pOut.set(center.x + time * velocity.x, center.y + time * velocity.y);
};

Phaser.Geom.Line.prototype.setFromPoints = function (a, b) {
  this.setTo(a.x, a.y, b.x, b.y);
};

Phaser.Geom.Line.prototype.setEmpty = function () {
  this.setTo(0, 0, 0, 0);
};

class Fish extends Phaser.GameObjects.Triangle {
  constructor(scene, x, y) {
    super(scene, x, y, 0, 4, 16, 0, 16, 8, hexColors.green, 0.8);

    this.name = "fish";

    // Steering forces
    this.fAlign = new Vector2();
    this.fCohere = new Vector2();
    this.fContain = new Vector2();
    this.fSeparate = new Vector2();
    this.fWander = new Vector2();

    // Positions
    this.pCohere = new Vector2();
    this.pContain = new Vector2();
    this.pWander = new Vector2();

    // Areas
    this.separateCirc = new Circle();
    this.nearCirc = new Circle();
    this.wanderCirc = new Circle();

    // Debug draw
    this.debugWanderCirc = new Circle();

    const { world } = this.scene.physics;

    this.wanderHost = scene.add.zone(0, 0, 1, 1);
    world.enableBody(this.wanderHost);
    this.wanderBody = this.wanderHost.body;

    this.once("destroy", this.onDestroy, this);
  }

  onDestroy() {
    this.wanderHost.destroy();
    this.wanderHost = null;
    this.wanderBody = null;
  }

  align(bodies, fAlign) {
    fAlign.reset();

    if (bodies.length === 0) return fAlign;

    const dBody = new Vector2();
    const vBody = new Vector2();
    const vSum = new Vector2();

    let productSum = 0;

    for (const body of bodies) {
      this.distanceTo(body.center, dBody);

      const product = dBody.dot(this.body.velocity) / dBody.length();

      if (product <= 0) continue;

      productSum += product;

      vBody.copy(body.velocity).scale(product);

      vSum.add(vBody);
    }

    if (vSum.equals(ZERO)) return fAlign;

    vSum.scale(1 / productSum);

    this.steer(vSum, fAlign);

    return fAlign;
  }

  flee(pTarget, fFlee) {
    this.distanceFrom(pTarget, fFlee);

    // Desired velocity.
    fFlee.setLength(options.maxSpeed);

    return this.steer(fFlee, fFlee);
  }

  cohere(bodies, fCohere) {
    fCohere.reset();

    if (bodies.length === 0) return fCohere;

    getAvg(bodies.map(getBodyCenter), this.pCohere);

    return this.seek(this.pCohere, fCohere);
  }

  distanceFrom(pTarget, dFrom) {
    return dFrom.copy(this.body.center).subtract(pTarget);
  }

  distanceTo(pTarget, dTo) {
    return dTo.copy(pTarget).subtract(this.body.center);
  }

  draw(graphic) {
    const {
      acceleration,
      align,
      cohere,
      contain,
      nearRange,
      separate,
      separateRange,
      velocity,
      wander,
      wanderRadius
    } = debugOptions;

    if (align) {
      this.drawSteer(graphic, this.fAlign, hexColors.yellow);
    }

    if (cohere) {
      this.drawSteer(graphic, this.fCohere, hexColors.yellow);
      this.drawPoint(graphic, this.pCohere, hexColors.yellow);
    }

    if (contain) {
      this.drawSteer(graphic, this.fContain, hexColors.red);
      this.drawPoint(graphic, this.pContain, hexColors.red);
    }

    if (separate) {
      this.drawSteer(graphic, this.fSeparate, hexColors.yellow);
    }

    if (nearRange) {
      this.drawCircle(graphic, this.nearCirc, hexColors.gray);
    }

    if (separateRange) {
      this.drawCircle(graphic, this.separateCirc, hexColors.gray);
    }

    if (wander) {
      this.drawSteer(graphic, this.fWander, hexColors.fuchsia);
      this.drawCircle(graphic, this.debugWanderCirc, hexColors.fuchsia);
    }

    if (velocity) {
      this.drawSteer(graphic, this.body.velocity, hexColors.lime);
    }

    if (acceleration) {
      this.drawSteer(graphic, this.body.acceleration, hexColors.red);
    }
  }

  drawCircle(graphic, circle, color) {
    graphic.lineStyle(1, color).strokeCircleShape(circle);
  }

  drawLine(graphic, line, color) {
    graphic.lineStyle(1, color).strokeLineShape(line);
  }

  drawPoint(graphic, point, color) {
    graphic.fillStyle(color).fillPointShape(point, 3);
  }

  drawRect(graphic, rect, color) {
    graphic.lineStyle(1, color).strokeRectShape(rect);
  }

  drawSteer(graphic, fSteer, color) {
    const { x, y } = this.body.center;

    graphic.lineStyle(1, color).lineBetween(x, y, x + fSteer.x, y + fSteer.y);
  }

  drawVector(graphic, vector, x, y, color) {
    graphic.lineStyle(1, color).lineBetween(x, y, x + vector.x, y + vector.y);
  }

  overlap(rect, includeDynamic, includeStatic) {
    const bodies = this.scene.physics.overlapRect(
      rect.x,
      rect.y,
      rect.width,
      rect.height,
      includeDynamic,
      includeStatic
    );

    Remove(bodies, this.body);

    return bodies;
  }

  overlapCirc(circ, includeDynamic, includeStatic) {
    const bodies = this.scene.physics.overlapCirc(
      circ.x,
      circ.y,
      circ.radius,
      includeDynamic,
      includeStatic
    );

    Remove(bodies, this.body);

    return bodies;
  }

  overlapPoint(point, includeDynamic, includeStatic) {
    const bodies = this.scene.physics.overlapRect(
      point.x,
      point.y,
      1,
      1,
      includeDynamic,
      includeStatic
    );

    Remove(bodies, this.body);

    return bodies;
  }

  seek(pTarget, fSeek) {
    // Distance to target.
    this.distanceTo(pTarget, fSeek);

    // Desired velocity.
    fSeek.scale(PHYSICS_STEPS_PER_SEC);

    // Steering force.
    return this.steer(fSeek, fSeek);
  }

  separate(bodies, fSeparate) {
    fSeparate.reset();

    if (bodies.length === 0) return fSeparate;

    // Distance to each body. Recycled.
    const dBody = new Vector2();

    // Separation force to each body. Recycled.
    const fBody = new Vector2();

    for (const body of bodies) {
      this.distanceFrom(body.center, dBody);

      const len = dBody.length();

      if (len === 0) continue;

      fBody.copy(dBody).setLength(1 / len);

      fSeparate.add(fBody);
    }

    // Maximum force.
    fSeparate.setLength(options.maxForce);

    return fSeparate;
  }

  // Calculate a steering force for the desired velocity.
  steer(vDesired, fSteer) {
    return fSteer
      .copy(vDesired)
      .limit(options.maxSpeed)
      .subtract(this.body.velocity)
      .limit(options.maxForce);
  }

  contain(fContain) {
    const { globe } = this.scene;

    fContain.reset();

    projectBody(this.body, options.containTime, this.pContain);

    if (Circle.ContainsPoint(globe, this.pContain)) {
      return fContain;
    }

    this.seek(globe, fContain);

    return fContain;
  }

  wander(fWander) {
    const { x, y } = this.body.center;
    const { maxForce, wanderStrength } = options;
    const wanderRadius = maxForce * options.wanderRadius;

    fWander.reset();

    const distance = wanderStrength * (maxForce - wanderRadius);

    this.pWander.setToPolar(DegToRad(this.wanderBody.rotation), wanderRadius);

    fWander.copy(this.pWander);
    fWander.x += distance;
    fWander.rotate(this.body.angle);
    fWander.limit(maxForce);

    this.debugWanderCirc.radius = wanderRadius;
    RotateTo(this.debugWanderCirc, x, y, this.body.angle, distance);

    return fWander;
  }

  update() {
    const { acceleration, angle, center } = this.body;

    this.rotation = angle;

    this.body.maxSpeed = options.maxSpeed;

    this.wanderBody.maxAngular = options.wanderSpeed;
    this.wanderBody.angularVelocity = FloatBetween(
      -options.wanderSpeed,
      options.wanderSpeed
    );

    this.nearCirc.setTo(center.x, center.y, options.nearRange);

    this.separateCirc.setTo(center.x, center.y, options.separateRange);

    const nearBodies = this.overlapCirc(this.nearCirc).filter(bodyIsFish);

    const separateBodies = this.overlapCirc(this.separateCirc).filter(
      bodyIsFish
    );

    this.fAlign.reset();
    this.fCohere.reset();
    this.fContain.reset();
    this.fSeparate.reset();
    this.fWander.reset();

    this.align(nearBodies, this.fAlign);
    this.cohere(nearBodies, this.fCohere);
    this.contain(this.fContain);
    this.separate(separateBodies, this.fSeparate);
    this.wander(this.fWander);

    this.fAlign.scale(options.alignWeight);
    this.fCohere.scale(options.cohereWeight);
    this.fContain.scale(options.containWeight);
    this.fSeparate.scale(options.separateWeight);
    this.fWander.scale(options.wanderWeight);

    acceleration.reset();
    acceleration.add(this.fAlign);
    acceleration.add(this.fCohere);
    acceleration.add(this.fContain);
    acceleration.add(this.fSeparate);
    acceleration.add(this.fWander);
    acceleration.limit(options.maxForce);
  }
}

class Scene extends Phaser.Scene {
  create() {
    this.cameras.main
      .setBounds(-512, -512, 1024, 1024)
      .setZoom(ZOOM)
      .centerOn(0, 0);

    this.physics.world.setBounds(-512, -512, 1024, 1024);

    this.globe = new Circle(0, 0, options.containRadius);

    this.add
      .image(0, 0, "__WHITE")
      .setDisplaySize(this.scale.width, this.scale.height)
      .setTint(hexColors.blue, hexColors.blue, hexColors.navy, hexColors.black)
      .setVisible(this.renderer.type === Phaser.WEBGL);

    this.fishes = this.physics.add.group({ runChildUpdate: true });

    let q = options.quantity;
    while (q-- > 0) {
      this.createFish(0, 0);
    }

    this.debugGraphic = this.add.graphics().setAlpha(1).setVisible(DEBUG);

    // Calculate the body angles.

    this.physics.world.step(0);

    // Pane

    const pane = new Tweakpane.Pane({ title: "Options" });
    
    pane.expanded = false;

    const fishFolder = pane.addFolder({ title: "🐠 Fish" });
    const steeringFolder = pane.addFolder({ title: "🌊 Steering Forces" });
    const worldFolder = pane.addFolder({ title: "⚓️ World" });

    fishFolder.addInput(options, "containRadius", {
      min: 64,
      max: 512,
      step: 64
    });
    fishFolder.addInput(options, "containTime", { min: 0, max: 3, step: 0.1 });
    fishFolder.addInput(options, "maxForce", { min: 30, max: 300, step: 10 });
    fishFolder.addInput(options, "maxSpeed", { min: 60, max: 300, step: 10 });
    fishFolder.addInput(options, "nearRange", { min: 5, max: 120, step: 5 });
    fishFolder.addInput(options, "quantity", { min: 1, max: 100, step: 1 });
    fishFolder.addInput(options, "separateRange", { min: 5, max: 60, step: 5 });
    fishFolder.addInput(options, "wanderRadius", {
      min: 0,
      max: 0.5,
      step: 0.05
    });
    fishFolder.addInput(options, "wanderSpeed", { min: 0, max: 360, step: 5 });
    fishFolder.addInput(options, "wanderStrength", {
      min: 0,
      max: 1,
      step: 0.1
    });

    steeringFolder.addInput(options, "alignWeight", {
      min: 0,
      max: 1,
      step: 0.1
    });
    steeringFolder.addInput(options, "cohereWeight", {
      min: 0,
      max: 1,
      step: 0.1
    });
    steeringFolder.addInput(options, "containWeight", {
      min: 0,
      max: 1,
      step: 0.1
    });
    steeringFolder.addInput(options, "separateWeight", {
      min: 0,
      max: 1,
      step: 0.1
    });
    steeringFolder.addInput(options, "wanderWeight", {
      min: 0,
      max: 1,
      step: 0.1
    });

    worldFolder.addInput(this.physics.world, "timeScale", {
      min: 0.1,
      max: 10,
      step: 0.1
    });
    worldFolder.addInput(this.cameras.main, "zoom", {
      min: 0.25,
      max: 5,
      step: 0.25
    });

    const debugFolder = pane.addFolder({ title: "🔮 Debug" });

    debugFolder.addInput(this.debugGraphic, "visible", { label: "show debug" });
    debugFolder.addSeparator();

    for (const key in debugOptions) {
      debugFolder.addInput(debugOptions, key);
    }
    
    pane.addButton({ title: "Follow" }).on("click", () => {
      this.followFirstFish();
    });

    pane.addButton({ title: "Unfollow" }).on("click", () => {
      this.unfollow();
      this.cameras.main.centerOn(0, 0);
    });

    pane.addButton({ title: "Restart" }).on("click", () => {
      this.scene.restart();
    });

    pane.containerElem_.style.width = "320px";

    this.events.once("shutdown", () => {
      pane.dispose();
    });
  }

  update() {
    this.physics.world.wrap(this.fishes);

    this.globe.radius = options.containRadius;

    this.debugGraphic.clear();

    this.debugGraphic
      .lineStyle(1, hexColors.aqua)
      .strokeCircleShape(this.globe);

    if (this.debugGraphic.visible) {
      for (const fish of this.fishes.getChildren()) {
        if (fish.active) {
          fish.draw(this.debugGraphic);
        }
      }
    }
  }

  createFish(x, y) {
    const fish = new Fish(this, x, y);

    this.fishes.add(fish, true);

    fish.body.velocity.setToPolar(RandomAngle(), 1);

    return fish;
  }

  followFirstFish() {
    this.cameras.main.startFollow(this.fishes.getFirstAlive(), false);
  }

  unfollow() {
    this.cameras.main.stopFollow();
  }

  restart() {
    this.scene.restart();
  }
}

new Phaser.Game({
  backgroundColor: hexColors.navy,
  scene: Scene,
  audio: {
    disableAudio: true
  },
  input: {
    mouse: false,
    touch: false
  },
  physics: {
    default: "arcade",
    arcade: {
      debug: DEBUG_PHYSICS,
      fps: PHYSICS_STEPS_PER_SEC,
      timeScale: TIMESCALE,
      useTree: true
    }
  },
  scale: {
    width: 1024,
    height: 1024,
    // mode: Phaser.Scale.FIT,
    // max: { width: 1024, height: 1024 },
    // min: { width: 512, height: 512 },
  }
});

document.getElementById("version").textContent = `Phaser v${Phaser.VERSION}`;
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser-arcade-physics.min.js
  2. https://cdn.jsdelivr.net/npm/@samme/colors@1.2.0
  3. https://cdn.jsdelivr.net/npm/tweakpane@3.0.8