Drawing stars and snakes and polygons is fun and all, but maybe you’re ready for a more substantial Dart example web app. This seems like a perfect time to implement a classic: Conway’s Game of Life. The app incorporates canvas-based animation, input from HTML controls, and a whole lot of other Darty goodness.

It's Alive Screenshot

It’s alive! You’re going to want to try this one out–especially if you’ve never seen the Game of Life or worked with cellular automata before. 🤓 Click here to run the app!

Starting with the HTML

Here we go! The HTML should look pretty familiar. It’s another canvas with some buttons and a box for typing in numbers.

<!DOCTYPE html>

<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="scaffolded-by" content="https://github.com/google/stagehand">
    <title>its_alive</title>
    <link rel="stylesheet" href="styles.css">
    <script defer src="main.dart.js"></script>
</head>

<body>

    <div id="canvas_container" class="container">
        <p><a href="https://www.dartographer.com">Dartographer</a> © 2018 Aaron Barrett</p>
        <canvas id="canvas" width="100" height="100"></canvas>
    </div>

    <div class="container">
        <table>
            <tr>
                <tc>
                    <label>FPS</label>
                    <input id="fps_box" type="number" value="20" min="5" max="60" step="5">
                </tc>
                <tc><input id="pause_button" type="button" value="Pause" disabled></tc>
                <tc><input id="step_button" type="button" value="Step" disabled></tc>
                <tc><input id="clear_button" type="button" value="Clear" disabled></tc>
                <tc><input id="randomize_button" type="button" value="Randomize" disabled></tc>
            </tr>
        </table>
    </div>

</body>
</html>

Bringing it to life with Dart

As usual, we’re going straight from main.dart into a Simulation class.

Here’s main.dart.

import "dart:html";

import "canvas_tools.dart";
import "simulation.dart";

void main() {
  var canvas = CanvasTools.configureSquareCanvas();
  var context = canvas.getContext("2d") as CanvasRenderingContext2D;

  new Simulation(canvas, context);
}

And here’s simulation.dart.

import "dart:html";
import "dart:async";

import "grid.dart";

enum SimulationState { running, paused, stopped }

class Simulation {
  CanvasElement _canvas;
  CanvasRenderingContext2D _context;

  double _canvasSize;

  Grid _grid;

  SimulationState _state = SimulationState.stopped;

  Timer _timer = new Timer(Duration.zero, () {});
  Duration _timerDuration = Duration.zero;

  int _animationFrameID;

  NumberInputElement _fpsBox = querySelector("#fps_box");
  ButtonInputElement _pauseButton = querySelector("#pause_button");
  ButtonInputElement _stepButton = querySelector("#step_button");
  ButtonInputElement _clearButton = querySelector("#clear_button");
  ButtonInputElement _randomizeButton = querySelector("#randomize_button");

  Simulation(this._canvas, this._context) {
    _canvasSize = _canvas.width.toDouble();
    _grid = new Grid(_canvasSize);

    _canvas.draggable = true;

    _canvas.onClick.listen(_onCanvasClicked);
    _canvas.onDragOver.listen(_onCanvasMouseDragged);
    _fpsBox.onChange.listen((_) => _refreshFramerate());
    _pauseButton.onClick.listen(_onPausePressed);
    _stepButton.onClick.listen(_onStepPressed);
    _clearButton.onClick.listen(_onClearPressed);
    _randomizeButton.onClick.listen(_onRandomizePressed);

    _pauseButton.disabled = false;
    _clearButton.disabled = false;
    _randomizeButton.disabled = false;

    state = SimulationState.running;
  }

  void _stopAnimating() {
    _timer.cancel();
    window.cancelAnimationFrame(_animationFrameID);
  }

  void _startAnimating() {
    _refreshFramerate();
    _animationFrameID = window.requestAnimationFrame(_update);
  }

  void _update(num time) {
    _timer.cancel();
    _timer = new Timer(_timerDuration, () {
      _grid.update();
      _grid.draw(_context);

      _animationFrameID = window.requestAnimationFrame(_update);
    });
  }

  void _refreshFramerate() {
    var fps = int.tryParse(_fpsBox.value) ?? 20;
    fps = fps.clamp(5, 60);
    _fpsBox.value = fps.toString();
    _timerDuration = new Duration(milliseconds: 1000 ~/ fps);
  }

