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.
socket.on("message" ... )
which takes a string message from the client and prints it into the console along with the client’s socket id for debugging purposes.socket.on("joinRoom" ... )
which will use the roomManager to check if the client was already in a room before joining. This will then add the client to the room in the room manager, as well as withsocket.join(roomCode)
letting the server bounce messages between the host and clients.socket.on("createRoom" ... )
which callsroomManager.createRoomWithRandomName();
to generate a room name, and sends the generated room code back to the host to be displayed on the screen.
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.