Building a Local Multiplayer Tower Defence Game Using React and Websockets

Concept

Playing games with friends on consoles like my Nintendo Switch is always fun, but there’s one recurring problem - there are never enough controllers for everyone. Many of my peers have shared the same frustration, so we decided to create a solution.

Our idea was to develop a game that allows the host to run it on a large screen (similar to a docked Switch), while guests can join in using their mobile phones as controllers. To make it even more accessible, we wanted the game to run entirely in the browser, eliminating the need for users to download any software. This way, everyone can participate, and there will always be enough “controllers” for everyone to join the fun.

The entire project is on GitHub linked here. A basic tutorial version of the project is here

Design

The idea is to use websockets to communicate between the game and the controllers in real time, with the server acting as a relay to pass on messages between these. For this purpose, we used socket.io.

For a fast react front end, with easy API routes we also decided to use Next.js, a framework I have thoroughly enjoyed using.

Implementation

Custom Socket.io Server

Vercel reccommend using pnpm as the package manager. We create a Next project using the default configuration more details here, and create your user interface. We can then use pnpm run dev to test the html server.

To use socket.io you can create a custom server called “server.js” in the root of the Next project. Here is a template including a room manager class in a seperate src file:

// server.js
import { createServer } from "node:http";
import next from "next";
import { Server } from "socket.io";
import { RoomManager } from "./src/roomManager.js";

const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = 3000;
// when using middleware `hostname` and `port` must be provided below
const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();

const roomManager = new RoomManager();

app.prepare().then(() => {
    const httpServer = createServer(handler);
    const io = new Server(httpServer);

    io.on("connection", (socket) => {
        socket.on("message", (message) => {
            print("message: " + message + " from: " + socket.id)
        });

        socket.on("joinRoom", ({roomId, username}) => {


            const userId = socket.id;
            const currentRoom = roomManager.getUserRoom(userId);
            if (currentRoom) {
                roomManager.removeUserFromRoom(userId, currentRoom);
                socket.leave(currentRoom);
            }

            if (roomManager.getRoom(roomId)) {
                const uIndex = roomManager.addUserToRoom(userId, roomId, username);
                socket.join(roomId);

                var users = roomManager.getUsersInRoom(roomId);
                socket.to(roomId).emit("updateUsers", users);

                socket.emit("roomJoinSuccess", {username: username});

            } else {
                socket.emit("RoomErr", "Room number " + roomId + " does not exist");
            }

        });

        socket.on("createRoom", () => {
            const roomCode = roomManager.createRoomWithRandomName();

            console.log("room created with code: ", roomCode);

            socket.join(roomCode);
            socket.emit("roomCode", {roomCode});
        });

        // these will be useful later
        socket.on("getUsers", () => {
            const users = roomManager.getUsersInRoom(roomManager.getUserRoom(socket.id));
            socket.emit("updateUsers", users);
        });

        socket.on("gameStarted", (roomCode) => {
            socket.to(roomCode).emit("gameStarted", "the game has started!");
        });
        socket.on("input_from_client_to_game", (data) => {
            socket.to(roomManager.getUserRoom(socket.id)).emit("game_input", data);
        });
    });

    httpServer
        .once("error", (err) => {
            console.error(err);
            process.exit(1);
        })
        .listen(port, () => {
            console.log(`> Ready on http://${hostname}:${port}`);
        });
});

roomManager.js:

// src/roomManager.js

// a user in room is 
// userID -> socket id
// username -> name
// the index in the list which is another way they can be identified

// Represents a room
class Room {
    // Creates a room with a given name
    constructor(name) {
        this.users = []; // list of users in the room
        this.roomName = name; // name of the room
    }
  
    // Adds a user to the room, returns the index in the room list
    addUser(userID, username) {
        const i = this.users.push({userID, username});
        return i-1;
    }
  
    swapSocketID(index, newID) {
        this.users[index].userID = newID;
    }
  
    // Removes a user from the room
    removeUser(user) {
        this.users = this.users.filter(u => u.userID !== user);
    }
  
    // Gets the list of users in the room
    getUsers() {
        // returns the {userID, username, userUserID} triples
        return this.users;
    }
}
  
class RoomManager {
    constructor() {
        // dictionary of rooms
        this.rooms = {};
    }
  
