Web Games with Dart: Ultimate Tic-Tac-Toe
Let's build a web game with Dart! Ever played Tic-Tac-Toe? Sucks, right? It's not even ultimate. But Ultimate Tic-Tac-Toe is! Since Dart is the ultimate web language, this will truly be a match made in heaven.
Note: This tutorial's full code is available on Github for those who would like to play with it or use it as a reference.
The code for this tutorial was tested with Dart SDK v1.12.1.
What is Dart?
Dart is an open-source, scalable, object-oriented programming language, with robust libraries and runtimes, for building web, server, and mobile apps. It was originally developed by Google, but has since become an open-source ECMA standard.
Setting Up
To create this game, you won't necessarily need a full Dart work environment on your computer, but if you want one, here's the stuff you need:
- Dart SDK and Dartium
- On Windows? Try the Dart for Windows installer.
- Dart-Up is a great option for Windows, OSX, or Linux.
- Code editor
- JetBrains WebStorm is the officially recommended Dart editor.
- See the Dart Tools page for other options (most of them free).
Good News!
All of the code in this tutorial will run just fine in the online Dart editor, DartPad. If you don't want to muck around with workspace setup right now, you don't have to. You can code the whole thing right in your browser.
Dart Primers
Check out the Dart Language Tour for a crash course in the Dart language. If you already know JavaScript, Java, PHP, ActionScript, C/C++/C#, or another "curly brace" language, you'll find Dart to be familiar, and you can be productive with Dart within an hour or so.
If you like learning from videos, take a look at Learn Dart in One Video.
Ultimate Tic-Tac-Toe
If you've never played Ultimate Tic-Tac-Toe, you probably have a job, friends, and several meaningful hobbies (yachting, for instance). As this colorful write-up discloses, it's a game primarily known only to mathematicians, geeks, nerds, programmers, and other undesirables.
A typical game of Tic-Tac-Toe is no fun past the age of 5 or so. Unless you're watching a movie, texting your BFF, and driving as you play, a tie is virtually inevitable. With the ultimate version, you'll find it takes you much longer to master to the point of destroying its potential to amuse.
How to Play
An Ultimate Tic-Tac-Toe board is like a regular one, except that there is a small Tic-Tac-Toe board within each of the main board's squares. To earn the privilege of marking one of the main board's squares with your X or O, you must first win that square's little board.
If that's all there was to it, you might be forgiven for saying, "But doesn't that mean you'll just end up with nine ties instead of one, making this game nine times as annoying?" If you jumped the gun and did say that, you are forgiven for underestimating me, but just this once.
What makes the game more interesting is that you can direct your opponent's next move. Each time you mark a little square, that dictates the main square your opponent must play next. If you place your X in the lower-middle square within any of the little boards, your worthy adversary must place his or her next O in the little board occupying the lower-middle square of the main board.
If you direct your opponent to a main board square that is already won or has resulted in a little tie, said foe may make his or her next move in any unoccupied square in the game. Note that in our implementation of the game, a tie on a little board becomes a dead zone, unavailable to either player for securing a win.
As you may have guessed by now, to win the game you must control three adjacent squares on the main board, vertically, horizontally, or diagonally. Spend a little time perfecting your happy dance for the occasion when you accomplish this.
Clear as mud? If you're still fuzzy on the mechanics of the game, do read this Ultimate Tic-Tac-Toe post.
AI?
It's beyond the scope of this article to go over the creation of a computer-controlled player, so this version of the game will be for two human players using the same device, though any demonstrably sentient creature can be substituted for either human.
Code It Up
Like most web apps, the code for this game will include HTML, CSS, and JavaScript, though the JavaScript will be written by the dart2js compiler so you can stick to Dart.
The HTML
The game will need very little HTML to start with, because due to its highly repetitious nature, you will create most of it dynamically using Dart.
If you're working with a local editing environment, place this code in your new project's index.html file, inside the <body>
tag. If using DartPad, simply copy and paste it directly into the HTML pane on the left side of your screen.
<div id="wrapper" class="layout vertical center fit">
<div id="toolbar" class="layout horizontal justified">
<button id="new-game-btn">New Game</button>
<div id="message"></div>
</div>
<div id="main-board" class="layout horizontal wrap"></div>
</div>
You start with a wrapper <div>
to make it easy to center things on the page, and within it you have a toolbar and the game's main board. The toolbar contains a button for starting a new game and a message area to communicate game state to the user.
The CSS
The CSS code needed for the game is a bit bulky to include directly in this article, but never fear, for I have made it available online for you. Download the file into your project and <link>
it in index.html, or for DartPad users, copy the file's contents into the CSS pane:
Using Flexbox
If you examine the project's CSS, you'll find the last several classes are shorthand for making use of the new CSS Flexbox standard. The classes are a subset of those provided by Google's awesome Polymer project, which is a JavaScript library for easily using web components. For those interested, the Dart version of Polymer is imminent at the time of this writing.
If you are working in a local development environment (not DartPad), and you would like access to the full complement of Polymer layout classes, you can include this line in the <head>
of index.html:
<link rel="import" href="https://cdn.rawgit.com/download/polymer-cdn/1.0.1/lib/iron-flex-layout/classes/iron-flex-layout.html">
I would encourage you to read more about Flexbox, Polymer, and iron-flex-layout at your leisure.
The Dart
All of the Dart code presented from now on can be placed in a single Dart file, often called main.dart if you used WebStorm or Stagehand to create your project. DartPad users can add it to the DART pane on the left side of the interface.
The Data Model
Before we start looking at Dart code, a word about how you'll model data for a Tic-Tac-Toe board. In memory, a board will be stored as a single-dimensional array (a Dart List) with nine elements, and each of those elements will be a String value. Each element's index (0-8) will represent a position on the board (pictured below), and the String value will always be "X"
, "O"
, or null
, with null
indicating an empty square on the board.
The TTTBoard Class
Since you're building a game based on Tic-Tac-Toe, you're going to start by creating a class to represent a Tic-Tac-Toe board. You'll use instances of this class to track the state of all nine little boards, and the main board too. Due to its relative simplicity, I'm just going to hit you with the whole class at once, then go over some of the highlights.
Insert this code in your Dart file, above or below the main()
function, as you prefer:
class TTTBoard {
static const List<List<int>> WIN_PATTERNS = const [
const [0, 1, 2], // row 1
const [3, 4, 5], // row 2
const [6, 7, 8], // row 3
const [0, 3, 6], // col 1
const [1, 4, 7], // col 2
const [2, 5, 8], // col 3
const [0, 4, 8], // diag 1
const [2, 4, 6] // diag 2
];
List<String> _board;
int _moveCount = 0;
TTTBoard() {
_board = new List<String>.filled(9, null);
}
String getWinner() {
for (List<int> winPattern in WIN_PATTERNS) {
String square1 = _board[winPattern[0]];
String square2 = _board[winPattern[1]];
String square3 = _board[winPattern[2]];
// if all three squares match and aren't empty, there's a win
if (square1 != null &&
square1 == square2 &&
square2 == square3) {
return square1;
}
}
// if we get here, there is no win
return null;
}
String move(int square, String player) {
_board[square] = player;
_moveCount++;
return getWinner();
}
bool get isFull => _moveCount >= 9;
bool get isNotFull => !isFull;
List<int> get emptySquares {
List<int> empties = [];
for (int i = 0; i < _board.length; i++) {
if (_board[i] == null) {
empties.add(i);
}
}
return empties;
}
String operator [](int square) => _board[square];
@override String toString() {
String prettify(int square) => _board[square] ?? " ";
return """
${prettify(0)} | ${prettify(1)} | ${prettify(2)}
${prettify(3)} | ${prettify(4)} | ${prettify(5)}
${prettify(6)} | ${prettify(7)} | ${prettify(8)}
""";
}
}
Win Patterns
The class starts off with a static List of Lists, with each of the sublists holding integer elements whose values make up a pattern of board positions that, if all held by the same player, would result in a win. All of the Lists are classified as const
, which makes them optimized compile-time constants, no different to the runtime than 42
or "a string"
. To check for a win condition, you can simply loop through these patterns, as you'll see when we examine the getWinner()
function.
The Constructor
The class's constructor, TTTBoard()
, uses the List.filled()
named constructor to initialize the private _board
variable (the underscore makes it private). The new List has nine elements, each of which starts out as null
.
We Have a Winner
As promised, the getWinner()
method loops through each of the defined win patterns. For each pattern, the occupant of those three squares is identified and a comparison is made to determine if they all contain the same value and are not empty. If a winner is found, the method returns that winner in the form of a String ("X"
or "O"
). If every pattern is scrutinized and no winner is found, null
is returned.
Make a Move
The move()
method records the move in the data model, increments a count of moves made so that you can check if the board is full, then returns the value of a getWinner()
call.
Get Some
Several getters are defined to make it convenient to make inquiries of a board instance. You may need to know if a board is full or not full, and whenever a player wants to make a move on a particular board, you'll need a List of which squares are currently empty. It may seem like overkill to define both isFull
and isNotFull,
but doing so is a Dart best practice, as it makes the code accessing those values read much better. Getters are, of course, nothing more than special class methods (functions), and some of them are defined using Dart's fat arrow syntax (=>
) to make them less verbose.
Operator Override
Dart allows you to override operators! For TTTBoard, you override the index operator ([]
) to make it easy to access a board's individual squares. With this in place, you can grab the value of square 3 with code like this:
TTTBoard myBoard = new TTTBoard();
myBoard.move(3, "X");
print("Square 3 contains: ${myBoard[3]}");
The expression myBoard[3]
works here because you've defined what the index operator should do when used on a TTTBoard. The above code uses Dart's string interpolation feature to handily print the results to the console.
Make It a String
The last method, marked with the optional @override
annotation for clarity, is a toString()
method, provided only to help in debugging the class. Every class in Dart is implicitly derived from Dart's Object
class, which defines a default toString()
method. You override that default implementation here with something that makes sense for a Tic-Tac-Toe board.
Several of Dart's best tricks are used in this method to make the code succinct. First is a nested function, prettify()
, defined with the fat arrow shorthand syntax, and it exists only to make null
values pretty; they become spaces in the output. Remember, null
in the Tic-Tac-Toe board is an empty square, but the default toString()
implementation for null
actually prints "null"
, which is a bit unsightly. One of the very handy new null-aware operators (??
) is used here to return " "
whenever _board[square]
is null
.
TTTBoard's toString()
method uses the multi-line string syntax ("""
) to construct a string in the shape of a Tic-Tac-Toe board without having to use concatenation, and string interpolation is used to insert the values at each of the board's nine positions.
Now if you need to see what's going on inside a board's data model, it's easy to show its state with a line like this:
print(myBoard);
Since the print()
function expects a string as an argument, Dart automatically calls toString()
on myBoard
in this case.
Test It
You don't have to take my word for it that this class works. If you want to give it a test drive, replace your main()
function with something like the following:
void main() {
TTTBoard testBoard = new TTTBoard();
print("Winner: ${testBoard.move(4, 'X')}");
print(testBoard);
print("Winner: ${testBoard.move(8, 'O')}");
print(testBoard);
print("Winner: ${testBoard.move(6, 'X')}");
print(testBoard);
print("Winner: ${testBoard.move(5, 'O')}");
print(testBoard);
print("Winner: ${testBoard.move(2, 'X')}");
print(testBoard);
}
Run this code and take a look at your console. The winner will be null
for the first four moves, then the last two print()
statements will produce:
Winner: X
| | X
| X | O
X | | O
This is a reenactment of that time you played Tic-Tac-Toe with your 3-year-old child/sister/niece for the first time, and she got lucky.
The Game Plan
It's important to have a good idea of how you want to structure your code before ever typing a line, so let's go over the general plan. I realize that you've already written and (hopefully) tested a Tic-Tac-Toe board class, but I'm willing to look past that if you promise not to get ahead of yourself in the future.
The game will have one instance of TTTBoard called mainBoard
. Winning that board means winning the game. Nine little boards will be stored in a List, with each little board's List index corresponding to its position on mainBoard
. If a player's move on a little board results in a win, that player's mark (X or O) will be placed into the correct square of mainBoard
.
There are many ways to handle user input for a game like this. For this project, you will prepare for each turn with a sequence like this:
- Determine which main square(s) is/are available this turn. If you've mastered the rules of the game, you'll know this is based on the little square played on the prior turn.
- With the list of available main squares in hand (note that this could be a list of just one), you will listen for click events on the empty little squares in each.
- When the user clicks one of the available squares for that turn, you will remove all of the click listeners, the move will be recorded, and if a little board has been won, the main square it occupies will be marked. If that results in a main board win, the winner will be declared, which will also serve to shame the loser.
Imports
Enough testing and planning! The real meat of this thing is just ahead, starting with importing some code from the Dart core libraries that you're going to need.
Put this code at the very top of your Dart file, above main()
:
import 'dart:html';
import 'dart:async' show StreamSubscription;
You need access to the HTML library in order to manipulate your game's user interface, which happens to take the form of HTML since this is a web app. If you come from a JavaScript background, it might make sense to think of the HTML library as Dart's jQuery.
You're only going to use one class from the async library: StreamSubscription. The show
syntax makes sure that only that class is imported into your program's namespace, and as a bonus, serves as documentation, telling you why you've included that import. StreamSubscription will be used to keep track of mouse-click listeners. You'll see that in action soon enough.
Main
The main()
function is where all Dart programs begin execution. For now, you're just going to empty it out. You'll put some code in there a bit later.
void main() {
}
I Do Declare
To execute the plan, you're going to need a few variables, several of which are directly implied in that plan.
Declare these top-level variables below the import statements, but above main()
:
TTTBoard mainBoard;
List<TTTBoard> littleBoards;
List<int> availableMainSquares;
Map<DivElement, StreamSubscription> availableLittleSquares;
String currentPlayer; // "X" or "O"
DivElement mainBoardDiv = querySelector("#main-board");
DivElement messageDiv = querySelector("#message");
You make heavy use of Dart's optional type annotations here. It's one of the language's best features and makes your intentions clear to the development environment and any human perusing the code. mainBoard
is typed as a TTTBoard, of course, and you use Dart's support for generics to declare littleBoards
as a List of TTTBoard elements.
availableMainSquares
will be a List of integers, with each integer being the position of a main square available to the player during a given turn.
A Map is a collection of key-value pairs (like an associative array or dictionary). In availableLittleSquares
, the keys will be of type DivElement (HTML <div>
elements—squares in the UI), and the values will be StreamSubscription instances (subscriptions to streams of click events).
Finally, you save the references to a few <div>
elements in the HTML that you'll need to access multiple times throughout the program. querySelector()
is a top-level function made available through the dart:html library.
A Few Helpers
Partially for convenience, and partially to keep all the DOM manipulation routines out of the game's logic code, you will now define a handful of helper functions. You can add these above or below the main()
function:
DivElement getMainSquareDiv(int mainSquare) =>
querySelector('.main-square[data-square="$mainSquare"]');
DivElement getLittleSquareDiv(int mainSquare, int littleSquare) =>
querySelector('.main-square[data-square="$mainSquare"] >'
'[data-square="$littleSquare"]');
bool toggleHighlight(DivElement squareDiv) =>
squareDiv.classes.toggle("available-square");
String markSquare(DivElement squareDiv, String player) =>
squareDiv.text = player;
String showMessage(String msg) => messageDiv.text = msg;
First, there are a couple of functions for getting references to <div>
elements representing squares on the main and little boards. The way they do so will make more sense once we've discussed the code that creates the game board.
toggleHighlight()
adds and removes the CSS class that highlights squares that are available to a player on his or her turn, markSquare()
places X or O into squares, and showMessage()
displays messages to the user via messageDiv
.
Creating the Game Board
If you were to create the game board's HTML by hand, it would look something like this:
<div class="main-square wrap layout horizontal
center center-justified" data-square="0">
<div class="little-square layout horizontal
center center-justified" data-square="0"></div>
<div class="little-square layout horizontal
center center-justified" data-square="1"></div>
<div class="little-square layout horizontal
center center-justified" data-square="2"></div>
<div class="little-square layout horizontal
center center-justified" data-square="3"></div>
<div class="little-square layout horizontal
center center-justified" data-square="4"></div>
<div class="little-square layout horizontal
center center-justified" data-square="5"></div>
<div class="little-square layout horizontal
center center-justified" data-square="6"></div>
<div class="little-square layout horizontal
center center-justified" data-square="7"></div>
<div class="little-square layout horizontal
center center-justified" data-square="8"></div>
</div>
Actually, that's just one of the board's main squares. You'd need to repeat that block nine times, incrementing data-square
for each new main square. That kind of repetition and tedium is best delegated to a computer, and that's just what you're going to do.
Add this function to your Dart file:
void createBoard() {
mainBoardDiv.children.clear();
final List<String> layout = [
"layout",
"horizontal",
"center",
"center-justified"
];
for (int mainSquare = 0; mainSquare < 9; mainSquare++) {
DivElement mainSquareDiv = new DivElement()
..classes.addAll(["main-square", "wrap"]..addAll(layout))
..attributes['data-square'] = mainSquare.toString();
mainBoardDiv.append(mainSquareDiv);
for (int littleSquare = 0; littleSquare < 9; littleSquare++) {
mainSquareDiv.append(new DivElement()
..classes.addAll(["little-square"]..addAll(layout))
..attributes['data-square'] = littleSquare.toString());
}
}
}
You start by clearing out the main board <div>
, so you're always starting from scratch. Next comes a list of CSS classes that every square (main or little) will need, layout classes from Polymer's iron-flex-layout module. The List is denoted final
to indicate it should be set only once.
The nested for
loops do all the heavy lifting here. You loop through the nine main squares, creating a <div>
for each, and within each you create nine little squares. You make use of the cascade operator (..
) to keep the quantity of code down. For every square, a <div>
is created, CSS classes are added to it, a data-square
attribute is set, and the <div>
is appended to the DOM.
"What's with all these data-square
attributes, anyway?" I'm glad you asked. Attributes prefixed with data-
can be used to store custom values on an HTML element. You use them here to make it easy to grab a reference to a particular square within the main board or any little board. The convenience functions you defined earlier, getMainSquareDiv()
and getLittleSquareDiv()
can use these attributes to find <div>
elements based on their board position.
There's a New Game in Town
Every time the players want to prove their mettle, including when the app is first run, they'll need to start a new game. There's a function for that! Well, almost....
Add this function to your Dart file:
void newGame([MouseEvent event]) {
mainBoard = new TTTBoard();
littleBoards = new List<TTTBoard>.generate(9, (_) => new TTTBoard());
currentPlayer = null;
availableMainSquares = [];
availableLittleSquares = {};
createBoard();
}
Perhaps unsurprisingly, newGame()
sets all the model data to sensible defaults, then calls createBoard()
to put some squares on the screen. The function takes one parameter, a MouseEvent, because it will often be called when a user clicks the New Game button. You surround the parameter with square brackets to indicate that it's optional; that way, you can occasionally call newGame()
without needing to pass an event. Why don't you do that now, in fact?
Update the main() function to look like this:
void main() {
querySelector("#new-game-btn").onClick.listen(newGame);
newGame();
}
In the first line, you grab a reference to the New Game button from the HTML and tell it to call newGame()
whenever it's clicked. Next, you call newGame()
yourself to start things off.
Test It Out
If you run the code right now, you should see an inert Ultimate Tic-Tac-Toe board appear. Pressing New Game will clear it out and redraw it (possibly too fast for your pathetic, organic eyes to perceive).
Turn, Turn, Turn
It wouldn't be much of a game if you didn't let players take their turns. A lot of the most important parts of the game plan we agreed on earlier will manifest in this next function.
Add this function to your Dart file:
void nextTurn([int lastLittleSquare]) {
// toggle current player
currentPlayer = currentPlayer == "X" ? "O" : "X";
showMessage("Player: $currentPlayer");
// figure out which main squares are available
if (lastLittleSquare != null &&
mainBoard[lastLittleSquare] == null &&
littleBoards[lastLittleSquare].isNotFull) {
availableMainSquares = [lastLittleSquare];
}
else {
availableMainSquares = mainBoard.emptySquares;
}
// find, save, and highlight all available little squares
for (int mainSquare in availableMainSquares) {
for (int littleSquare in littleBoards[mainSquare].emptySquares) {
DivElement squareDiv = getLittleSquareDiv(mainSquare, littleSquare);
toggleHighlight(squareDiv);
availableLittleSquares[squareDiv] = squareDiv.onClick
.listen((MouseEvent event) => move(mainSquare, littleSquare));
}
}
}
The function takes an optional parameter representing the position of the square the last player marked. This is needed to determine which of the board's main squares is available to the next player. If no value is passed, lastLittleSquare
will default to null
.
Whose turn is it, anyway?
The first section of nextTurn()
's body sets the current player to "X"
or "O"
as appropriate and displays the value to the user.
Main Squares
The second section fills out the top-level list of available main square positions. If there was a prior move and the main square correlated to that move has not yet been taken or tied, availableMainSquares
becomes a List with one element. That element will be the integer position of the main square the next player must move in next. If, according to the game's rules, the next player will not be restricted to a single main square, the list of available main squares will be all of those not yet won.
Little Squares
Next, you loop through all available main squares, and for each of those, find and highlight empty little squares. A click handler is created, which calls the move()
function (coming soon), and the subscription to the click stream is saved into the availableLittleSquares
Map, which can later be used to cancel the subscriptions.
After this function runs, all little squares that are legal moves for the player will be highlighted (using CSS), and those squares will be the only ones on the board with click event handlers attached. The user will not be able to make a move into restricted squares, because clicking those will have no effect.
Testing
Add a call to nextTurn()
at the end of the newGame()
function, right after the call to createBoard()
. This will start off the game, setting currentPlayer
to "X"
and highlighting all the little squares on the board. For now, clicking a square will attempt to call the non-existent move()
function, throwing an exception, but you'll fix that in the next section.
Your newGame()
function should now look like this:
void newGame([MouseEvent event]) {
mainBoard = new TTTBoard();
littleBoards = new List<TTTBoard>.generate(9, (_) => new TTTBoard());
currentPlayer = null;
availableMainSquares = [];
availableLittleSquares = {};
createBoard();
nextTurn();
}
Make a Move
Clicking a highlighted square results in a call to the move()
function, which takes the positions of the main square and the little square within it as arguments.
Add this function to your Dart file:
void move(int mainSquare, int littleSquare) {
// remove click listeners from last turn
availableLittleSquares
..forEach((DivElement squareDiv, StreamSubscription listener) {
toggleHighlight(squareDiv);
listener.cancel();
})
..clear();
// make the move
String littleBoardWinner =
littleBoards[mainSquare].move(littleSquare, currentPlayer);
markSquare(getLittleSquareDiv(mainSquare, littleSquare), currentPlayer);
// if there is a win on a little board, make a move on the main board
if (littleBoardWinner != null) {
String mainBoardWinner = mainBoard.move(mainSquare, littleBoardWinner);
markSquare(
getMainSquareDiv(mainSquare)..children.clear(), littleBoardWinner);
// check for win or tie on main board
if (mainBoardWinner != null) {
showMessage("Player $mainBoardWinner wins!");
return;
}
else if (mainBoard.isFull) {
showMessage("It's a tie!");
return;
}
}
nextTurn(littleSquare);
}
I'm Not Listening
In the first section, you clear out all of the click listeners and remove the highlights on the squares.
Update the Model
Each of the squares on the main board has a corresponding little board (an instance of TTTBoard), and in the second section of move()
, you call TTTBoard's move()
function to update the game's data model. That call returns a value that will be "X"
, "O"
, or null
. If it's not null
, one of the players has won that little board.
You also use the helper function markSquare()
to insert the player's mark into the DOM.
Winner!
If a player won the little board, you use the main board's move()
function to claim the main square for the player and markSquare()
to update the DOM. Note that the cascade operator (..
) is used here to clear out the main square <div>
's children (the little board) before marking the square.
Each time a player is able to make a move on the main board, you need to check if someone has won the game or caused a deadlock. In either case, a suitable message is displayed and you return
from the function, preventing another call to nextTurn()
. If nextTurn()
is not called, the game is stalled until a user presses the New Game button.
Keep Going
Lastly, if the game has not ended, you execute nextTurn()
, passing it the position of the last move, and the game keeps moving along.
Run It!
If you've made it this far, pat yourself on the back, because you're going places, unlike those pretenders who bailed out halfway through. If you've entered (or copy/pasted) all of the code correctly, you should now have a fully functioning game. Admittedly, it's not the most attractive UI in the world, but I'll leave making it pretty to you, since you're undoubtedly a world-class designer, while I am a lowly engineer.
Conclusion
I'd like to take a moment to thank Dart for getting me through this project with a minimum of hassle. At no time was my patience tested, and never was I tempted to petulantly rage quit and give up my programming career to become a male nurse. Dart can do the same for you, if you let it.