Getting started building an agent-based model is terribly intimidating. There is often significant background assumed by the teacher or text. While I was a graduate student, NetLogo, "a multi-agent programmable modeling environment", served as an introduction to scripting, with its pseudo-OOP language. This was sufficient to teach me the basics of programming syntax. Yet, it was simultaneously limiting since the language could only be used within NetLogo. I owe much appreciation to creators of NetLogo. If the approach here seems like too much, you can get started using the NetLogo tutorial.
The tutorial I provide here is for those who would like their first model to catapult them directly into the broader world of programming. Python was my first programming language, one that I continually return to. But after more than a decade of programming, I increasingly see JavaScript as the ideal starting point for developers because of the ease of sharing one's work with non-programmers that the language facilitates. If you are familiar with NetLogo, you will see immediately that JavaScript, with a little bit of extra work, can provide the same functionality of NetLogo while providing many other opportunities.
Schelling's "Models of Segregation"
We will build a simple version of Schelling's "Models of Segregation", published in the American Economic Review in 1969. The logic of the model is simple. There is a landscape composed of pixels. Each pixel is an agent. Each agent wishes to be satsified with the composition of one's neighbors. Schelling's model showed that even just a preference for having half of one's neighbors be part of one's race (or culture) can lead to significant levels of segregation: a powerful insight. Of course, one can imagine many ways to develop the model beyond use of a single preference absent other constraints. After building the Schelling model through this tutorial, you can, without too heroic an effort, further develop it.
1. Build the HTML File
To begin, we will create an HTML (Hypertext Markup Language) file. The HTML language generates outputs using the Document Object Model that creates objects that will be accessible using JavaScript. I find that HTML is intuitive once you begin working with a few of the main building blocks of the language. It also helps to conceptualize an html file as being composed of two parts. First part of the document defines the head, which is followed by the body of the document. Often, we will identify styling in the document using CSS (Cascading Style Sheets). We will not focus on this part but will create a minimal CSS script for the model. In the head, we will also define the title that appears at the top of the window and the meta, where we will assign features that improve formatting for mobile devices. Don't worry, it is quite easy.
In the body of the document, we will create various widgets (inputs and buttons) as well as the canvas. Each of the input elements will be preceded by labels. Each widget is assigned an id that will be used to access the value of the input. This value will be reflected in the span element immediately following the input element. The text and input widgets will automatically wrap to the next line when they reach the edge of the window. (We could make this look nicer, but let's not get distracted.) Following the input widgets are the buttons that will 1) initialize, 2) step, and 3) run the model. Again, we will reference these by id. The buttons are followed by text that will reflect the period of the model. Below the period is the canvas that will visually represent the current position of each agent in the model.
The specific input widgets are as follows:
Input Name |
ID |
Input Type |
Current Value ID |
Number of types |
numTypes |
number |
|
Tolerance |
tolerance |
range |
tolValue |
Neighbors per Side |
gridSize |
range |
gridSizeValue |
Percent Vacant |
percentVacant |
range |
percentVacantValue |
Milliseconds per Step |
stepInterval |
range |
stepIntervalValue |
Following the input values are the button elements, the period value, and canvas. These will reappear as we link the sliders to the model using JavaScript. We could create all of these elements using JavaScript. However, since I am assuming you do not have a strong background in programming, this is an opportunity to learn about the general structure of the HTML script with which our JavaScript code will interact.
<!DOCTYPE html>
<html lang = "en">
<head>
<!-- Simple meta tag or you can format for mobile devices...
<meta charset = "UTF-8">
-->
<meta name="viewport" content="width=device-width, initial-scale=1.0" charset="UTF-8" />
<title>Schelling Segregation Model</title>
<style>
/* set general settings for body dom */
body {
font-family: sans-serif;
padding: 20px;
}
canvas {
border: 1px solid #555555;
/*other image-rendering options include crisp-edges, pixelated, optimizeQuality, optimizeSpeed*/
image-rendering: auto;
margin-top: 20px;
}
</style>
</head>
<body>
<!-- here we will set the widgets that set model parameters-->
<div id = "controls">
<label> Number of types:</label><input type = "number" id = "numTypes" value = "2" min = "2" max = "10">
<!-- range input is a slider; to reflect current value requires JS -->
<label>Tolerance:</label><input type = "range" id = "tolerance" min ="0" max = "1" step = "0.01" value = "0.3"><span id="tolValue">0.30</span>
<label>Neighbors per Side:</label><input type = "range" id = "gridSize" min = "5" max = "100" step = "1" value = "50"><span id = "gridSizeValue">50</span>
<label>Percent Vacant:</label> <input type = "range" id = "percentVacant" min = "0" max = "1" step = "0.01" value = "0.2"><span id="percentVacantValue">0.20</span>
<!-- add stepInterval slider -->
<label>Step Interval:</label><input type = "range" id = "stepInterval" min = "0" max = '100' step = "1" value = '1'><span id = "stepIntervalValue">1</span>
<button id = "initBtn">Initialize</button>
<button id = "stepBtn">Step</button>
<button id = "runBtn">Run</button>
</div>
<label>Period: </label><span id = "period">0</span>
<br>
<!-- We will have the size automatically update relative to the size of the window -->
<canvas id = "gridCanvas" width = "300" height = "300"></canvas>
</div>
<!-- <script src = "segregation.js" type = "module"></script> -->
</body>
</html>
Finally, at the end of the script we could import the JavaScript file. I have commented this out. For simplicity we will, instead, develop this script within the html file. When loaded in the browser, the html file should appear as follows. The widgets have not been connected to any part of the model, but you can move the sliders around and click the buttons.
2. Updating the Window Size
In the next step, we will take our first leap into interactivity. We will create a variable to reference the canvas where the positions of agents will be presented. Although we have not created cells that represent each agent, we will calculate the number of cells that fit along one side of the canvas. We will use the canvasScale variable to ensure that the canvas does not stretch completely across the screen, either vertically or horizontally as we wouuld like to be able to view it in its entirety even when dimensions of the window do not match.
We access the value of the slider labeled "Neighbors per Side" by calling the id: gridSize. We save the slider as gridInput so that it can be called using this variable. Then, from gridInput, we save the value as gridSize and calculate cellSize. We will build a function that recalculates cellSize any time the window is resized.
While we are at it, let's update the text reflecting the value of this slider. We access the value next to the slider using the id, gridSizeValue. We add a listener to the slider, using the slider variable that we saved, gridInput. The listener updates the gridSizeValue as well as cellSize any time the slider is moved. You will be able to see the value change as the slider moves when you load the updated file.
Our first function is the updateCanvasSize() function. This function will check the size of the window and scale the canvas to whichever dimension, the height or the width, that is smaller. If the model is already drawn, indicated by the boolean variable, gridDrawn, we will rescale the model that is displayed in the canvas. For now, drawGrid() function will not be called. We will build this function once we have initialized the model.
Within and beneath the updateCanvasSize() function, we call the window variable. You may notice that we never created this variable. It is a global variable that refers to the viewing area within the browser. As the dimensions of the browser change, we can access the new height and width values on-the-fly with window.innerWidth and window.innerHeight. Our updateCanvasSize() function will draw this information whenever called.
We immediately call the updateCanvasSize() function to ensure that the the canvas size is proportional to the window dimensions when the browser is first loaded. Then, we create a resize listener that is attached to the window. Whenever window dimensions change, the updateCanvasSize() function is called.
<script>
// link objects in html doc to js variables
const canvas = document.getElementById("gridCanvas");
const ctx = canvas.getContext("2d");
let grid = [];
let canvasScale = 0.9;
let gridDrawn = false;
// draw num
const gridInput = document.getElementById("gridSize");
let gridSize = parseInt(gridInput.value);
let cellSize = canvas.width / gridSize;
// dynamically update grid size value
const gridSizeValue = document.getElementById("gridSizeValue");
// ad listner to gridInput to update gridSizeValue
gridInput.addEventListener("input", e => {
gridSize = e.target.value;
gridSizeValue.innerText = gridSize;
cellSize = canvas.width / gridSize;
});
function updateCanvasSize() {
if (window.innerWidth < window.innerHeight) {
canvas.width = window.innerWidth *canvasScale;
canvas.height = window.innerWidth *canvasScale;
}
else {
canvas.width = window.innerHeight * canvasScale;
canvas.height = window.innerHeight * canvasScale;
}
cellSize = canvas.width / gridSize;
if (gridDrawn) {drawGrid();}
}
updateCanvasSize();
// create listener to dynamically update canvas size to match window width if it is smaller than window height and window height if it is smaller than window width
window.addEventListener("resize", updateCanvasSize);
</script>
3. Reflect the Current Value of the Remaining Sliders
The next step is to create a variables for each slider and for the value that is supposed to reflect the slider. The names for these inputs, their ids and the ids of each variable reflecting a slider value is contained in the table above. There is not too much else to say about this. As you update the script, you can refresh your HTML file to see that the slider value is presented by the value in the span next to the slider. (For the remaining additions, we can assume that the script will be placed within the <script></script>
tags.)
// . . .
const period = document.getElementById("period");
// dynamically update types value
const typesInput = document.getElementById("numTypes");
let types = typesInput.value;
typesInput.addEventListener("change", e => {
types = parseInt(e.target.value);
});
// dynamically update tolerance value
const toleranceInput = document.getElementById("tolerance");
let tolerance = parseFloat(toleranceInput.value);
const toleranceValue = document.getElementById("tolValue");
// add listener for tolerance update
toleranceInput.addEventListener("input", e => {
tolerance = parseFloat(e.target.value);
// the difference between innerText and textContent is that innerText will return the text as it is displayed, while textContent will return the text as it is in the HTML
// used toFixed to avoid any hanging floating points... e.g., 0.100000001
toleranceValue.innerText = tolerance.toFixed(2);
});
let stepInput = document.getElementById("stepInterval");
let stepInterval = parseInt(stepInput.value);
let stepIntervalValue = document.getElementById("stepIntervalValue");
// add listener for stepInterval update
stepInput.addEventListener("input", e => {
stepInterval = parseInt(e.target.value);
stepIntervalValue.innerText = stepInterval;
});
// dynamically update percentVacant
const percentVacantInput = document.getElementById("percentVacant");
let percentVacant = parseFloat(percentVacantInput.value);
const percentVacantValue = document.getElementById("percentVacantValue");
// add listener for percentVacant update
percentVacantInput.addEventListener("input", e => {
percentVacant = parseFloat(e.target.value);
percentVacantValue.innerText = percentVacant.toFixed(2);
});
4. Create variables for the Buttons
Next we will create three variables that will be used to track agents in the model. We include these before the buttons because these buttons will call functions that use these data structures. The agentDict will hold all agents. We will cycle through the keys in this dictionary (they are actually called Objects in JavaScript) to check agent satisfaction and to check and, if necessary, change agent position. Finally, we will track which cells are empty in the emptyCells array.
We prepare the initBtn and the stepBtn with listeners. These listeners reference placeholder functions until we return to complete the init() and step() functions. We also create the variable, startStopBtn as we will use this later to switch the button between two states. These states will be identified by the boolean variable: running.
let agentDict = {};
let keys = [];
let emptyCells = [];
// Buttons
document.getElementById("initBtn").addEventListener("click", () =>{
init();
keys = shuffleList(Object.keys(agentDict));
});
document.getElementById("stepBtn").addEventListener("click", step);
let startStopBtn = document.getElementById("runBtn");
let running = false;
function init(){
console.log("placeholder for init function");
}
function step(){
console.log("placeholder for init function");
}
5. Develop Initialization
Now we are ready for the most intensive development so far. Since this is an agent-based model, it is good practice to create an Agent class. This is not strictly necessary in this case, but it will help you to develop a working template of model structure to which you can refer when developing other models.
5.1 Build the Agent
Each agent will own $x$ and $y$ coordinates that refer to the grid coordinates, an agentType (akin to culture or race), a variable, satisfied indicating their satisfaction, an id that will be used to call the agent from agentDict, and a list of neighbors. After each step, each agent will call the getNeighbors() method to record the race of the agent's neighbors. Then, the checkSatisfaction() will calculate the number of neighbors that are the same as the checking agent. We use the outOfBounds function to make sure that the agent only call values from the grid if they exist.
function outOfBounds(x, y) {
return x < 0 || x >= gridSize || y < 0 || y >= gridSize;
}
// build Agent class
class Agent {
constructor(x, y, agentType, id) {
this.x = x;
this.y = y;
this.agentType = agentType;
this.satisfied = false;
this.id = id;
this.neighbors = [];
}
getNeighbors() {
this.neighbors = [];
// check all 8 neighbors
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
// skip the agent itself
if (dx === 0 && dy === 0) continue;
const nx = this.x + dx;
const ny = this.y + dy;
// check if neighbor is within bounds
if (outOfBounds(nx, ny)) continue;
if (grid[nx][ny] === -1) continue;
this.neighbors.push(grid[nx][ny]);
}
}
}
checkSatisfaction() {
// add each value in sameagentType array
// to get the number of neighbors
let numSame = 0
this.neighbors.forEach(neighbor => {
numSame += neighbor === this.agentType ? 1 : 0;
}, 0);
this.percentSame = numSame / this.neighbors.length;
this.satisfied = (this.percentSame >= tolerance);
this.intPercentSame = Math.round(this.percentSame * 100);
}
}
5.2 Develop Initialization
Now we are ready to initialize the model. We will create the init() function and reference this function in a listener attached to the button with the id initButton.
The init() function immediately replaces the existing agentDict, keys, grid, and emptyCells global data structures so that data from a previous simulation is removed. Likewise, the value of period.innerText is set to $0$. We initialize the id of the agent. Values in the two-dimensional grid are filled using the nested for loops. If a random float between $0$ and $1$ is greater than percentVacant, then a value of $-1$ is entered to indicate that the cell is empty. Otherwise, a random integer is generated to indicate the race of the agent at the cell on the grid. Once the agentType is selected, the agent is created using the $x$ and $y$ coordinates, the agentType, and the id. The id is incremented upward after each iteration of the inner for loop. We gather the keys of agentDict to check the satisfaction of each agent so that this can be referenced after initialization using the checkAllSatisfied() function, which will call getNeighbors() and checkSatisfaction() for each agent. Though it is not strictly necessary on initialization, we shuffle the keys upon initialization using the shuffleList() function. This function randomly swaps the position of each element in the list with another element. When we run each step of the model, shuffling the keys is necessary to ensure that we do not temporally bias placement of agents when unsatisfied agents move to a empty cell on the grid. Although we will not reference the sastisfaction of the agent yet, in section 7, we will use an event listener to check the status of each agent on the canvas.
Finally, we draw the grid. Since we have measured the length of the side of a cell in the cellSize variable, we can use this value to identify the relative placement of each cell when we call ctx.fillRect(). This method identifies the corner of the top left corner of the cell and essentially draw a square with a side length of cellSize down and to the right of this anchor point. The top-left corner is treated as $(0,0)$, positive $y$ values actually indicated downward movement.
// Buttons
document.getElementById("initBtn").addEventListener("click", () =>{
init();
keys = shuffleList(Object.keys(agentDict));
});
// . . .
function init() {
// clear grid
let agentType = null;
agentDict = {}; // clear previous agents
keys = []; // clear keys if needed
grid = [];
emptyCells = [];
// set period to 0
period.innerText = 0;
let id = 0;
for (let x = 0; x < gridSize; x++) {
grid[x] = [];
for (let y = 0; y < gridSize; y++) {
const r = Math.random();
if (r < percentVacant) {
grid[x][y] = -1;
emptyCells.push({x,y});
} else {
// Effect is equivalent to drawing a random integer between zero and # of types - 1
agentType = Math.floor(Math.random() * types)
// create new agent from Agent class
agentDict[id] = new Agent(x, y, agentType, id);
grid[x][y] = agentDict[id].agentType;
id+=1;
}
}
}
// shuffle agentDict
keys = shuffleList(Object.keys(agentDict));
// check if each agent is happy
// get neighbors
checkAllSatisfied();
cellSize = canvas.width / gridSize;
drawGrid();
gridDrawn = true;
}
function drawGrid() {
ctx.clearRect(0,0,canvas.width, canvas.height);
for (let x = 0; x < gridSize; x++) {
for (let y = 0; y < gridSize; y++) {
const agentType = grid[x][y];
if (grid[x][y] === -1) {
ctx.fillStyle = "#FFFFFF";
} else {
ctx.fillStyle = `hsl(${(agentType / types) * 360}, 70%, 50%)`;
}
ctx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize);
}
}
}
function checkAllSatisfied() {
let allSatsified = true;
for (let i = 0; i < keys.length; i++) {
agentDict[keys[i]].getNeighbors();
agentDict[keys[i]].checkSatisfaction();
if (!agentDict[keys[i]].satisfied) {
allSatsified = false;
}
}
return allSatsified;
}
function shuffleList(list) {
for (let i = list.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
}
return list;
}
6. Create Step and Run Functions
Finally, we can run the model. Operations during a period in the model are defined by the step() function. We randomly cycle through the keys in agentDict. For each agent, we check if that agent is satisfied. If the agent is not satisfied, the agent moves to an empty cell. At the end of the step() function, we update the satisfaction of each agent, draw the grid, and move the period forward by $1$.
The run() simply calls the step() for as long as the run button is not clicked again. The running boolean variable is set to true, only switching to false if the button, now labeled stop, is clicked again or until all agents are happy with their position. The run() function calls the runInterval value, which determines the minimum length of time, in milliseconds, that passes between each time that the step() function is called. If you set the runInterval value to $0$, it will run at maximum speed, though the maximum number of frames per second will likely be reached somewhere around an interval of $5$ to $10$ milliseconds.
Once you have completed these updates to the script, the model will run. Try it out before moving on.
document.getElementById("stepBtn").addEventListener("click", step);
let startStopBtn = document.getElementById("runBtn");
let running = false;
startStopBtn.addEventListener("click", run);
// . . .
function step() {
// shuffle agentDict
// create array of keys from agentDict
keys = shuffleList(keys);
// cycle through all keys
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const agent = agentDict[key];
// check if agent is happy
if (agent.satisfied) continue;
// move agent who is not happy
// check if there are any empty cells
if (emptyCells.length === 0) {
break;
}
// get random empty cell
const randomIndex = Math.floor(Math.random() * emptyCells.length);
const randomCell = emptyCells[randomIndex];
// check if random cell is empty
if (grid[randomCell.x][randomCell.y] === -1) {
// agents original position is now empty
grid[agent.x][agent.y] = -1;
// remove random cell from emptyCells
emptyCells.splice(randomIndex, 1);
// add old cell to emptyCells
emptyCells.push({x: agent.x, y: agent.y});
// agent moves to empty cell, so it is not longer empty...
agent.x = randomCell.x;
agent.y = randomCell.y;
grid[agent.x][agent.y] = agent.agentType;
}
}
let allSatisfied = checkAllSatisfied();
drawGrid();
// update period label
period.innerText = parseInt(period.innerText) + 1;
return allSatisfied;
}
function run() {
if (gridDrawn) {
if (running) {
running = false;
startStopBtn.innerText = "Run";
} else {
running = true;
startStopBtn.innerText = "Stop";
// run simulation until startStopBtn is clicked again
// runInterval creates an infinite loop that runs every stepInterval milliseconds
const runInterval = setInterval(() => {
if (!running) {
clearInterval(runInterval);
return;
}
const allSatisfied = step();
if (allSatisfied) {
// nobody wants to move โ stop running
running = false;
startStopBtn.innerText = "Run";
}
}, stepInterval);
};
};
}
7. Add mousemove Listener for Hover Text
Our last task is to create an interactive feature that shows information about an agent when the mouse hovers over the cell where the agent resides. We will attach a listener to the entire canvas that generates hover text (tooltips). We will translate the mouse coordinates to the model grid, as we have done before. Then, we will use the function, findAgentAtCell() to call the agent at the mouse's grid position. The hover text will reflect the agents type (akin to race or culture), the agent's satisfaction ('๐ Happy' or '๐ Sad'), and the percentage of the agents neighbors that are the same type as the agent. Every time the mouse moves over the canvas, the grid position is identified. Then, the position of every agent in the agentDict is compared to the mouse position. Once the agent with the same position is found, then the agent information is selected and reflected in the hover text.
The variable, tooltip, actually refers to a div, but this div does not appear in a fixed position. It is a placeholder for holding information that will appear relative to the position of the mouse.
Once you have gotten this interactive feature functioning, you are done with the agent based model. If this your first agent based model that you have created in JavaScript, or any language, $Congratulations!$ You have taken your first steps into a larger world of programming. Whether you want to continue building models, websites, or applications, the fundamentals that you have learned here will position you to beginning making swift progress.
function findAgentAtCell(gridX, gridY) {
for (let key in agentDict) {
const agent = agentDict[key];
if (agent.x === gridX && agent.y === gridY) {
let info = `agentType: ${agent.agentType}\nSatisfaction: ${agent.satisfied ? '๐ Happy':'๐ Sad'} \n ${agent.intPercentSame}% similar neighbors`;
return info;
}
}
}
// Add tooltip element once at startup
const tooltip = document.createElement("div");
tooltip.style.position = "absolute";
tooltip.style.padding = "5px";
tooltip.style.backgroundColor = "#000000";
tooltip.style.color = "#FFFFFF";
tooltip.style.borderRadius = "3px";
tooltip.style.pointerEvents = "none";
tooltip.style.fontSize = "14px";
tooltip.style.visibility = "hidden";
document.body.appendChild(tooltip);
// Add mouse event listeners on canvas
canvas.addEventListener("mousemove", e => {
if (period.innerText === "0") {
// do not execute script if grid is not drawn
tooltip.style.visibility = "hidden";
return;
}
const rect = canvas.getBoundingClientRect();
// get mouse position relative to canvas
// rect.left and rect.top are the coordinates of the top left corner of the canvas
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const gridX = Math.floor(mouseX / cellSize);
const gridY = Math.floor(mouseY / cellSize);
let info = "Empty cell";
// if mouse in bounds
if (!outOfBounds(gridX, gridY) && grid[gridX][gridY] !== -1) {
// Find the agent at this cell by iterating agentDict
info = findAgentAtCell(gridX, gridY);
}
tooltip.innerText = info;
tooltip.style.left = e.pageX + 10 + "px";
tooltip.style.top = e.pageY + 10 + "px";
tooltip.style.visibility = "visible";
});
canvas.addEventListener("mouseleave", () => {
tooltip.style.visibility = "hidden";
});