Part 2 - Building Realtime Multiplayer Games with React and Roomservice

Time to read: 19 minutes

In this blog series I am going to create a realtime multiplayer game.

Features

  • 100% HTML and CSS game running in the browser for mobile, tablet and desktop. PART 1
  • Realtime Multiplayer and Player vs. Player. PART 2
  • Authentication and login for players.
  • Lobbies for private and public games with unique shareable codes.

Part 2

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:

  • We created a board, player, and controls
  • Connect the controls to the player
  • Tested it on a number of devices to ensure it was responsively designed.

Game board and controls from part 1

Adding Multiplayer

To add multiplayer to the game we will use Room Service

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

Setting Up Room Service API

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

Adding Player Movement Tracking to the Game

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
        }}
      >
      &nbsp;
      </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:

Three screens show 3 players playing the game on tablet, desktop and mobile

Deploying the Changes

To deploy the changes to Vercel we will need to:

  • Set an environment variable of ROOM_SERVICE_API_KEY in vercel
  • Commit the code
  • Push the code to GitHub

And that is it, we have a live game, that has multiplayer's.

Adding an Objective

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.

Scoring Points

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
    }}
  >
    &nbsp;
  </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.

Adding a scoreboard

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;
  }
}

Who are these players

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:

Three players named chasing coins

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:

Two players gaming to chase the coin

Wrap Up

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.

PART 3 coming soon...