  void _onPausePressed(_) {
    if (state == SimulationState.running) {
      state = SimulationState.paused;
    } else if ((state == SimulationState.paused) ||
        (state == SimulationState.stopped)) {
      state = SimulationState.running;
    } else {
      throw new StateError("Cannot pause/resume from current state: $state");
    }
  }

  void _onStepPressed(_) {
    _grid.update();
    _grid.draw(_context);
  }

  void _onClearPressed(_) {
    state = SimulationState.stopped;
    _grid.clear();
    _grid.draw(_context);
  }

  void _onRandomizePressed(_) {
    state = SimulationState.stopped;
    _grid.randomizeCells();
    _grid.draw(_context);
  }

  void set state(SimulationState value) {
    if (value == SimulationState.running) {
      _pauseButton.value = "Pause";
      _stepButton.disabled = true;
      _startAnimating();
    } else if (value == SimulationState.paused) {
      _pauseButton.value = "Resume";
      _stepButton.disabled = false;
      _stopAnimating();
    } else if (value == SimulationState.stopped) {
      _pauseButton.value = "Start";
      _stepButton.disabled = false;
      _stopAnimating();
    } else {
      throw new UnsupportedError("Unsupported simulation state: $value");
    }

    _state = value;
  }

  SimulationState get state => _state;

  void _onCanvasClicked(MouseEvent event) {
    if (state == SimulationState.running) {
      state = SimulationState.paused;
    } else if ((state == SimulationState.paused) ||
        (state == SimulationState.stopped)) {
      var clickLocation = event.offset;
      var cell = _grid.cellAtLocation(clickLocation);
      cell.invert();
      _grid.draw(_context);
    } else {
      throw new UnsupportedError(
          "Canvas clicked with unsupported state: $state");
    }
  }

  void _onCanvasMouseDragged(MouseEvent event) {
    if (state == SimulationState.running) {
      state = SimulationState.paused;
    } else if ((state == SimulationState.paused) ||
        (state == SimulationState.stopped)) {
      var clickLocation = event.offset;
      var cell = _grid.cellAtLocation(clickLocation);
      cell.isAlive = true;
      _grid.draw(_context);
    } else {
      throw new UnsupportedError(
          "Canvas mouse dragged with unsupported state: $state");
    }
  }
}

There’s a lot more to see this time. In the constructor, the Simulation pulls out all of the HTML controls and starts listening for events from them. Beyond that, the Simulation is set up as a very simple state machine. There are three possible states declared in the SimulationState enum: running, paused, and stopped. If you look at the setter method for state, you’ll notice that paused and stopped have the same effects; they just set the text on the _pauseButton to different values.

The way we’re doing animation is a little more complex this time around. We’re using a Timer from Dart’s async library. The Duration for that timer is determined by _refreshFramerate, and we replace _timer with a new Timer every time _update gets called. We’re also holding on to an _animationFrameID, which we need in order to cancel the animation in the web browser.

But the most important part of the Simulation is the Grid, which does all the drawing to the canvas. Let’s take a look at grid.dart.

import "dart:html";
import "dart:math";
import "cell.dart";
import "direction.dart";

class Grid {
  static const sizeInCells = 40;
  static const cellsCount = sizeInCells * sizeInCells;

  static const gridBrightness = 38;

  final double _canvasSize;

  List<Cell> _cells = new List(cellsCount);

  double _cellSize;
  double _cellPadding;

  Grid(this._canvasSize) {
    _cellSize = _canvasSize / sizeInCells;
    _cellPadding = max(0.5, _cellSize * 0.05);

    _buildCells();
    _linkCellNeighbors();
    randomizeCells();
  }

  void _buildCells() {
    for (var cellIndex = 0; cellIndex < cellsCount; cellIndex++) {
      var xIndex = cellIndex % sizeInCells;
      var yIndex = cellIndex ~/ sizeInCells;

      _cells[cellIndex] = new Cell(xIndex, yIndex);
    }
  }

  void _linkCellNeighbors() {
    for (var cell in _cells) {
      var xIndex = cell.xIndex;
      var yIndex = cell.yIndex;

      cell.assignNeighbor(
          Direction.northWest, _wrappedCell(xIndex - 1, yIndex - 1));
      cell.assignNeighbor(Direction.north, _wrappedCell(xIndex, yIndex - 1));
      cell.assignNeighbor(
          Direction.northEast, _wrappedCell(xIndex + 1, yIndex - 1));
      cell.assignNeighbor(Direction.west, _wrappedCell(xIndex - 1, yIndex));
      cell.assignNeighbor(Direction.east, _wrappedCell(xIndex + 1, yIndex));
      cell.assignNeighbor(
          Direction.southWest, _wrappedCell(xIndex - 1, yIndex + 1));
      cell.assignNeighbor(Direction.south, _wrappedCell(xIndex, yIndex + 1));
      cell.assignNeighbor(
          Direction.southEast, _wrappedCell(xIndex + 1, yIndex + 1));
    }
  }

