p5.js Intermediate Article 1

Classes & Objects in Depth

Design patterns for complex sketches — composition, shared state, entity management, and object pooling.

⏱ 25 min read OOP classes composition design patterns entity

Beyond basic classes

The Beginner level introduced classes as “bundles of data and behaviour.” This article goes further: how do you design class hierarchies that don’t become a mess? How do you share state without global variables? How do you handle thousands of objects efficiently?

Composition over inheritance

Inheritance (extends) is tempting but often leads to rigid hierarchies. Composition — giving objects capabilities as properties — is usually more flexible.

// ❌ Deep inheritance — fragile
class MovingFlashingBall extends FlashingBall extends MovingBall extends Ball {}

// ✅ Composition — mix capabilities
class Entity {
  constructor(config) {
    this.pos    = createVector(config.x, config.y);
    this.mover  = config.mover  || null;
    this.drawer = config.drawer || null;
    this.alive  = true;
  }

  update() {
    if (this.mover) this.mover.update(this);
  }

  draw() {
    if (this.drawer) this.drawer.draw(this);
  }
}

class BounceMove {
  constructor(vx, vy) {
    this.vel = createVector(vx, vy);
  }
  update(entity) {
    entity.pos.add(this.vel);
    if (entity.pos.x < 0 || entity.pos.x > width)  this.vel.x *= -1;
    if (entity.pos.y < 0 || entity.pos.y > height) this.vel.y *= -1;
  }
}

class CircleDrawer {
  constructor(r, col) { this.r = r; this.col = col; }
  draw(entity) {
    fill(this.col);
    noStroke();
    circle(entity.pos.x, entity.pos.y, this.r * 2);
  }
}

// Usage
let e = new Entity({
  x:      random(width),
  y:      random(height),
  mover:  new BounceMove(random(-3, 3), random(-3, 3)),
  drawer: new CircleDrawer(15, color(100, 200, 255))
});

p5.Vector — stop doing the maths manually

Stop tracking x, y, vx, vy as separate numbers. Use p5.Vector:

class Particle {
  constructor(x, y) {
    this.pos = createVector(x, y);
    this.vel = createVector(random(-2, 2), random(-2, 2));
    this.acc = createVector(0, 0);
    this.r   = random(6, 16);
    this.life = 1.0;
  }

  applyForce(force) {
    this.acc.add(force);
  }

  update() {
    this.vel.add(this.acc);
    this.vel.limit(4);      // max speed
    this.pos.add(this.vel);
    this.acc.mult(0);       // reset acceleration each frame
    this.life -= 0.01;
  }

  isDead() { return this.life <= 0; }

  draw() {
    fill(200, 120, 255, this.life * 255);
    noStroke();
    circle(this.pos.x, this.pos.y, this.r * 2);
  }
}

// Apply gravity to all particles
let gravity = createVector(0, 0.1);

function draw() {
  background(20);
  for (let p of particles) {
    p.applyForce(gravity);
    p.update();
    p.draw();
  }
  particles = particles.filter(p => !p.isDead());
}

p5.Vector methods: .add(), .sub(), .mult(), .div(), .mag(), .normalize(), .limit(), .dot(), .dist(), .copy(), .lerp().

Object pooling — performance for many objects

Creating and destroying objects constantly puts pressure on the garbage collector. A pool pre-allocates objects and reuses them:

class Pool {
  constructor(Factory, size) {
    this.items    = Array.from({ length: size }, Factory);
    this.inactive = [...this.items];
    this.active   = [];
  }

  acquire(...args) {
    if (this.inactive.length === 0) return null;
    let item = this.inactive.pop();
    item.init(...args);
    this.active.push(item);
    return item;
  }

  update() {
    for (let i = this.active.length - 1; i >= 0; i--) {
      this.active[i].update();
      if (this.active[i].isDead()) {
        this.inactive.push(this.active.splice(i, 1)[0]);
      }
    }
  }

  draw() {
    for (let item of this.active) item.draw();
  }
}

Entity manager

For large sketches, a central manager that handles update/draw/removal:

class EntityManager {
  constructor() {
    this.entities = [];
  }

  add(entity) {
    this.entities.push(entity);
    return entity;
  }

  update() {
    for (let e of this.entities) e.update();
    this.entities = this.entities.filter(e => e.alive);
  }

  draw() {
    for (let e of this.entities) e.draw();
  }

  count() { return this.entities.length; }

  getByType(Type) {
    return this.entities.filter(e => e instanceof Type);
  }
}

let manager = new EntityManager();

function setup() {
  createCanvas(700, 500);
  for (let i = 0; i < 50; i++) {
    manager.add(new Particle(random(width), random(height)));
  }
}

function draw() {
  background(20);
  manager.update();
  manager.draw();
}

Spatial indexing — don’t check every pair

Checking all N² pairs for collisions is slow. A simple grid-based spatial index cuts it to roughly O(N):

class SpatialGrid {
  constructor(cellSize) {
    this.cell = cellSize;
    this.grid = new Map();
  }

  key(x, y) {
    return `${Math.floor(x / this.cell)},${Math.floor(y / this.cell)}`;
  }

  insert(entity) {
    let k = this.key(entity.pos.x, entity.pos.y);
    if (!this.grid.has(k)) this.grid.set(k, []);
    this.grid.get(k).push(entity);
  }

  nearby(entity, range) {
    let results = [];
    let cells   = Math.ceil(range / this.cell);
    let gx      = Math.floor(entity.pos.x / this.cell);
    let gy      = Math.floor(entity.pos.y / this.cell);

    for (let dx = -cells; dx <= cells; dx++) {
      for (let dy = -cells; dy <= cells; dy++) {
        let k = `${gx + dx},${gy + dy}`;
        if (this.grid.has(k)) results.push(...this.grid.get(k));
      }
    }
    return results;
  }

  clear() { this.grid.clear(); }
}

Key takeaways

  • Prefer composition over deep inheritance — give objects capability objects as properties
  • p5.Vector replaces manual x/y/vx/vy arithmetic with clean vector math
  • Object pooling eliminates garbage-collector pressure in high-frequency spawn/destroy cycles
  • An EntityManager centralises update/draw loops and handles removal
  • Spatial grids reduce O(N²) nearest-neighbour checks to near-O(N)