Conway's Game of Life demo annotated source

Back to index

        

Minimal 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