<canvas>
body {
  margin: 0;
  overflow: hidden;
}

canvas {
  display: block;
}
const STG = {
  nbParticles: 100,
  sensorsOffset: 20,
  attractionStrength: 1,
}

const gui = new dat.GUI()
gui.add(STG, "sensorsOffset", 1, 40)
gui.add(STG, "attractionStrength", 0, 4)
gui.add(STG, "nbParticles", 50, 500, 1)

let W, H
const cvs = document.querySelector("canvas")
const ctx = cvs.getContext("2d")
cvs.width = W = window.innerWidth
cvs.height = H = window.innerHeight

// create an offscreen canvas which will be used to draw the subtrate from the particles
const offCvs = document.createElement("canvas")
const offCtx = offCvs.getContext("2d")
offCvs.width = W
offCvs.height = H
let offImgData

// sample the offscreen canvas at given x, y coordinates
const sampleEnv = (x, y) => {
  const idx = ((x|0) + (y|0)*W) * 4
  return offImgData.data[idx]
} 

// initialize the particles
let particles = []
for (let i = 0; i < STG.nbParticles; i++) {
  particles[i] = {
    x: Math.random() * W,
    y: Math.random() * H,
    vx: (Math.random() - 0.5) * .5,
    vy: (Math.random() - 0.5) * .5,
    ax: 0,
    ay: 0
  }
}


const rst = {
  reset: () => {
    offCtx.fillStyle = "black"
    offCtx.fillRect(0, 0, W, H)
    particles = []
    for (let i = 0; i < STG.nbParticles; i++) {
      particles[i] = {
        x: Math.random() * W,
        y: Math.random() * H,
        vx: (Math.random() - 0.5) * .5,
        vy: (Math.random() - 0.5) * .5,
        ax: 0,
        ay: 0
      }
    }
  }
}
gui.add(rst, "reset")


// toroidal topology (coordinates that loop on the edges)
const toroidal = (x, y) => {
  if (x > W) x%= W
  else if (x < 0) x = W + x
  if (y > H) y%= H
  else if (y < 0) y = H + y
  return { x, y }
}


// a map of the angles for faster update loop
const angles = (new Array(8)).fill(0).map((_, i) => (i/8) * Math.PI*2)
const cosAngles = angles.map(a => Math.cos(a))
const sinAngles = angles.map(a => Math.sin(a))


const update = () => {
  // first store the substrate data
  offImgData = offCtx.getImageData(0, 0, W, H)
  
  // particles acceleration is changed by sampling the substrate map
  let ang, Sp, weight, ax, ay
  for (const p of particles) {
    ax = 0
    ay = 0
    // sample the env with the sensors
    for (let i = 0; i < 8; i++) {
      ang = angles[i]
      // the sensor position
      Sp = toroidal(
        p.x + cosAngles[i] * STG.sensorsOffset,
        p.y + sinAngles[i] * STG.sensorsOffset
      )
      // sample the environment
      weight = sampleEnv(Sp.x, Sp.y)
      ax+= cosAngles[i] * weight
      ay+= sinAngles[i] * weight
    }
    p.ax = ax / 8
    p.ay = ay / 8
  }
  
  // move the particles
  for (const p of particles) {
    p.vx+= p.ax * STG.attractionStrength * 0.01
    p.vy+= p.ay * STG.attractionStrength * 0.01
    const { x, y } = toroidal(p.x + p.vx, p.y + p.vy)
    p.x = x
    p.y = y
    p.vx*= 0.98
    p.vy*= 0.98
  }
  
  // apply some decay to the offscreen canvas
  offCtx.globalCompositeOperation = "normal"
  offCtx.globalAlpha = 0.08
  offCtx.fillStyle = `rgba(0, 0, 0, 1)`
  offCtx.fillRect(0, 0, W, H)
  
  // draw to the substrate map (ie the offscreen canvas)
  offCtx.globalAlpha = 0.1
  offCtx.globalCompositeOperation = "lighter"
  for (const p of particles) {
    const grad = offCtx.createRadialGradient(p.x, p.y, 3, p.x, p.y, 50)
    grad.addColorStop(0, "#555555")
    grad.addColorStop(1, "transparent")
    offCtx.beginPath()
    offCtx.arc(p.x, p.y, 50, 0, 2 * Math.PI)
    offCtx.fillStyle = grad
    offCtx.fill()
  }
}


const draw = () => {
  // draw the substrate
  ctx.drawImage(offCvs, 0, 0)
  
  // draw the particles
  ctx.fillStyle = "red"
  for (const p of particles) {
    ctx.beginPath()
    ctx.arc(p.x, p.y, 4, 0, 2*Math.PI)
    ctx.fill()
  }
}


const loop = () => {
  requestAnimationFrame(loop)
  update()
  draw()
}

loop()

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.7/dat.gui.min.js