TicTacToe Tutorial
What you are makingโ
Play the final version. Playing the game will help you envision what the underlying logic will look like.
Ask yourself:
- How do you define what happens when a player moves?
- How can you tell if a player won the game?
Setupโ
Generate the tictactoe tutorial locally:
npx @urturn/runner init --tutorial first-game # answer prompts
cd first-game
The UI is provided for you. Your goal is to implement the underlying logic (room functions) which determine the resulting state after any event (e.g. player move, joins, etc.).
File/Folder structureโ
game
โโโโsrc # room logic goes here
โ โ main.js # room functions (e.g. onRoomStart, onPlayerMove, etc.)
| | util.js # helper functions (e.g. evaluateBoard)
โ
โโโโfrontend # Tictactoe UI code lives here
| โ ...frontend files
| ...other files # not important for this tutorial
There are several // TODO:
statements scattered across the files, src/main.js
and src/util.js
, to help guide you.
Defining how state changesโ
Start up the game and play around.
npm run dev
The runner will immediately open a new window.
You will see a console that let's you easily debug/inspect the global state of your game.
You will also notice that if you add
, remove
a player or try to make a move
on the tictactoe board, none of the state will change. So let's fix that!
Initializing stateโ
We need to define what the initial state of the room looks like.
All state related to a room is held within the RoomState. We modify this object by returning the RoomStateResult.
The runner will automatically hot reload changes when make changes and save
Now, modify the onRoomStart
function.
Implement the TODO
statements in onRoomStart
in the file src/main.js
, and then check your work with the solution.
- TODO
- onRoomStart Solution
function onRoomStart() {
/**
* TODO: define the fields:
* state.status (hint: use Status enum)
* state.plrIdToPlrMark
* state.plrMoveIndex
* state.board
* state.winner
*/
const state = {};
return { state };
}
function onRoomStart() {
return {
state: {
status: Status.PreGame,
plrIdToPlrMark: {}, // map from plrId to their mark (X or O)
plrMoveIndex: 0, // track who's move it is
board: [
[null, null, null],
[null, null, null],
[null, null, null],
],
winner: null,
},
};
}
You should see your modifications to the initial state show up in the console!
Players joiningโ
Modify the onPlayerJoin
function to handle when a player joins.
Implement the TODO
statements in onPlayerJoin
, and then check your work with the solution.
While implementing, try adding players. Try adding 3 players to see what happens.
- TODO
- onPlayerJoin Solution
function onPlayerJoin(player, roomState) {
const { players, state } = roomState;
if (players.length === 2) { // enough players to play the game
// TODO: modify state to start the game
return {
// TODO: tell UrTurn to NOT allow anymore players in this room
// TODO: return the modified state
};
}
// still waiting on another player so make no modifications
return {};
}
function onPlayerJoin(player, roomState) {
const { players, state } = roomState;
if (players.length === 2) { // enough players to play the game
// start the game and set the player's marks
state.status = Status.InGame;
state.plrIdToPlrMark[players[0].id] = Mark.X;
state.plrIdToPlrMark[players[1].id] = Mark.O;
// return modifications we want to make to the roomState
return {
state,
// tell UrTurn to NOT allow anymore players in this room
joinable: false,
};
}
// still waiting on another player so make no modifications
return {};
}
Players leavingโ
Modify the onPlayerQuit
function to handle when a player quits.
Implement the TODO
statements in onPlayerQuit
, and then check your work with the solution.
While implementing, try adding players and then removing them. Does the console show the state you expect?
- TODO
- onPlayerQuit Solution
function onPlayerQuit(player, roomState) {
const { state, players } = roomState;
state.status = Status.EndGame;
if (players.length === 1) {
// TODO: when a player quits and there is another player, we should default the winner to
// be the remaining player
return {
// TODO: properly tell UrTurn the room is over
// (hint: modify finished, state fields)
};
}
return {
// TODO: when a player quits and there was no other player, there is no winner but we should
// properly tell UrTurn the room is over
// (hint: modify finished)
};
}
function onPlayerQuit(player, roomState) {
const { state, players } = roomState;
state.status = Status.EndGame;
if (players.length === 1) {
const [winner] = players;
state.winner = winner;
return { state, finished: true };
}
return { state, joinable: false, finished: true };
}
Helper functionsโ
Before we approach handling player moves, let's implement the helper function evaluateBoard
for determining if the game is finished and who won.
Implement the TODO
statements in evaluateBoard
, and then check your work with the solution.
Read the doc string for the function to understand what we should return!
There are many ways to implement tictactoe evaluation logic, so don't be discouraged if your implementation doesn't look exactly like ours.
- TODO
- evaluateBoard Solution
export const evaluateBoard = (board, plrIdToPlrMark, players) => {
/**
* TODO: check for a winner (hint: make sure the mark is not null)
* - check rows
* - check columns
* - check diagonals
*/
// TODO: check for tie and return correct result
// TODO: return default not finished
};
export const evaluateBoard = (board, plrIdToPlrMark, players) => {
// calculate markToPlr map
const [XPlayer, OPlayer] = plrIdToPlrMark[players[0].id] === Mark.X ? players : players.reverse();
const markToPlr = {
[Mark.X]: XPlayer,
[Mark.O]: OPlayer,
};
const winningLine = [ // all possible lines to check
// rows
[[0, 0], [0, 1], [0, 2]],
[[1, 0], [1, 1], [1, 2]],
[[2, 0], [2, 1], [2, 2]],
// columns
[[0, 0], [1, 0], [2, 0]],
[[0, 1], [1, 1], [2, 1]],
[[0, 2], [1, 2], [2, 2]],
// diagonals
[[0, 0], [1, 1], [2, 2]],
[[2, 0], [1, 1], [0, 2]],
].find((indexes) => { // find the first line that has 3-in-a-row
const [[firstI, firstJ]] = indexes;
const firstMark = board[firstI][firstJ];
const isSame = indexes.every(([i, j]) => board[i][j] === firstMark);
return firstMark != null && isSame;
});
if (winningLine != null) { // winning line was found
const [i, j] = winningLine[0];
const mark = board[i][j];
return { finished: true, winner: markToPlr[mark] };
}
// check for tie
if (!board.some((row) => row.some((mark) => mark === null))) {
return {
finished: true,
tie: true,
};
}
return { finished: false };
};
Player Movesโ
Implement the TODO
statements in onPlayerMove
, and then check your work with the solution.
- TODO
- onPlayerMove Solution
function onPlayerMove(player, move, roomState) {
const { state, players, logger } = roomState;
const { plrMoveIndex, plrIdToPlrMark } = state;
const { x, y } = move;
// TODO: validate player move and throw sensible error messages
// 1. what if a player tries to make a move when the game hasn't started?
// 2. what if a player makes a move and its not their turn?
// 3. what if a player makes a move on the board where there was already a mark?
// TODO: mark the board
// check if the game is finished
const result = evaluateBoard(state.board, plrIdToPlrMark, players);
if (result?.finished) {
// TODO: handle different cases when game is finished, using the values calculated from
// evaluateBoard() call
// hint: winner is Player type (not string)
return {
// TODO: include state modifications so UrTurn updates the state
// TODO: tell UrTurn that the room is finished, which lets UrTurn display the room correctly
};
}
// TODO: Set the plr to move to the next player (hint: update state.plrMoveIndex)
return { state };
}
function onPlayerMove(player, move, roomState) {
const { state, players } = roomState;
const { plrMoveIndex, plrIdToPlrMark } = state;
const { x, y } = move;
// validate player moves
if (state.status !== Status.InGame) {
throw new Error("game is not in progress, can't make move!");
}
if (players[plrMoveIndex].id !== player.id) {
throw new Error(`Its not this player's turn: ${player.username}`);
}
if (state.board[x][y] !== null) {
throw new Error(`Invalid move, someone already marked here: ${x},${y}`);
}
// mark the board
state.board[x][y] = plrIdToPlrMark[player.id];
// check if the game is finished
const { winner, tie, finished } = evaluateBoard(state.board, plrIdToPlrMark, players);
if (finished) {
state.status = Status.EndGame;
state.winner = winner;
state.tie = tie;
// tell UrTurn that the room is finished, which let's UrTurn index rooms properly and display
// finished rooms to players correctly
return { state, finished: true };
}
// Set the plr to move to the next player
state.plrMoveIndex = (plrMoveIndex + 1) % 2;
return { state };
}
That's it! Now try adding two players and play around with it.
What's Next?โ
You can deploy your game to UrTurn in a couple of minutes! Immediately play with random people, or create private rooms and play with close friends!
Notice that you didn't have to worry about:
- How to communicate between two players
- How to manage room creation, matchmaking, and scaling
With Urturn you get to focus on your game logic, without worrying about unnecessary infrastructure problems.
If you think you are up for the challenge. Try making a more advanced game, Chess, or start making your own game!