    generateRandomRoomName() {
        // random integer from 0 to 999_999
        let name = Math.floor(Math.random() * 999_999).toString();
        while (name.length < 6) {
            name = "0" + name;
        }
        return name;
    }
  
    createRoomWithRandomName() {
        var name = this.generateRandomRoomName();
        // while the name already exists
        while (this.rooms[name]) {
            // generate a new name
            name = this.generateRandomRoomName();
        }
    
        // create the room
        this.createRoom(name);
        return name;
    }
    createRoom(name) {
        if (!this.rooms[name]) {
            // create a new room with that name
            this.rooms[name] = new Room(name);
        }
    }
  
    deleteRoom(name) {
        delete this.rooms[name];
    }
  
    getRoom(name) {
        return this.rooms[name];
    }
  
    addUserToRoom(userId, roomName, username) {
        if (this.rooms[roomName]) {
            return this.rooms[roomName].addUser(userId, username);
        }
    }

  
    removeUserFromRoom(userId, roomName) {
        if (this.rooms[roomName]) {
            this.rooms[roomName].removeUser(userId);
        }
    }
  
    swapSocketID(index, roomName, newID) {
        // console.log("swapping")
        if (this.rooms[roomName]) {
            // console.log("here 200")
            const oldID = this.rooms[roomName].users[index].userID;

            if (oldID === newID) {
                return null;
            } else {
                this.rooms[roomName].swapSocketID(index, newID);
                return { oldID, newID };
            }
        }
    }
  
    getUsersInRoom(roomName) {
        if (this.rooms[roomName]) {
            // return a list of {userID, username}
            return this.rooms[roomName].getUsers();
        }
        return [];
    }
  
    getUserRoom(userId) {
        // iterate through all room names
        for (const roomName in this.rooms) {
            // if the user is in the room
            var userPairs = this.rooms[roomName].getUsers();
    
            for (const userPair of userPairs) {
                if (userPair.userID === userId) {
                    return roomName;
                }
            }
        }
        return null;
    }
}
  
export { RoomManager };

The server is using the socket.on method to accept messages from a connected client. In this case we have included the handling for 3 different messages.

Change your scripts and type in package.json to

  "type": "module",
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js",
    "lint": "next lint"
  },

Install dependencies:

pnpm install socket.io
pnpm install socket.io-client

and check this runs with

pnpm run dev

In your app/ directory add a file to initialise the websocket on the client side src/socket.ts.

"use client";

import { io } from "socket.io-client";

export const socket = io({
    transports: ['polling', 'websocket']
});

Import this into your pages

// page.tsx
"use client";

import { socket } from "../src/socket";

You can now call socket.emit to send and recieve messages using websockets.

Socket.io errors

Please note that there is a race condition when trying to emit messages automatically as soon as a page loads which may cause these errors:

[Error] WebSocket connection to 'ws://localhost:3000/socket.io/?EIO=4&transport=websocket&sid=Fw0znMAAy-XZ6Po4AAAY' failed: WebSocket is closed due to suspension.
[Error] Failed to load resource: the server responded with a status of 400 (Bad Request) (socket.io, line 0)

This is easily solvable by first emitting the message when the page loads, and then emitting the message when the socket has connected. This solves both problems where the socket may already be connected by the time the page has loaded, or may not connect until after. You will see this solution used in the app.

if (!socket.connected) {
    socket.connect();
}

// this will not work if the socket is not yet connected
socket.emit("myMessage");

// this will not run if the socket is already connected before this code is reached
socket.on("connect", () => {
    socket.emit("myMessage");
})
// This code is now consistent and the message will always be sent, however you need both of these in order to dodge the race condition.

Host and Join pages

I have included an example server and example Join and Host pages in the tutorial github repository. Here is some example code. For the scannable QR code you will need to run pnpm install qrcode.react.

The join page:

// app/join/page.tsx
"use client";

import { socket } from "../src/socket";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from 'next/navigation';
import Link from "next/link";

