Conway's Game of Life demo annotated source
Back to indexMinimal Conway's Game of Life simulation
2
Bootstrap the required globals from Torus, since we're not bundling
4for (const exportedName in Torus) {
5 window[exportedName] = Torus[exportedName];
6}
7
Constants to determine simulation scale and look
9const CELL_SIZE = 10;
10const CELL_RADIUS = 3;
11
GameOfLife
encapsulates the full state of a Game of Life simulation
and implements all game logic. It forms the "model" layer of the app.
14class GameOfLife {
15
16 constructor() {
Determine how many cells we have in our window, given the cell
sizes and the window size. The + 2
makes sure the full canvas
is filled.
20 this.xCount = ~~(window.innerWidth / CELL_SIZE) + 2;
21 this.yCount = ~~(window.innerHeight / CELL_SIZE) + 2;
22 this.count = this.xCount * this.yCount;
23
We represent the game as a single array of 0's and 1's, scanning the grid row-by-row, left to right, from the top row to the bottom row. Cells all begin with the "dead" 0 state.
27 this.cells = Array.from({length: this.count});
this.cells
is double-buffered, so that we don't have to keep creating
new cells arrays with each tick. This is the other "buffer" of the game state.
30 this._cells = Array.from({length: this.count});
31 this.clear();
32 }
33
Seed the entire game board randomly, without killing live cells.
35 seedRandomly() {
36 for (let i = 0; i < this.count; i ++) {
37 if (this.cells[i] === 0) {
38 this.cells[i] = Math.random() < .1 ? 1 : 0;
39 }
40 }
41 }
42
Clear the entire game board for a new game.
44 clear() {
45 this.cells.fill(0);
46 }
47
Utility function to get the index of a cell above the given one. -1 is used as a sentinel value for "no such cell".
50 north(i) {
51 const result = i - this.xCount;
52 return result < 0 ? -1 : result;
53 }
54
Like north()
, but for the cell below the given one.
56 south(i) {
57 const result = i + this.xCount;
58 return result >= this.count ? -1 : result;
59 }
60
Like north()
, but for the cell to the right of the given one.
62 east(i) {
We have to do this add-one, subtract-one trick here because remainders should start at 1 but indexes start at 0.
65 const remainder = (i + 1) % this.xCount;
66 if (remainder === 0) {
67 return -1;
68 } else {
69 return i + 1;
70 }
71 }
72
Like north()
, but for the cell to the left of the given one.
74 west(i) {
75 const remainder = (i + 1) % this.xCount;
76 if (remainder === 1) {
77 return -1;
78 } else {
79 return i - 1;
80 }
81 }
82
Given current game state and a cell index, compute the next state of the cell in the next tick/step. Each tick of the game computes this for each cell on the board.
85 cellNextState(i) {
86 const live = this.cells[i] === 1;
87 const cells = this.cells;
88
89 const west = this.west(i);
90 const east = this.east(i);
91
Compute live/dead state for each of the 8 neighboring cells.
93 let liveNeighbors = 0;
94 if (this.cells[this.north(i)] === 1) {
95 liveNeighbors ++;
96 }
97 if (this.cells[this.south(i)] === 1) {
98 liveNeighbors ++;
99 }
100 if (cells[east] === 1) {
101 liveNeighbors ++;
102 }
103 if (cells[west] === 1) {
104 liveNeighbors ++;
105 }
106 if (cells[this.north(west)] === 1) {
107 liveNeighbors ++;
108 }
109 if (cells[this.south(west)] === 1) {
110 liveNeighbors ++;
111 }
112 if (cells[this.north(east)] === 1) {
113 liveNeighbors ++;
114 }
115 if (cells[this.south(east)] === 1) {
116 liveNeighbors ++;
117 }
118
Literal implementation of Conway's Game of Life
120 if (live && liveNeighbors < 2) {
121 return 0;
122 } else if (live && (liveNeighbors === 2 || liveNeighbors === 3)) {
123 return 1;
124 } else if (live && liveNeighbors > 3) {
125 return 0;
126 } else if (!live && liveNeighbors === 3) {
127 return 1;
128 } else {
If no change, return the original state of the cell.
130 return this.cells[i]
131 }
132 }
133
Iterate the entire game board for one tick.
135 step() {
this.cells
is double-buffered, so we modify the non-current
buffer of cells with new cell states and swap them out at the end.
138 for (let i = 0; i < this.count; i ++) {
139 this._cells[i] = this.cellNextState(i);
140 }
141 const tmpCells = this.cells;
142 this.cells = this._cells;
143 this._cells = tmpCells;
144 }
145
146}
147
GameCanvas
component represents the canvas on which the game unfolds.
It contains just the canvas, and no other UI element, but is controllable
via its API (methods).
151class GameCanvas extends Component {
152
153 init() {
154 this.width = window.innerWidth;
155 this.height = window.innerHeight;
156
Create a <canvas>
element for the game itself. We render this
programmatically, not through the compose()
method, so we can control
exactly how things are painted on the canvas's 2D context.
160 this.canvas = document.createElement('canvas');
161 this.canvas.width = this.width;
162 this.canvas.height = this.height;
163 this.ctx = this.canvas.getContext('2d');
Since we only ever paint one thing -- the dots -- we set a single fill style here and never change it.
166 this.ctx.fillStyle = '#333';
167
New instance of the game state, seeded with a random state to begin
169 this.game = new GameOfLife();
This stores the previous "snapshot" of the game state, for dragging / clicking to toggle cell states. We need to do this because when we drag, we should flip each cell state from a snapshot before we started dragging, not necessary the state immediately before the mouse cursor reaches the cell.
174 this._prevGameCells = this.game.cells.slice();
175
Local state that represents whether a mouse or touch pointer is being dragged, for adding points to the game.
178 this._down = false;
179
Bind UI event listener methods
181 this.handleStart = this.handleStart.bind(this);
182 this.handleMove = this.handleMove.bind(this);
183 this.handleEnd = this.handleEnd.bind(this);
184
185 this.canvas.addEventListener('mousedown', this.handleStart);
186 this.canvas.addEventListener('mousemove', this.handleMove);
187 this.canvas.addEventListener('mouseup', this.handleEnd);
188 this.canvas.addEventListener('touchstart', this.handleStart);
189 this.canvas.addEventListener('touchmove', this.handleMove);
190 this.canvas.addEventListener('touchend', this.handleEnd);
191
Randomly seed the game state to begin
193 this.seedRandomly();
194 }
195
Helper that maps XY coordinates in the screen to an index in the cells array. There's a slight leak of abstraction here because we have to worry about the 1-dimensional array representation of cells, but this gains us a measurable performance impact.
200 xyToCellIdx(x, y) {
201 return (~~(y / CELL_SIZE) * this.game.xCount) + ~~(x / CELL_SIZE);
202 }
203
204 toggleCellStateAt(xCoord, yCoord) {
205 const cellIdx = this.xyToCellIdx(xCoord, yCoord);
(x + 1) % 2
is an easy way to flip between the 0 and 1 states without a conditional
207 this.game.cells[cellIdx] = (this._prevGameCells[cellIdx] + 1) % 2;
208 }
209
210 handleStart(evt) {
211 evt.preventDefault();
212 this._down = true;
When the pointer is down, immediately start painting new cells in, so a "click" will regiter a new live cell.
215 if (evt.touches) {
216 evt = evt.touches[0];
217 }
218 this._prevGameCells = this.game.cells.slice();
219 this.toggleCellStateAt(evt.clientX, evt.clientY);
220 this.render();
221 }
222
223 handleMove(evt) {
224 evt.preventDefault();
225 if (this._down) {
226 if (evt.touches) {
227 evt = evt.touches[0];
228 }
229 this.toggleCellStateAt(evt.clientX, evt.clientY);
230 this.render();
231 }
232 }
233
234 handleEnd(evt) {
235 evt.preventDefault();
236 this._down = false;
237 }
238
239 seedRandomly() {
240 this.game.seedRandomly();
241 this.render();
242 }
243
244 clear() {
245 this.game.clear();
246 this.render();
247 }
248
Main logic for rendering the game's state to the canvas
250 redraw() {
251 const ctx = this.ctx;
252 const {cells, count, xCount} = this.game;
253
Memoize constants, since it's easy and cheap
255 const HALFCELL = CELL_SIZE / 2;
256 const TAU = Math.PI * 2;
257
Clear the canvas. We'll re-draw the entire world each time.
259 ctx.clearRect(0, 0, this.width, this.height);
260
For each cell, if the cell is alive, draw a circle and fill it in.
262 for (let i = 0; i < count; i ++) {
263 if (cells[i] === 1) {
264 const remainder = (i + 1) % xCount;
265
266 ctx.beginPath();
267 ctx.arc(
268 ((remainder - 1) * CELL_SIZE) + HALFCELL,
269 ((i - remainder + 1) / xCount * CELL_SIZE) + HALFCELL,
270 CELL_RADIUS,
271 0,
272 TAU
273 );
274 ctx.fill();
275 }
276 }
277 }
278
Simulate and render a "tick" in the game. This is the API to progress
the game from components that consume this GameCanvas
component.
281 step() {
282 this.game.step();
283 this.render();
284 }
285
We override the compose method to also re-draw the frame with each render.
287 compose() {
288 this.redraw();
289 return this.canvas;
290 }
291
292}
293
Main app that contains the simulation and a couple of other UI elements.
295class App extends StyledComponent {
296
297 init() {
By default, a game in "play" ticks forward every 100ms
299 this.INTERVAL = 100;
This is where we store the timer of a game in play, so we can cancel it.
301 this.timer = null;
Make a new game canvas to display game state.
303 this.gameCanvas = new GameCanvas();
304 }
305
306 start() {
Iff game is not playing, start a new interval timer that steps the game forward each time.
309 if (this.timer === null) {
310 this.timer = setInterval(() => this.gameCanvas.step(), this.INTERVAL);
311 this.render();
312 }
313 }
314
315 pause() {
To pause, clear the timer and set it back to null, so we can check if we're playing.
318 clearInterval(this.timer);
319 this.timer = null;
320 this.render();
321 }
322
323 styles() {
324 return css`
325 height: 100vh;
326 width: 100vw;
327 overflow: hidden;
328 menu {
329 position: absolute;
330 background: #fff;
331 box-shadow: 0 3px 6px rgba(0, 0, 0, .4);
332 transform: translateX(-50%);
333 top: 20px;
334 left: 50%;
335 border-radius: 40px;
336 display: flex;
337 margin: 0;
338 padding: 8px;
339 flex-direction: row;
340 button {
341 padding: 0 8px;
342 font-size: 1em;
343 cursor: pointer;
344 height: 36px;
345 line-height: 34px;
346 border-radius: 18px;
347 background: #333;
348 color: #fff;
349 margin-left: 8px;
350 transition: transform .2s;
351 border: 0;
352 &:first-child {
353 margin-left: 0;
354 }
355 &:hover {
356 opacity: .7;
357 transform: translateY(-2px);
358 }
359 }
360 }
361 footer {
362 position: absolute;
363 right: 4px;
364 bottom: 4px;
365 padding: 0 6px;
366 box-sizing: border-box;
367 text-align: right;
368 color: #777;
369 font-family: system-ui, sans-serif;
370 font-size: 14px;
371 }
372 a {
373 color: #777;
374 cursor: pointer;
375 &:hover {
376 opacity: .7;
377 }
378 }
379 `;
380 }
381
382 compose() {
383 return jdom`<main>
384 ${this.gameCanvas.node}
385 <menu>
386 <button onclick="${() => this.gameCanvas.clear()}">Clear</button>
387 <button onclick="${() => this.gameCanvas.seedRandomly()}">Randomize</button>
388 <button onclick="${() => this.gameCanvas.step()}">Step</button>
389 ${this.timer === null ? (
Depending on whether the game is in play or not, display the appropriate button.
392 jdom`<button onclick="${() => this.start()}">Play</button>`
393 ) : (
394 jdom`<button onclick="${() => this.pause()}">Pause</button>`
395 )}
396 </menu>
397 <footer>
398 Conway's Game of Life by
399 <a href="https://linus.zone/now">Linus</a>,
400 built with
401 <a href="https://linus.zone/torus">Torus</a>
402 </footer>
403 </main>`;
404 }
405
406}
407
Create an instance of the app and mount it to the page DOM.
409const app = new App();
410document.body.appendChild(app.node);
411document.body.style.margin = '0';
412document.body.style.padding = '0';
413