Tutorial
This tutorial will show how to build a multiplayer Tic-Tac-Toe game with Ravens.
#
SetupLet's create a new Javascript project and install the Ravens engine:
Create a file src/TicTacToeGame.js
. Inside it, we'll use Ravens to create our game.
#
Defining a gameIn Ravens, a game is defined by 2 concepts:
- The state of the game, which contains all the information. Ravens takes care of synchronizing this state across all the users, so they can display it in their browser.
In the case of Tic-Tac-Toe, this is the grid, with the
X
and theO
inside it. - The actions that a user can do on the state of the games. When a user wants to do an action, it sends it to the server where it is validated, and broadcasted to all the connected users so they can update their state of the game. In the case of Tic-Tac-Toe, there is only one: fill the grid with a symbol.
#
InitializationTo define a game in Ravens, create a class extending Game
:
The first thing we need to define is the initial state of our game. In this example, it will be an empty 3-by-3 grid, represented by a two-dimensional array in Javascript. To achieve this, we define the initialize
method of Game
:
caution
The state of the game must be a pure Javascript object to allow Ravens to easily serialize it. This means that it cannot contain class instances or cyclic references.
We're going to add an other element to our state: which symbol's turn it is.
#
Defining the actionsNow that the state of our game is correctly initialized, we can define the actions that users can do. To handle them, we define the applyAction
method of Game
:
This method receives two arguments:
userId
is a string and corresponds to the id of the user that performed the action.action
is a Javacript object containing a description of the action performed. We can choose the structure of this object. For this tutorial, we'll assume that an action has the following form:In this case, this action would corresponds to a user trying to fill the cell at coordinates1, 2
.
To handle the action of type fill
, we can implement the logic in the method applyAction
:
#
Handling Invalid actionsFor now, our applyAction
method accepts any move sent by the users, but we should invalidate actions that try to fill an already-filled cell. Let's implement this in applyAction
. Ravens expect that we throw an InvalidActionError
whenever applyAction
encounters an invalid move:
#
Making the UIinfo
Ravens has built-in support for React, but it can be used with any other UI framework. More information can be found in section TODO
Now that the logic has been implemented, we can work on the UI of our game. We'll use React to implement the UI of our Tic-Tac-Toe game.
Conceptually, designing an UI for a turn-based game is a simple task: we simply need to describe what the screen should look like, based on the state of the game that we have defined in the previous section.
First, let's install the libraries that we will use:
Next, let's set up a simple style sheet to format the game's grid. Create src/style.css
and add the following styling. This .css
file will be imported by the React component that we define next.
Then, create a file src/TicTacToeComponent.jsx
, and fill the render
function with an UI for our game:
#
Responding to user actionsWe already configured the src/TicTacToeGame.js
to respond to the "fill" user action. However we still need to allow for user clicks and to trigger the "fill" action in response to these clicks.
Let's update src/TicTacToeComponent.jsx
to add an onClick
callback to each <td>
table data cell.
First, add the onClick
callback to trigger a method named onCellClick
, which we will define shortly. This method takes two arguements (in addition to this
) for the position of the cell that was clicked (this, x, y
):
Now whenever the user clicks on one of the table cells, a call to onCellClick
will be triggered. Let's now define onCellClick
to issue a "fill" action using this.props.client.sendAction()
. This will trigger a Ravens action that will be handled by the applyAction
method in src/TicTacToeGame
.
Now everytime the user clicks on a cell, a "fill" action will be applied. We already defined applyAction
to respond to "fill" actions by updating the cell that was tapped and updating the turn state to reflect the next player's turn.
The only problem here is that users can click on a cell that has already been filled. (The applyAction
will handle this by issuing an exception, but this is not the best user experience. Instead, the UI should not allow invalid clicks ta all.)
To fix this, let's add logic to determine if a cell is able to be filled:
This method can be used in two places. The first is to update the UI to only display a pointer cursor when the cell is able to be filled. The second is within onCellClick
to avoid issuing a sendAction
when an invalid click occurs.
#
Launching the gameTo be able to start a game server and a client, let's create 3 files that will be the starting point of those 2 processes:
First, an index.html
:
Then, src/client.jsx
:
Finally, src/server.js
:
If you followed all the instructions of the tutorial, you should have those files in your project folder:
To launch the game server, run in a terminal:
To launch the UI, run in an other terminal:
You can now access the game by opening http://localhost:8080
. You can now play the game by clicking on the squares to make a move for each player.
#
Making it multiplayerAt the moment, our game is a bit bare-bones:
- There's not concept of players yet as you can play the full game inside a single browser. We'll improve that by making the game truly multiplayer.
- When a player wins the game, there's no message indicating who's the winner.
#
Separating into phasesThe first modification we'll be doing is separating our game into 3 phases:
- The
Lobby
phase, where the server will wait for 2 players to connect. Once 2 players are connected, the server will actually launch the game by proceeding to theGameInProgress
phase. - The
GameInProgress
phase, where the 2 players will actually play the game. When the game ends (either by a win or a draw), the server will proceed to theGameEnded
phase. - The
GameEnded
phase, which will be the final phase of the game.
Ravens allows use to model this sequence of phases by coding sub-classes of Phase
. Each sub-class of Phase
will have its own state, and will be capable of processing its own actions, specific to this phase. More information about Phases can be found about in the documentation.
Let's write the skeletons of our phases in src/TicTacToeGame.js
:
Let's breakdown what we have added here:
This line defines a class that extends the
Phase
class. This class will contain everything related to theLobbyPhase
of our gamesHere, we defined the id of our phase. This is a requirement of Ravens, and is used for serialization purposes
In the same way we did for the
LobbyPhase
, we defined theGameInProgressPhase
andGameEndedPhase
.Here, we list all the phases of our games defined earlier.
Let's now implement Tic-Tac-Toe using the phases we defined
#
Modifying TicTacToeGameWe'll first modify the class TicTacToe
that we defined earlier. We'll remove parts that
We did two things:
- In
initialize
, a line was added to initialize the initial phase of our game,LobbyPhase
. applyAction
was removed since it's the child phases that will handle the actions.
#
Implementing LobbyPhaseLobbyPhase
will wait for new users to join. When 2 players have joined, it will proceed to the GameInProgressPhase
phase:
Notice that:
- We tell Ravens to mark a user as a player using
this.addPlayer
. Conversely, we can unmark them usingthis.removePlayer
. - We can access the list of players with
this.players
. - Once 2 users have connected, we change the phase of the game using
this.parent.setchild
. Indeed,this.parent
corresponds to theTicTacToe
class that we defined. Callingthis.parent.setChild
thus replaces the current phase by the new one.
#
Implementing GameInProgressPhase#
Implementing GameEndedPhaseGameEndedPhase
will be the final phase of the game. Its job is simple: track the winner so that it can be displayed in the UI.
This phase will take a parameter during "initialization". In other words, this phase takes an argument whenever this phase is triggered via this.parent.setChild(GameEndedPhase, ...)
.
This parameter (winner
) will be the 'X' or 'O' symbol corresponding to the winning player.
Notice that:
- The
winner
is passed in to the phase via theinitialize()
parameter. - Phases can have their own internal state. For this phase, track the winner in the phase's state. This will be read by the UI code.
#
Modifying the UINow that we split our game into 3 phases, we can modify the UI:
Notice how we can use this.props.game.child instanceof XXX
to check in which phase the game is currently in, allowing us to make our UI display accordingly.
The full code can be found in the GitHub repository.
#
Launching the improved gameTo launch the game:
You can access the game by opening http://localhost:1234
.
To simulate a second player joining the game, access http://localhost:1234#2
. You can simulate more players by increasing the number after the #
.