Play with Bun and Solid.js: Create a multi-player Tic-Tac-Toe, Part1
Recently, Bun reached version 1.0 and made a splash in the JavaScript ecosystem. I wanted to try it out and thought maybe good idea to create something in combination with Solid.js, the frontend framework I wanted to play with for a long time. As a person who is crazy about performance, this combination looks very fancy as both of them claim to be very fast and efficient. So, here I will share my journey to build a full-stack multi-player Tic-Tac-Toe game with you.
I split this post into two parts. First, we will create the backend for a tic-tac-toe game to provide APIs to create a new game and play over a WebSocket connection. Then we will implement the frontend part of it.
What’s Bun?
Bun is a new JavaScript runtime. It’s a drop-in replacement for Node.js and promises higher performance, comes with a built-in package manager (which means no more discussion for using npm, yarn, or pnpm🙄), and even a bundler! But the most interesting part for me is that TypeScript is a first-class citizen of Bun, which is great, isn’t it?
So let’s get started.
Install Bun and initialize the project
Install Bun using the command on their website.
Run bun init
to create a new project using Bun. Choose a name for your project and use src/index.ts
as your entry point.
Execute command bun run src/index.ts
, and you should see this output.
Voilà🎉 We're almost done with our project (●'◡'●).
Run bun add -d bun-types
to add Typescript Bun types to the project. Now we are ready for the tic-tac-toe.
Create Tic-Tac-Tobe!
We will use Bun.serve
without any library and wrapper. We will create an REST endpoint for creating a game, and handle game communications on WebSocket.
A brief look at the architecture
We create a Game
class to represent a game. Game logic will be implemented here, it keeps the game state and handles player actions in the game (selecting a tile). To notify the WebSocket layer about events happening inside our game (game started, the game is over, etc.), we emit events from our game, so that our WebSocket layer can listen to Game events and push them to the users.
Create Game class
As said, we want to emit events from our Game
, so we extend EventEmitter
class which provides us with emit
method we can use inside Game
to emit events, and also on
method which we can use when using Game
to listen for events.
type Events = {
start: [];
over: [Player];
tileChosen: [{ player: Player; row: number; col: number }];
};
export class Game extends EventEmitter<Events> {
private turn: Player = Math.floor(Math.random() * 2);
private gameState: GameState = GameState.WAITING;
private winner: Player | null = null;
private numberOfPlayers: number = 0;
private boardState: Array<(Player | null)[]> = [
[null, null, null],
[null, null, null],
[null, null, null],
];
constructor(private readonly id: string) {
super();
}
getId(): string {
return this.id;
}
getGameState(): GameState {
return this.gameState;
}
getWinner(): Player | null {
return this.winner;
}
getTurn(): Player {
return this.turn;
}
}
Here, we pass the type parameter to EventEmitter
to define types of events and their parameters. We have the start
event which has no parameter, the over
event which has one parameter of type Player
, and the tileChoosen
event which has one parameter of the object you see in the code.
When initializing, we set turn
with a random value 0 or 1, set GameState
to waiting, winner
to null, numberOfPlayers
to 0, and boardState
to a 3x3 array which is initially empty (null) but can be chosen by Player
s
Also, we defined some getters for getting Id
, gameState
, winner
, and turn
.
We used Player
and GameState
types which are simple enums as follows:
export enum Player {
PLAYER1,
PLAYER2,
}
export enum GameState {
WAITING,
PLAYING,
OVER,
}
Game methods
In Game
class, we have a method to add a player to the game, which checks if the game has not already started and then just increments the number of players. If the player we are adding is the second player, we start the game by changing the state and emitting the start event.
addPlayer(): Player {
if (this.gameState !== GameState.WAITING) {
throw new Error("Game is already started");
}
const idx = this.numberOfPlayers;
this.numberOfPlayers += 1;
// start the game if it's second player
if (this.numberOfPlayers === 2) {
this.gameState = GameState.PLAYING;
this.emit("start");
}
return idx;
}
Then comes the time a user wants to select one of the tiles.
chooseTile(player: Player, row: number, col: number) {
if (player !== this.turn) {
throw new Error("Not this player's turn");
}
if (this.boardState[row][col] === undefined) {
throw new Error("Invalid tile address");
}
if (this.boardState[row][col] !== null) {
throw new Error("Tile is already chosen");
}
this.boardState[row][col] = player;
this.emit("tileChosen", { player, row, col });
// toggle turn
this.turn = this.turn === Player.PLAYER1 ? Player.PLAYER2 : Player.PLAYER1;
const winner = this.checkWinner();
if (winner !== null) {
this.gameState = GameState.OVER;
this.winner = winner;
this.emit("over", winner);
}
}
First, we have some validation. Is it the requester’s turn or not? Are the coordinates inside our board? Isn’t the tile already chosen?
Then we assign the tile to the user, emit the event of tileChosen
with the player and tile coordinates, and toggle turn.
Now we need to check if the game is finished with this action or not. The logic for checking the winner is implemented in another method, and if a player wins, we change the game state, set the winner, and emit over the event.
The method for checking the winner is as follows:
checkWinner(): Player | null {
// check rows
for (let row = 0; row < 3; row++) {
if (
this.boardState[row][0] === this.boardState[row][1] &&
this.boardState[row][0] === this.boardState[row][2] &&
this.boardState[row][0] !== null
) {
return this.boardState[row][0];
}
}
// check columns
for (let col = 0; col < 3; col++) {
if (
this.boardState[0][col] === this.boardState[1][col] &&
this.boardState[0][col] === this.boardState[2][col] &&
this.boardState[0][col] !== null
) {
return this.boardState[0][col];
}
}
// check diagonals
if (
this.boardState[0][0] === this.boardState[1][1] &&
this.boardState[0][0] === this.boardState[2][2] &&
this.boardState[0][0] !== null
) {
return this.boardState[0][0];
}
if (
this.boardState[0][2] === this.boardState[1][1] &&
this.boardState[0][2] === this.boardState[2][0] &&
this.boardState[0][2] !== null
) {
return this.boardState[0][2];
}
return null;
}
This is just if conditions for checking rows, columns, or diagonals are the same and a user wins the game.
Games container
To ease creating games and access to them, we create a class to just contain games, with static properties and methods, like this:
import { randomUUID } from "crypto";
import { Game } from "./Game";
export class Games {
private static games: Record<string, Game> = {};
static newGame(): Game {
const id = randomUUID();
const game = new Game(id);
this.games[id] = game;
return game;
}
static deleteGame(id: string): void {
delete this.games[id];
}
static getGame(id: string): Game {
const game = this.games[id];
if (!game) {
throw new Error("Invalid game id");
}
return game;
}
}
Now the Bun comes in…
As said earlier, we use Bun.serve
to create our HTTP and WebSocket server.
Bun.serve({
fetch(request, server) {
console.log("Request received");
},
websocket: {
open: (ws) => console.log("WebSocket connection opened", ws),
message: (ws, message) => console.log("WebSocket message received", ws, message),
},
port: 8080,
});
Pretty simple, to handle HTTP requests, we implement the fetch
method which receives requests and server objects, and we return a Response
. In websocke
property, we define methods for handling events. open
is fired when a new WebSocket connection is opened, and message
handles new message coming over WebSocket.
Fetch implementation
We want to have two endpoints for our API, /games
which accepts POST
requests and creates a new game, another /play
for upgrading to a WebSocket connection.
Inside the fetch
method, first, we parse the request url using URL class.
const url = new URL(request.url);
By that, we can check pathname
and method
for our endpoints. Inside this condition, we create a new game and create a JSON response containing the game id to send to the user. We also add Access-Control-*
headers to allow CORS.
if (url.pathname === "/games" && request.method === "POST") {
const game = Games.newGame();
const response = Response.json({ id: game.getId() }, { status: 201 });
response.headers.set("Access-Control-Allow-Origin", "*");
response.headers.set("Access-Control-Allow-Methods", "POST");
return response;
}
Upgrade WebSocket connection
Handling the WebSocket connection has two phases. First, the browser sends an HTTP request which we handle in the fetch
method, by responding with server.upgrade
, we are switching protocol and the two-way WebSocket connection will be established, so we receive websocket.open
event.
So now, let's check /play
endpoint and get gameId
from URL search (query) params, check if the game exists, and upgrade the connection to WebSocket.
if (url.pathname === "/play") {
const params = url.searchParams;
const gameId = params.get("gameId");
const game = Games.getGame(gameId ?? "");
if (!game) {
return;
}
server.upgrade(request, { data: { game, player: null } });
}
As you see, we are passing some data as a second parameter to server.upgrade
method. This data is attached to this specific WebSocket client and in WebSocket methods we can read and also mutate this data.
To make the type of this data value known to other parts of the application, we can define the type and pass it as the first type parameter to Bun.serve
, like this:
export type WebSocketData = {
game: Game;
player: Player | null;
};
Bun.serve<WebSocketData>({
...
WebSocket implementation
In the WebSocket part, we implement open
method as follows. We retrieve the game instance stored on ws.data
and check for its state. If already started we close the connection, otherwise, we listen to the game events and notify the user by ws.send
, and after that, we add the user as a player to the game.
open: (ws) => {
const { game } = ws.data;
if (game.getGameState() !== GameState.WAITING) {
ws.close(4000, "Bad state");
return;
}
// send player
ws.send(JSON.stringify({ event: "welcome", player: ws.data.player }));
// set event listeners
game.on("start", () =>
ws.send(JSON.stringify({ event: "start", turn: game.getTurn() }))
);
game.on("over", (winner) =>
ws.send(JSON.stringify({ event: "over", winner: winner }))
);
game.on("tileChosen", (data) =>
ws.send(JSON.stringify({ event: "tileChosen", ...data }))
);
// add to game
ws.data.player = game.addPlayer();
}
Everything is wired together, we just need to handle WebSocket messages and do the action on the game instance. Just parse the message and get game and player from WebSocket data. If the command is chooseTile
, perform it on the game
object. All the logic required is implemented in this method.
message: (ws, message) => {
const msg = JSON.parse(message.toString());
const { game, player } = ws.data;
if (msg.command === "chooseTile") {
game.chooseTile(player!, msg.row, msg.col);
}
},
Now run the bun run src/index.ts
and your server will be ready to accept gamers, Congratulations!
In the next part, we will implement the frontend part of this game using Solid.js and communicate with this backend. Stay tuned!
Source code
To see the final code, you can refer to this GitHub repository