const JoinPageContent: React.FC = () => {
    const router = useRouter();
    const searchParams = useSearchParams();
    const [number, setNumber] = useState('');
    const [message, setMessage] = useState<string>("");
    const [username, setUsername] = useState<string>("");
    const [isSubmitted, setIsSubmitted] = useState(false);
    
    useEffect(() => {
        if (searchParams) {
            const roomCode = searchParams.get('roomCode');
            if (roomCode) {
                setNumber(roomCode);
            }
        }
      
        function onMessage(msg: string) {
            setMessage(msg);
        }

        function onRoomErr(err: string) {
            setMessage(err);
            setIsSubmitted(false);
        }

        function onSuccess(data: {username: string}) {
            router.push(`/join/room?username=${data.username}`);
        }

        socket.on("message", onMessage);
        socket.on("RoomErr", onRoomErr);
        socket.on("roomJoinSuccess", onSuccess);

        return () => {
            // Cleanup function to remove event listeners
            socket.off("message", onMessage);
            socket.off("RoomErr", onRoomErr);
            socket.off("roomJoinSuccess", onSuccess);
        };
    }, [searchParams]);

    const handleSubmit = (event: React.FormEvent) => {
        event.preventDefault();
        if (number === "") {
            setMessage("Please enter a join code");
            return;
        }
        if (username === "") {
            setMessage("Please enter a username");
            return;
        }

        if (username.length >= 20) {
            setMessage("Username too long (username should be fewer than 20 characters)");
            return;
        }

        console.log("Joining room with code:", number, username.trim());

        
        localStorage.setItem('player_username', username.trim())

        dataSender(number, username.trim());
      
        setIsSubmitted(true);
    };

    const dataSender = (code: string, username: string) => {
        try {
            const join_message = {roomId: code, username };
            socket.emit("joinRoom", join_message);
        } catch (e) {
            setMessage(e instanceof Error ? e.message : String(e));
        }

    }

    return (
        <div className="flex flex-col items-center justify-center min-h-screen p-8 bg-black text-white sm:p-20">
            <Link
                href="/"
                className="absolute top-4 right-4 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-all"
            >
                Back to Home
            </Link>
            <h1 className="text-4xl font-bold text-orange-600 drop-shadow-md">Join Code</h1>
            <form onSubmit={handleSubmit} className="flex flex-col items-center gap-4 mt-10 bg-gray-800 shadow-xl rounded-2xl p-6 border border-gray-700">
                <label className="text-lg font-medium w-full">
                    <p className="text-red-500 text-center">{message}</p>
                    <input
                        type="text"
                        value={username}
                        onChange={(e) => setUsername(e.target.value)}
                        className="mt-2 p-2 border border-gray-600 rounded-full text-white appearance-none w-full"
                        placeholder="Username"
                    />
                    <br />
                    <input
                        type="number"
                        value={number}
                        onChange={(e) => setNumber(e.target.value)}
                        className="mt-2 p-2 border border-gray-600 rounded-full text-white appearance-none w-full"
                        placeholder="Join Code"
                        onKeyDown={(e) => {
                            if (e.key === 'Enter') {
                                handleSubmit(e);
                            }
                        }}  
                    />
                </label>
                <button
                    type="submit"
                    className={`mt-6 px-4 py-2 rounded-lg shadow-md transition-all ${
                        isSubmitted ? "bg-gray-600 cursor-not-allowed" : "bg-orange-600 hover:bg-orange-700"
                    } text-white`}
                    disabled={isSubmitted}
                >
                    Submit
                </button>
            </form>
        </div>
    );
};

const JoinPage: React.FC = () => {
    return (
        <Suspense fallback={<p>Loading...</p>}>
            <JoinPageContent />
        </Suspense>
    );
};

export default JoinPage;

The join page has some html for a form to submit the room code, and the player’s username. The handle submit function does some client side validation, and then sends the code and username to the server in a message called joinRoom. There is also a handler that waits for a roomErr message to promt the user that their room code was not valid.

The API route for the host page:

// app/getip/route.ts

import { NextResponse } from 'next/server';
import { networkInterfaces } from 'os';

export async function GET() {
    try {
        const nets = networkInterfaces();
        let ipAddress = '';

        // Look for the local network IP address
        for (const name of Object.keys(nets)) {

        for (const net of nets[name] || []) {
                // Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses
                if (net.family === 'IPv4' && !net.internal) {
                ipAddress = net.address;
                break;
            }
        }
        if (ipAddress) break;
        }

        return NextResponse.json({ ip: ipAddress });
    } catch {
        return NextResponse.json(
            { error: 'Failed to get IP address' },
            { status: 500 }
        );
    }
}

