This is another simulation based on one of the "Coding Train" channel's videos Coding Challenge #124: Flocking Simulation. It is based on the original work - Boids by Craig Reynolds. The simulation is built using love2d.
Boids are "flocking creatures" which simulate the behaviour of birds and fishes when flying or swimming together respectively. It is based on the autonomous behaviour of each organism in the group which tries to move based on changes around it.
Each boid tries to stay together with the boids around it, but not too close. It also tries to move in the general direction of the boids around it. These behaviours can be programmed into the autonomous boid based on three simple rules:
- Separation: Move in such a way that one avoids a collision with, or coming too close to a nearby organism/boid.
- Alignment: Move in the same average direction as the nearby boids.
- Cohesion: Move towards the average location of the nearby boids.
The above rules have some parameters which can be tweaked, especially the definition of nearby.
The movement of each boid is a combination of the acceleration computed by evaluating each of the above three rules separately. The values of separation, alignment, and cohesion can be combined in various proportions to make the final acceleration of the individual boid. This leads remarkably different flocking behaviours from the boids.
The following video shows some of the possibilities of the simulation.
Code
The following program implements the flocking simulation in Love2D. The latest version of the program can be found on github at flocking-sim-boids exploration
boid.lua
--- boid.lua - Boid class for the flocking simulation
--
-- date: 23/3/2024
-- author: Abhishek Mishra
local class = require('middleclass')
local nl = require('ne0luv')
local Vector = nl.Vector
--- Boid class
local Boid = class('Boid')
--- constructor of the Boid class with a given position
-- if no position is provided, it is random
--@param x the x position of the boid
--@param y the y position of the boid
function Boid:initialize(panel, x, y)
self.panel = panel
-- position is random if not provided
x = x or math.random(self.panel:getX(), self.panel:getWidth())
y = y or math.random(self.panel:getY(), self.panel:getHeight())
self.position = Vector(x, y)
self.velocity = Vector.random2D()
-- get random value in range 0.5 to 1.5
local m = math.random(20, 40)/10
self.velocity:setMag(m)
self.acceleration = Vector(0, 0)
self.maxForce = 0.2
self.maxSpeed = 5
self.boidWidth = 5
end
--- check if the boid is within the screen
-- and wrap around if it goes out of bounds
function Boid:edges()
if self.position.x + self.boidWidth > self.panel:getWidth() then
self.position.x = self.panel:getX() + self.boidWidth
elseif self.position.x - self.boidWidth < self.panel:getX() then
self.position.x = self.panel:getWidth() - self.boidWidth
end
if self.position.y + self.boidWidth > self.panel:getHeight() then
self.position.y = self.panel:getY() + self.boidWidth
elseif self.position.y - self.boidWidth < self.panel:getY() then
self.position.y = self.panel:getHeight() - self.boidWidth
end
end
--- flock the boid with the other boids
-- @param boids the list of boids
function Boid:flock(boids, alignmentSlider, cohesionSlider, separationSlider)
local alignment = self:align(boids)
local cohesion = self:cohesion(boids)
local separation = self:separation(boids)
alignment = alignment * alignmentSlider.currentValue
cohesion = cohesion * cohesionSlider.currentValue
separation = separation * separationSlider.currentValue
self.acceleration = self.acceleration + separation
self.acceleration = self.acceleration + alignment
self.acceleration = self.acceleration + cohesion
end
--- align the boid with the other boids
-- @param boids the list of boids
-- @return the alignment force
function Boid:align(boids)
local perceptionRadius = 25
local steering = Vector(0, 0)
local total = 0
for _, boid in ipairs(boids) do
local distance = self.position:dist(boid.position)
if boid ~= self and distance > 0 and distance < perceptionRadius then
steering = steering + boid.velocity
total = total + 1
end
end
if total > 0 then
steering = steering / total
steering:setMag(self.maxSpeed)
steering = steering - self.velocity
steering:limit(self.maxForce)
end
return steering
end
--- separation of the boid with the other boids
-- @param boids the list of boids
-- @return the separation force
function Boid:separation(boids)
local perceptionRadius = 24
local steering = Vector(0, 0)
local total = 0
for _, boid in ipairs(boids) do
local distance = self.position:dist(boid.position)
if boid ~= self and distance > 0 and distance < perceptionRadius then
local diff = self.position - boid.position
diff = diff / (distance * distance)
steering = steering + diff
total = total + 1
end
end
if total > 0 then
steering = steering / total
steering:setMag(self.maxSpeed)
steering = steering - self.velocity
steering:limit(self.maxForce)
end
return steering
end
--- cohesion of the boid with the other boids
-- @param boids the list of boids
-- @return the cohesion force
function Boid:cohesion(boids)
local perceptionRadius = 50
local steering = Vector(0, 0)
local total = 0
for _, boid in ipairs(boids) do
local distance = self.position:dist(boid.position)
if boid ~= self and distance > 0 and distance < perceptionRadius then
steering = steering + boid.position
total = total + 1
end
end
if total > 0 then
steering = steering / total
steering = steering - self.position
steering:setMag(self.maxSpeed)
steering = steering - self.velocity
steering:limit(self.maxForce)
end
return steering
end
--- update the boid
function Boid:update()
self.position = self.position + self.velocity
self.velocity = self.velocity + self.acceleration
self.velocity:limit(self.maxSpeed)
self.acceleration = self.acceleration * 0
end
--- show the boid on the screen
function Boid:show()
-- only draw the boid if it is within the panel
local inside = self.panel.rect:contains(self.position.x, self.position.y)
if not inside then
return
end
-- set the color of the boid
love.graphics.setColor(1, 1, 1)
-- draw boid as a circle in the direction of the velocity
love.graphics.push()
love.graphics.translate(self.position.x, self.position.y)
local theta = self.velocity:heading() - math.pi/2
love.graphics.rotate(theta)
--- three lines of the triangle, base width is half of the boid width
local baseWidth = self.boidWidth/2
-- base line
love.graphics.line(-baseWidth, -baseWidth, baseWidth, -baseWidth)
-- right line
love.graphics.line(baseWidth, -baseWidth, 0, self.boidWidth)
-- left line
love.graphics.line(-baseWidth, -baseWidth, 0, self.boidWidth)
love.graphics.pop()
-- draw the boid as a circle
-- love.graphics.circle('fill', self.position.x, self.position.y, self.boidWidth-2)
end
return Boid
main.lua
--- main.lua: Flocking Simulation in LÖVE using boids. Based on a video by
-- Daniel Shiffman on the Coding Train youtube channel.
--
-- date: 23/3/2024
-- author: Abhishek Mishra
local Class = require('middleclass')
local Boid = require('boid')
local nl = require('ne0luv')
local Layout = nl.Layout
local Rect = nl.Rect
local Slider = nl.Slider
local Panel = nl.Panel
local Text = nl.Text
local cw, ch
local top
local boidPanel
local controlPanel
local cpWidth = 150
----- ControlPanel ------
-- A Control Panel Class which extends Layout
local ControlPanel = Class('ControlPanel', Layout)
function ControlPanel:initialize(w, h)
Layout.initialize(self, Rect(0, 0, w, h),
{
layout = 'column',
bgColor = { 0.1, 0.1, 0.5 },
}
)
local itemH = 20
-- sliders
self.alignmentSlider = nil
self.cohesionSlider = nil
self.separationSlider = nil
-- slider labels
local alignmentLabel
local cohesionLabel
local separationLabel
-- fps text display
self.fpsText = nil
----------- Alignment Slider ------------
-- create and add alignment slider label
alignmentLabel = Text(
Rect(0, 0, cpWidth, 20),
{
text = 'Alignment:',
bgColor = { 0.2, 0.2, 0, 1 },
align = 'center'
}
)
self:addChild(alignmentLabel)
-- create and add alignment slider
self.alignmentSlider = Slider(
Rect(0, 0, cpWidth, 20),
{
minValue = 0,
maxValue = 1.5,
currentValue = 0.5,
bgColor = { 0.2, 0.2, 0, 1 }
}
)
self.alignmentSlider:addChangeHandler(function(value)
-- trim value to 2 decimal places
value = math.floor(value * 100) / 100
alignmentLabel:setText('Alignment: ' .. value)
end)
self:addChild(self.alignmentSlider)
-- set initial Alignment value
alignmentLabel:setText('Alignment: ' .. self.alignmentSlider.currentValue)
-- add empty panel
self:separator(20)
----------- Cohesion Slider ------------
-- create and add cohesion slider label
cohesionLabel = Text(
Rect(0, 0, cpWidth, 20),
{
text = 'Cohesion:',
bgColor = { 0.2, 0.2, 0, 1 },
align = 'center'
}
)
self:addChild(cohesionLabel)
self.cohesionSlider = Slider(
Rect(0, 0, cpWidth, 20),
{
minValue = 0,
maxValue = 1,
currentValue = 0.1,
bgColor = { 0.2, 0.2, 0, 1 }
}
)
self.cohesionSlider:addChangeHandler(function(value)
-- trim value to 2 decimal places
value = math.floor(value * 100) / 100
cohesionLabel:setText('Cohesion: ' .. value)
end)
self:addChild(self.cohesionSlider)
-- set initial Cohesion
cohesionLabel:setText('Cohesion: ' .. self.cohesionSlider.currentValue)
-- separator
self:separator(20)
----------- Separation Slider ------------
separationLabel = Text(
Rect(0, 0, cpWidth, 20),
{
text = 'Separation:',
bgColor = { 0.2, 0.2, 0, 1 },
align = 'center'
}
)
self:addChild(separationLabel)
self.separationSlider = Slider(
Rect(0, 0, cpWidth, 20),
{
minValue = 0,
maxValue = 1,
currentValue = 0.4,
bgColor = { 0.2, 0.2, 0, 1 }
}
)
self.separationSlider:addChangeHandler(function(value)
-- trim value to 2 decimal places
value = math.floor(value * 100) / 100
separationLabel:setText('Separation: ' .. value)
end)
self:addChild(self.separationSlider)
-- set initial Separation
separationLabel:setText('Separation: ' .. self.separationSlider.currentValue)
-- separator
-- total height for 3 sliders, 3 labels, and 2 separators, each itemH
-- in height. Add one more itemH for the fps text display.
local totalHeight = itemH * (3 + 3 + 2 + 1);
self:separator(ch - totalHeight)
self.fpsText = Text(
Rect(0, 0, cpWidth, 20),
{
text = 'FPS: 0',
bgColor = { 0.2, 0.2, 0, 1 },
fgColor = { 1, 0, 0, 1},
align = 'center'
}
)
self:addChild(self.fpsText)
end
--- Create and add a separator to the control panel, with the
-- specified height.
-- @param height The height of the separator
function ControlPanel:separator(height)
local emptyPanel = Panel(
Rect(0, 0, cpWidth, height),
{
bgColor = { 0.2, 0.2, 0, 1 }
}
)
self:addChild(emptyPanel)
end
------- Main Program -------
-- random seed
math.randomseed(os.time())
local boids
local function initBoids()
boids = {}
for i = 1, 300 do
local b = Boid(boidPanel)
table.insert(boids, b)
end
end
--- love.load: Called once at the start of the simulation
function love.load()
cw, ch = love.graphics.getWidth(), love.graphics.getHeight()
top = Layout(
Rect(0, 0, cw, ch),
{
bgColor = { 0.1, 0.1, 0.1 },
layout = 'row',
}
)
boidPanel = Layout(
Rect(0, 0, cw - cpWidth, ch),
{
bgColor = { 0.1, 0.5, 0.1 },
}
)
top:addChild(boidPanel)
controlPanel = ControlPanel(cpWidth, ch)
top:addChild(controlPanel)
top:show()
initBoids()
end
--- love.update: Called every frame, updates the simulation
function love.update(dt)
top:update(dt)
for _, boid in ipairs(boids) do
boid:edges()
boid:flock(boids, controlPanel.alignmentSlider, controlPanel.cohesionSlider, controlPanel.separationSlider)
boid:update()
end
end
--- love.draw: Called every frame, draws the simulation
function love.draw()
--update fps
controlPanel.fpsText:setText('FPS: ' .. love.timer.getFPS())
top:draw()
for _, boid in ipairs(boids) do
boid:show()
end
end
-- escape to exit
function love.keypressed(key)
if key == "escape" then
love.event.quit()
end
end
--- love.mousepressed: Called when a mouse button is pressed
function love.mousepressed(x, y, button, istouch, presses)
top:mousepressed(x, y, button, istouch, presses)
end
--- love.mousereleased: Called when a mouse button is released
function love.mousereleased(x, y, button, istouch, presses)
top:mousereleased(x, y, button, istouch, presses)
end
--- love.mousemoved: Called when the mouse is moved
function love.mousemoved(x, y, dx, dy, istouch)
top:mousemoved(x, y, dx, dy, istouch)
end