Frontend Repo - https://github.com/psterckx/tictactoe-frontend
Backend Repo - https://github.com/psterckx/tictactoe-backend
Open this link https://tictactoe.psterckx.be in two separate tabs (you can also open the link on two different devices or ask a friend to play with you).
Click Start game
in both tabs. Both clients will establish connections to the backend and request a game. Then the clients will be matched with each other and a game will start.
Alternate between the tabs and try to beat yourself at tic tac toe. ๐
Once the game is over, you can either leave or click Play again
in both tabs to request another game.
An online multiplayer tic tac toe game.
I wanted to explore building an online multiplayer game that leveraged websockets. I'm also interested in serverless and I wanted to see how you could build a websocket API with AWS API Gateway and AWS Lambda.
This project uses websockets to create the real-time multiplayer game experience. Websockets are ideal for multiplayer online games since they allow the both the client and the server to send messages to each other, whereas with the HTTP protocol, client must request the information from the backend. Websockets also provide lower latency messaging than HTTP, which is another reason to use it for online games and other real-time web applications.
The backend uses a few components to coordinate the tic tac toe game.
- API Gateway WebSocket API - allows clients to connect to our API and invokes our lambda functions (more about this in a moment)
- DynamoDB table - stores the connections and games
- SQS queue - used for matching clients
- Lambda functions - connection and game management logic
Let's walkthrough what happens behind the scenes during a game.
When Start game
is clicked, the client attempts to open the websocket connection to the server. However, since our tech stack is entirely serverless, how is this accomplished?
AWS API Gateway provides a WebSocket API that allows you to invoke Lambda functions based on incoming messages from a client. API Gateway determines which lambda function to invoke based on the value of the specified route key of the JSON payload. The route key is determined by evaluating the route selection expression. For example, if the route selection expression is ${request.body.action}
and the client sends the payload {"action": "requestGame"}
, API Gateway will invoke the Lambda function corresponding to the requestGame
route.
There are also three predefined routes provided: $connect
, $disconnect
, and $default
. So when our client clicks Start game
in the UI, the client connects to the websocket API and API Gateway calls the $connect
route which invokes the Lambda function configured for that route.
We could create a lambda function for each of our routes, but to keep this project simple, I just created two lambda functions. One function, the ws-handler
handles most of the routes - clients connecting, client or server disconnecting, and in-game events (for example, when a player marks a square in our tic tac toe game). The second lambda function, called matcher
handles the requestGame
route. This lambda function matches two players together and starts new a game.
To keep things clear, I'll refer to messages from client to server as actions and messages from server to client as events. Here is the list of actions with the corresponding routes and lambda functions.
Route | Lambda function | Description |
---|---|---|
$connect |
ws-handler |
Client connects to the WebSocket API. |
$disconnect |
ws-handler |
Client or server disconnects from the WebSocket API. |
markSquare |
ws-handler |
Client marks a square on the board. |
requestGame |
matcher |
Client requests a new game. |
So back to our walk through... the client connects to our websocket API and API Gateway invokes the ws-handler
lambda function. The lambda function persists the connect details in the our DynamoDB tictactoe-table
table. We can't store the connection details in-memory because if and when the lambda environment is shutdown, all the in-memory data will be lost.
Once the connection is established, the client sends a requestGame
action over the websocket connection. API Gateway then invokes the matcher
lambda function associated with our requestGame
route. The matcher logic is pretty simple - it tries to retrieve an existing connection from our SQS queue. If it finds one, then it matches the two connections, deletes the connections from the queue, and starts a game by creating the game in our DynamoDB table and broadcasting the START_GAME
message to both clients. It also picks one of the two connections and sends the BEGIN_TURN
message to that client.
If the function does not find an existing connection in the SQS queue, then it adds the connection who requested the game to the queue and sends the event WAITING_FOR_GAME
to that client.
Let's say another client has connected and we've started a game. Now the logic is pretty simple. We wait for player 1 to mark a square, check for the win/draw condition, then if the win/draw condition is not met, we request player 2 to mark a square. Whenever a player marks their square, we also broadcast the state of the game to both players so the clients can update their UIs. Using our events and actions, the logic would flow like this.
- Server sends event
START_GAME
to both players (start) - Server sends event
BEGIN_TURN
to player 1 or 2 (alternates) - Client sends action
markSquare
from player 1 or 2 (alternates) - Check for win/draw
- If win/draw condition is not met
- Update game state in the table
- Server sends event
GAME_UPDATED
to both players - Go to step 2, alternating the player
- If win/draw condition is met
- Update game state in the table
- Server sends event
WIN
,LOSE
, orDRAW
to each player depending on outcome (end)
- If win/draw condition is not met
So for this simple game, the only events we send to the client are BEGIN_TURN
, GAME_UPDATED
, WIN
, LOSE
, and DRAW
. There is actually one more event that let's us handle what happens when a player disconnects during a game, but I'll leave that and some other implementation details for you to discover in the code. ๐
Once the game is over, clicking the Play again
button in the UI simply sends the requestGame
action to the server, and the process can repeat!
- Vue 3 (the best frontend framework)
- Tailwindcss (a CSS utility framework that makes styling a breeze)
- Built and deployed with AWS CDK (I really liked deploying applications with SAM, until I met CDK...)
- AWS Services
- API Gateway (WebSocket API)
- Lambda
- DynamoDB
- SQS
- S3 + CloudFront + Route53 for serving the frontend
More complex games will have a lot more actions and events. This tic tac toe game has a limited number of actions and events, which makes it pretty easy to manage. But you can imagine with a more complex game, say a board game like Catan, the number of actions and events would increase quickly. You'll need to keep your code organized and standardized to manage all the types of actions and events and the corresponding backend and client-side logic.
The game is a state machine. I was wondering if you could use AWS Step Functions to manage the state of the game and use their wait for callback with task token pattern to wait for messages from a client. You can actually integrate your WebSocket API routes with other AWS services besides lambda, so you could invoke an SNS topic that tells your Step Function to continue the execution. This architecture would probably involve more lambda functions and the latency would probably be higher, so this would be ideal for games that don't require fast response times like board games. However, using Step Function as your state machine could help separate components and reduce the amount of code, especially for more complex games/applications.
Are lambda functions the best choice for real-time applications using websockets? If you're building a real-time, websocket-based application and you want to optimize for performance, lambda functions probably aren't the best choice. Real-time applications using websockets usually send lots of requests back and forth between the client and server, so at some volume of traffic you'll probably have little "down time" where your lambda functions aren't being invoked and you lose the "only pay for what you use" advantage of lambda functions. With high enough volumes of traffic, the cost of the lambda function invocations could be greater than if you were to use EC2 instances or a containerized solution. Also, a large EC2 instance or a container running on a large EC2 instance would be able to provide lower latency than lambda functions. However, for smaller projects or applications where latency is of lower importance, lambda functions could be the right choice.
If you liked this, check out my other projects at petersterckx.com/projects.
Thanks! ๐ Yes, tic tac toe is quite simple but I wanted to focus on the backend architecture more than the game logic for this project. I'll probably try out a more complex game at some point.