This tells the host what the ip of the server is so that a qr code can be generated.

The host page:

// app/host/page.tsx
"use client";

import React, { useEffect, useState } from "react";
import { socket } from "../src/socket";
import { useRouter } from "next/navigation";
import { QRCodeSVG } from 'qrcode.react';
import Link from "next/link";

type User = { userID: string; username: string };

const HostPage = () => {
  const router = useRouter();
  const [roomCode, setRoomCode] = useState("");
  const [users, setUsers] = useState<User[]>([]);
  const [ipAddress, setIpAddress] = useState<string>("");
  const [gameStarted, setGameStarted] = useState(false);

  useEffect(() => {
    // Get IP address
    const getIPAddress = async () => {
      try {
        const response = await fetch('/api/getip');
        const data = await response.json();
        setIpAddress(data.ip);
        console.log(data.ip)
      } catch (error) {
        console.error('Failed to get IP address:', error);
      }
    };

    socket.on("connection_finished" , () => {
      socket.emit("createRoom")
    })

    if (!socket.connected) {
      socket.connect();
    } else {
      socket.emit("createRoom")
    }

    socket.emit("createRoom");

    socket.on("connect", () => {
        console.log("connect")
        socket.emit("createRoom");
    });

    getIPAddress();

    const handleRoomCode = (data: {roomCode: string}) => {
      setRoomCode(data.roomCode);
    };
    const handleUpdateUsers = (userList: User[]) => {
      setUsers(userList);
      localStorage.setItem("usersLen", userList.length.toString())
    }

    socket.on("roomCode", handleRoomCode);
    socket.on("updateUsers", handleUpdateUsers);

    return () => {
      socket.off("roomCode", handleRoomCode);
      socket.off("updateUsers", handleUpdateUsers);
    };
  }, []);

  // Create the URL for the QR code
  const joinUrl = ipAddress ? `http://${ipAddress}:3000/join?roomCode=${roomCode}` : '';

  const startGame = (): void => {
    if (users.length > 0 && !gameStarted) {
      setGameStarted(true);
      router.push("/game");
      localStorage.setItem("roomCode", roomCode);
    }
  };
  return (
    <div className="flex items-stretch justify-center min-h-screen bg-gradient-to-br from-black-900 to-black text-white p-8 gap-8">
      {/* MIDDLE */}
      <div className="flex-grow max-w-2xl bg-gray-800/95 p-8 rounded-2xl shadow-2xl border-y-8 border-orange-600 backdrop-blur-sm z-10 flex flex-col items-center justify-center">
        
        <h1 className="text-5xl font-bold text-orange-600 drop-shadow-md">Room Code</h1>

        <div className="flex flex-col items-center gap-10 w-full max-w-lg mt-10">
          {roomCode && (
            <>
              <div className="w-full text-center">
                <div className="mt-2 p-5 border border-gray-600 rounded-xl bg-gray-900 text-3xl font-semibold tracking-widest text-orange-500 shadow-sm">
                  {roomCode}
                </div>
              </div>

			  {ipAddress && (
                <div className="bg-white p-4 rounded-xl">
                  <QRCodeSVG
                    value={joinUrl}
                    size={300}
                    level="L"
                  />
                </div>
              )}
              
              <p className="text-sm text-gray-400">
                Join URL: {joinUrl}
              </p>
            </>
          )}
          <div className="flex gap-4 mt-6">
            <Link
              href="/"
              className="px-4 py-2 rounded-lg shadow-md transition-all bg-black hover:bg-orange-900 text-white text-2xl"
            >
              Back to Home
            </Link>
            <button
              className={`px-4 py-2 rounded-lg shadow-md transition-all ${
                users.length === 0 || gameStarted ? "bg-gray-600 cursor-not-allowed" : "bg-orange-600 hover:bg-orange-700"
              } text-white text-2xl`} // Further increased font size
              disabled={users.length === 0 || gameStarted}
              onClick={startGame}
            >
              Start the Game
            </button>
          </div>
        </div>
      </div>
      {/* RIGHT */}
      <div className="flex-grow max-w-2xl bg-gray-800/95 p-8 rounded-2xl shadow-2xl border-y-8 border-orange-600 backdrop-blur-sm z-10 flex flex-col items-center justify-center">
        <div className="w-full">
          <h2 className="text-2xl font-semibold text-orange-500 mb-4 text-center border-b pb-2 border-gray-600">
            Connected Users
          </h2>
          <ul className="space-y-3">
            {users.length > 0 ? (
              users.map((user, index) => (
                <li
                  key={user.userID || index} // Prefer userID if available
                  className="p-4 bg-gray-900 rounded-lg border border-gray-600 transition-all hover:bg-gray-700 shadow-sm text-center text-lg font-medium"
                >
                  <span className="text-orange-500">{user.username}</span>
                    <button
                    className="ml-2 text-red-500 hover:text-red-700"
                    onClick={() => {
                      setUsers(users.filter((u) => u.userID !== user.userID));
                      socket.emit("removeUser", {userid: user.userID, roomName: roomCode});
                    }}
                    >
                    
                    </button>
                </li>
              ))
            ) : (
              <p className="text-gray-400 text-lg text-center">Waiting for users to join...</p>
            )}
          </ul>
        </div>
      </div>
    </div>
  );
};

