In this blog series I am going to create a realtime multiplayer game.
Welcome back, or, welcome.
If you did miss part 1 of the game then you can grab the source code for the game from here:
Lets have a refresher anyway:
To add multiplayer to the game we will use Room Service
.env.local
for Next.JS local boot.The API can be added to the local env like this:
ROOM_SERVICE_API_KEY=<api-key>
Now, we need to add Room Service's dependencies to the application.
yarn add
@roomservice/browser
@roomservice/node
@roomservice/react
In Next.JS we can set API routes inside the pages/api
folder, so we will do just that to create an authentication endpoint for roomservice.
// ../../roomies/pages/api/roomservice.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { NextApiRequest, NextApiResponse } from "next"
const API_KEY = process.env.ROOM_SERVICE_API_KEY
// Until we have authentication, that returns a UUID, we will have a pseudo
// random number for users that join the application
function getRandomInt(min, max) {
min = Math.ceil(min)
max = Math.floor(max)
return Math.floor(Math.random() * (max - min + 1)) + min
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
const body = req.body
const user = 'some-user-' + getRandomInt(1, 20000000000)
// Authenticate with the Roomservice API
const r = await fetch('https://super.roomservice.dev/provision', {
method: 'post',
headers: {
Authorization: `Bearer: ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user: user,
resources: body.resources
})
})
const json = await r.json()
res.json(json)
}
Now we need to wrap the provider around our application, so that our react application has context that it is connected to the room service API and can update player locations for us.
Setting up the Provider in the _app.tsx
is done like so:
// ../../roomies/pages/_app.tsx
import { RoomServiceProvider } from "@roomservice/react";
import Head from 'next/head'
import '../styles/globals.css'
function MyApp({ Component, pageProps }) {
// Wrap the application in the Room Service context provider
// and declare the auth route to be the route we wrote above.
return <RoomServiceProvider clientParameters={{ auth: "/api/roomservice" }}>
<Head>
<title>ROOMIES!!!</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
</Head>
<Component {...pageProps} />
</RoomServiceProvider>
}
export default MyApp
Room service has a service known as presence, designed to track transient positions. What Room Services does is, set up a websocket on your device. It then keeps track on an Array of objects. You can specify the shape of these objects to fit your needs. I will have a set of players which have positions on the board.
Import usePresence
into our game:
import {
usePresence
} from '@roomservice/react'
And we need a model for our players, that we can code in typescript as:
export type Player = {
x: string
y: string
}
Next, we can combine the type with the hook to get a collection of players and an action we can dispatch on our local client to set the position of our own player:
const [players, setMyPlayer] =
usePresence<Player>("demo", "players");
Ok, we have everything we need to track our player in Room Service, so finally, we need to control when we send information to room service to update the player location. To do this I will implement a useEffect
hook, which will track mutations to the top and, left pieces of our state. That way, when we move locally, we will send an event to Room Service, and Room Service will send that down to everyone else that is in the game with us!
useEffect(() => {
if (!players) return
// Only store relative positions so they
// can be translated out into the height
// of the players board
const x = left / board.current.clientWidth
const y = top / board.current.clientHeight
setMyPlayer.set({
x: x.toString(),
y: y.toString(),
})
}, [left, top])
So far so good, we can look at the network tab of our local device and see what is currently happening. It could be a good idea for now to add a console log to the players object, so we can track if everything is connected correctly, and see what happens when we use multiple local devices to connect to the game.
{
"some-user-161254333": {x: "0.95725", y: "0.251486"},
"some-user-17003492921": {x: "0", y: "0"},
}
That looks good, we have an object full of players that we can render on the page.
So we can do that using react like below:
{
Object.entries(players)
.map(([key, val]) => {
return <div
key={key}
className={styles.otherPlayer}
// Translate the height back into absolute
style={{
top: parseFloat(val.y) * board.current.clientWidth,
left: parseFloat(val.x) * board.current.clientHeight
}}
>
</div>
})
}
That is everything that we need to ensure we can track the players across the multiple devices are get position updates in realtime. Lets take a look:
To deploy the changes to Vercel we will need to:
ROOM_SERVICE_API_KEY
in vercelAnd that is it, we have a live game, that has multiplayer's.
So to make the game competitive I am going to add a scoring mechanism.
To do this I will be adding a single coin to the game board. The players will need to seek out the coin, and when they collect the coin they will receive 10 points!
Roomservice enables us to set persisted items inside the current room by using the Map.
Room Service Maps are simple key-value stores with last-writer-wins resolution.
So to use the map in react we can add a hook to our index component:
const [coin, map] = useMap(
"demo",
"coin"
);
Here 'demo'
is the room name, and the key is 'coin'
.
The coin is global to everybody in the room, therefore it will need to appear in the same location for everyone that is playing the game.
The player scores a point if they reach and touch the coin. SO we need to detect if the div
that represents the player is overlapping on the another div
which will represent the coin:
useEffect(() => {
if (!coin?.position) return
const interval = setInterval(() => {
const overlap = isOverlapping(
coinElement.current,
player.current
)
if (overlap) {
const x = Math.random()
const y = Math.random()
map?.set("position", { x, y })
setScore((val) => val + 10)
}
}, 20);
return () => clearInterval(interval);
}, [map])
So this useEffect
will ensure that every 20ms we check for an overlap between the ref
to the current player and a ref
to the coinElement
. If the two are overlapping we will increment our local player score and set a new position for the coin.
So we will render the coin into the game board aswell so that it appears on all devices in the same relative position:
{
coin && board.current && <div
ref={coinElement}
className={styles.coin}
style={{
left: parseFloat(coin?.position?.x) *
board.current.clientWidth || 0,
top: parseFloat(coin?.position?.y) *
board.current.clientHeight || 0
}}
>
</div>
}
Having setup the coin, and given our players the ability to have a score I think we need a leader board so we know who is in the lead.
A scoreboard will need to display on the mobile, tablet and desktop version of the game.
The react component for the scoreboard looks like:
import { Player } from '../pages'
import styles from './ScoreBoard.module.css'
export const ScoreBoard = ({
players
}: {
players: { [key: string]: Player }
}) => (
<div className={styles.scoreBoard}>
{
Object.entries(players)
.map(([key, player]) => (
<div
className={styles.scoreCard}
key={key}
>
{player.name}: {player.score}
</div>
)
}
</div>
)
The styles for the scoreboard ensure that when we are in desktop mode the board will appear as a distinct div mounted on the left of the game board with a margin between them. Then, on mobile, the scoreboard will appear above the game board.
.scoreBoard {
display: flex;
flex-direction: column;
background-color: gainsboro;
padding: 10px;
height: 100%;
height: 95vw;
max-height: 70vh;
margin-right: 2rem;
border-radius: 1rem;
}
.scoreCard {
margin: 0.5rem;
}
@media(max-width: 1020px) {
.scoreBoard {
flex-direction: row;
flex-wrap: wrap;
width: 95vw;
max-width: 70vh;
height: 100%;
margin: 0;
border-radius: 1rem 1rem 0 0;
}
}
The final piece of the puzzle is to ensure that the players get a name. This will become something that we can store across session, but for now we have no authentication mechanism.
The name of the player will be set from a text input, with a maximum length of 16:
const [name, setName] = useState<string>('anon')
...
<input
className={styles.userName}
onChange={(e) => {
setName(e.target.value || 'anon')
}}
placeholder="Your name max (16)"
type="text"
name="username"
maxLength={16}
aria-label="Username"
autoComplete="off"
/>
This mean we have players with names in our scoreboard like below:
Now we can extend the existing position use effect hook, to also send update when the name is changed:
useEffect(() => {
if (typeof window !== 'undefined') {
if (!players || !board) return
// Only store relative positions so they can be
// translated out into the height of the players
// board
const x = left / board.current.clientWidth
const y = top / board.current.clientHeight
setMyPlayer.set({
x: x.toString(),
y: y.toString(),
name: name,
score: score
})
}
}, [left, top, name])
Now we can try out our PvP game:
That is all for Part 2, I think its a good time to take a break from the application.
If you need the full source code for the application it is here:
In the next Part we will add authentication using NextAuth.js.