Play with Bun and Solid.js: Create a multi-player Tic-Tac-Toe, Part1

Photo by Jon Tyson on Unsplash

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 Players

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