export default HostPage;

The host page generates a room code, and updates a list of users when one joins or leaves.

When you run the server (pnpm run dev) and navigate to http://localhost:3000/host and http://localhost:3000/join you will see that you are able to create a room with a random code, and join it showing your username. This is the first part of our system completed.

Please note that you may want to use webtokens or some other form of token to send to the host and the players in order to have some authentication, or allow them to rejoin if their websocket connection ends, and their socket id changes. This is shown on the TowerTech repository but has not been included in the tutorial.

The Joined Room Page

This is the page that is shown on the clients browser when they have joined a room.

// app/join/room/page.tsx
"use client";

import React, {useState, useEffect, Suspense} from 'react';
import { socket } from "../../src/socket";
import { useRouter } from "next/navigation";
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';

const JoinRoomPageContent = () => {
  const router = useRouter();
  const [users, setUsers] = useState<User[]>([]);
  const [inRoom, setInRoom] = useState<boolean>(true);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  type User = { userID: string, username: string };

  const searchParams = useSearchParams();
  const username = searchParams?.get('username') || "";

  
  useEffect(() => {
    // setStoredValue(username);
    // Listen for updates to the user list
    socket.on('updateUsers', (userList) => {
      setIsLoading(false);
      if (userList.length === 0) {
        setInRoom(false)
      }
      setUsers(userList);
    });
    socket.on("gameStarted", () =>{
      // route to a different page
      router.push("/game_controller");
    });

    socket.on("You have been ejected", () => {
      router.push("/join")
    });

    socket.emit("getUsers");

    // Clean up the socket connection on component unmount
    return () => {
      socket.off('updateUsers');
      socket.off("gameStarted");
      socket.off("You have been ejected")
    };
  }, []);

  return (
    <div className="flex flex-col items-center justify-center min-h-screen p-8 bg-black text-white sm:p-20">
      {isLoading ? (
        <h1 className="text-4xl font-bold text-orange-600 drop-shadow-md">Loading...</h1>
      ) : (
      <>
        {inRoom ? (
        <>
          <h1 className="text-4xl font-bold text-orange-600 drop-shadow-md">Successfully joined room</h1>
          <table className="min-w-[50%] max-w-[75%] divide-y divide-gray-600 rounded-lg overflow-hidden border border-gray-700">
            <thead className="bg-gray-800">
              <tr>
                <th scope="col" className="px-6 py-3 text-left font-medium text-orange-500">
                  Joined users
                </th>
              </tr>
            </thead>
            <tbody className="bg-gray-900 divide-y divide-gray-600">
              {users.map((user, index) => (
                <tr key={index}>
                    <td className={`px-6 py-4 whitespace-nowrap text-sm font-medium ${user.username === username ? 'text-white' : 'text-orange-500'}`}>{user.username}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </>
        ):(
          <>
            <h1 className="text-4xl font-bold text-orange-600 drop-shadow-md">You are not in a room</h1>
            <p><Link className="text-orange-600 hover:text-orange-700 underline" href='/join'>Join a room</Link> or <Link className="text-orange-600 hover:text-orange-700 underline" href='/'>go back to home</Link></p>
          </>
        )}
      </>
      )}

    </div>
  );
};

const JoinRoomPage: React.FC = () => {
  return (
      <Suspense fallback={<p>Loading...</p>}>
        <JoinRoomPageContent />
      </Suspense>
  );
};

export default JoinRoomPage;

We now have all (almost) of the front end that we need for our game.

The Game Pages

You may implement a game however you want, however for this example we will use phaser, a javaScript game engine and the same tool we used for towertech. Run the following command: pnpm install phaser You will want to create a new directory next to app/ called components/ this is where we will put our components for the game and the controller.

Game.js

Create a new file in the components directory called Game.js this will contain our basic phaser game. It looks like this:

import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { socket } from "../app/src/socket";

const Game = () => {
    const gameRef = useRef(null);
    const router = useRouter();

    useEffect(() => {
        if (!socket.connected) socket.connect();


        if (typeof window !== 'undefined') {
            import('phaser').then(Phaser => {

                socket.on("game_input", input_data);

                const config = {
                    width: 600,
                    height: 400,
                    type: Phaser.AUTO,
                    scene: {
                        preload: preload,
                        create: create,
                        update: update,
                    },
                    backgroundColor: '#6abe30',
                };

                const game = new Phaser.Game(config);
                let player;
                let keys = {
                    left: false,
                    right: false,
                    up: false,
                    down: false
                }

                return () => {
                    socket.off("game_input");
                    game.destroy(true);
                };

                function input_data(data) {
                    if (data.type === 'key_update') {
                        keys[data.key] = data.pressed;
                    }
                }

                function init_server() {
                    let roomCode = localStorage.getItem("roomCode");
                    socket.emit("gameStarted", roomCode)
                }

                function preload() {
                    this.load.image('player', 'player.png');
                }
                function create() {
                    player = this.add.sprite(200,200,'player');
                    init_server();
                }
                function update() {
                    if (keys.left) {
                        player.x -= 4;
                    }
                    if (keys.right) {
                        player.x += 4;
                    }
                    if (keys.up) {
                        player.y -= 4;
                    }
                    if (keys.down) {
                        player.y += 4;
                    }
                }
            });
        }
    }, []);

    return <div ref={gameRef} />;
};

export default Game;

Game.js uses socket.io to take handle the “game_input” message that is forwarded by the server from the controller.

Controller.js

Similarly, in the same file, you will create another new file called Controller.js like this:

import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { socket } from "../app/src/socket";

const Controller = () => {
    const gameRef = useRef(null);
    const router = useRouter();

    useEffect(() => {
        if (!socket.connected) {
            socket.connect();
        }

        if (typeof window !== 'undefined') {
            import('phaser').then(Phaser => {

                const config = {
                    width: 800,
                    height: 600,
                    type: Phaser.AUTO,
                    scene: {
                        create: create,
                    },
                    backgroundColor: '#000000',
                };

                const game = new Phaser.Game(config);

                function create() {
                    let left_button = this.add.text(50,100,'left');
                    let right_button = this.add.text(150,100,'right');
                    let up_button = this.add.text(100,50,'up');
                    let down_button = this.add.text(100,150,'down');
                    left_button.setInteractive().on('pointerdown', ()=>button_down('left')).on('pointerup', ()=>button_up('left'));
                    right_button.setInteractive().on('pointerdown', ()=>button_down('right')).on('pointerup', ()=>button_up('right'));
                    up_button.setInteractive().on('pointerdown', ()=>button_down('up')).on('pointerup', ()=>button_up('up'));
                    down_button.setInteractive().on('pointerdown', ()=>button_down('down')).on('pointerup', ()=>button_up('down'));
                }
                function button_down(key) {
                    socket.emit("input_from_client_to_game", {type:'key_update', key:key, pressed:true})
                }
                function button_up(key) {
                    socket.emit("input_from_client_to_game", {type:'key_update', key:key, pressed:false})
                }

            });
        }
    }, []);

    return <div ref={gameRef} />;
};

export default Controller;

You can now test your game by running pnpm run dev in the command line. You should be able to see the game start, and that the player moves when you click the buttons on the game controller.

Making your own game

Please feel free to use this framework to develop your own games.