  void randomizeCells() => _cells.forEach((cell) => cell.randomize());

  void clear() => _cells.forEach((cell) => cell.clear());

  void update() {
    _cells.forEach((cell) => cell.determineSurvivalMarking());
    _cells.forEach((cell) => cell.resolveSurvival());
  }

  void draw(CanvasRenderingContext2D context) {
    context
      ..setFillColorRgb(0, gridBrightness, 0)
      ..fillRect(0.0, 0.0, _canvasSize, _canvasSize);

    for (var cell in _cells) {
      context
        ..setFillColorRgb(0, cell.fillBrightness, 0)
        ..fillRect(
            cell.xIndex * _cellSize + _cellPadding,
            cell.yIndex * _cellSize + _cellPadding,
            _cellSize - 2.0 * _cellPadding,
            _cellSize - 2.0 * _cellPadding);
    }
  }

  Cell _wrappedCell(int xIndex, int yIndex) {
    xIndex %= sizeInCells;
    yIndex %= sizeInCells;

    var cellIndex = (yIndex * sizeInCells) + xIndex;
    return _cells[cellIndex];
  }

  Cell cellAtLocation(Point<num> location) {
    var xIndex = (location.x / _cellSize).floor();
    var yIndex = (location.y / _cellSize).floor();

    xIndex = min(xIndex, sizeInCells - 1);
    yIndex = min(yIndex, sizeInCells - 1);

    var cellIndex = (yIndex * sizeInCells) + xIndex;
    return _cells[cellIndex];
  }
}

To understand what this code is doing, you’ll need to study the rules to Conway’s Game of Life. They’re surprisingly simple!

The Grid is composed of Cells40 wide and 40 tall. In the constructor, we initialize a one-dimensional List of these cells. We’ll draw them as a two-dimensional grid, but they’re easier to work with if we keep them in a flat array. Next, we link each cell to its neighbors so that we can determine which cells will survive and die off each frame. Finally we randomly select whether each cell will start off as alive or dead.

The Grid interacts closely with the Cells. Let’s look at cell.dart.

import "direction.dart";
import "dart:math";

class Cell {
  static const starvationNeighborsCount = 1;
  static const overcrowdingNeighborsCount = 4;
  static const idealNeighborsCount = 3;

  static final Random randomGenerator = new Random();

  List<Cell> _neighbors = new List(Direction.values.length);

  bool isAlive = false;
  bool isMarkedToLive = false;

  int xIndex;
  int yIndex;

  Cell(this.xIndex, this.yIndex);

  void assignNeighbor(Direction direction, Cell neighbor) =>
      _neighbors[direction.index] = neighbor;

  int get fillBrightness {
    if (isAlive) {
      return 240;
    } else {
      return 0;
    }
  }

  int get livingNeighborsCount {
    int count = 0;
    for (var neighbor in _neighbors) {
      if (neighbor.isAlive) {
        count++;
      }
    }
    return count;
  }

  void determineSurvivalMarking() {
    isMarkedToLive = isAlive;

    var count = livingNeighborsCount;

    if ((count <= starvationNeighborsCount) ||
        (count >= overcrowdingNeighborsCount)) {
      isMarkedToLive = false;
    } else if (count == idealNeighborsCount) {
      isMarkedToLive = true;
    }
  }

  void resolveSurvival() => isAlive = isMarkedToLive;

  void clear() => isAlive = false;
  void invert() => isAlive = !isAlive;
  void randomize() => isAlive = randomGenerator.nextBool();

  @override
  String toString() => "Cell($xIndex, $yIndex)";
}

The rules for the entire game are expressed very plainly in determineSurvivalMarking. It’s remarkable just how concise an intricate program like this one can be in Dart. Getting all the boilerplate out of the way really helps!

Summing up

Even more complex programs can be expressed cleanly and concisely in Dart. If you’d like to see all the code for this example, it’s posted on here on GitHub. I’d encourage anyone to modify it and expand upon it. I hope you’ve found this example as amusing as I have. Thanks for reading, and happy coding!