Build a Multiplayer Game

A complete game of tag, from an empty file to two people playing it

The other pages describe the pieces. This one assembles them into a game you can actually play with somebody: everyone moves a blob, one player is it, and touching them makes you it instead.

It is short enough to read in one sitting and exercises everything a networked game needs: who owns what, how often to send, how to catch up a late arrival, and how to test it with two players.

Start from /new, which publishes a starter and opens the editor on it. Replace index.tsx as you go.

1. Decide who owns what first

Before any code. This is the decision the rest follows from, and changing it later means rewriting the game.

Owned by
Where my blob is Me
That I have been tagged Me
Who is it The host
The round clock The host

Every client owns itself and nothing else, and the host owns what belongs to nobody. See Rooms for why that split is what makes a relay safe.

Note what is not on the list: nobody can tag anybody. A player who touches the current "it" reports that they became it. That is a statement about themselves, so it is one everybody can believe.

2. The rules, with no screen in them

Put the rules in their own file. They are then testable without rendering anything, and readable by whoever forks the game.

// tag.ts
export const ARENA = { width: 900, height: 560 }
export const BLOB = 22
export const SPEED = 260
/** How long after being tagged before you can tag back. */
export const IMMUNITY = 1.2

export interface Blob { x: number; y: number }

export function move(blob: Blob, dx: number, dy: number, dt: number): void {
    const length = Math.hypot(dx, dy) || 1
    blob.x += (dx / length) * SPEED * dt
    blob.y += (dy / length) * SPEED * dt
    blob.x = Math.min(ARENA.width - BLOB, Math.max(BLOB, blob.x))
    blob.y = Math.min(ARENA.height - BLOB, Math.max(BLOB, blob.y))
}

export function touching(a: Blob, b: Blob): boolean {
    return Math.hypot(a.x - b.x, a.y - b.y) < BLOB * 2
}

3. Join the room

import { useRef, useState } from "react"
import { View, Text, mount, useFrame, useRoom, input } from "oj"
import { ARENA, BLOB, IMMUNITY, move, touching, type Blob } from "./tag"

function Game() {
    const me = useRef<Blob>({ x: 200, y: 280 })
    const peers = useRef(new Map<number, Blob>())
    const it = useRef<number | null>(null)
    const taggedAt = useRef(-99)
    const clock = useRef(0)

    const room = useRoom("arena", {
        onLeave: (id) => peers.current.delete(id),
    })

useRoom connects for as long as the component is mounted and reconnects on its own. Room names are per game, so "arena" here cannot collide with another game's "arena".

4. Receive

Three messages, and each one is believed by a different rule.

    const room = useRoom("arena", {
        onLeave: (id) => peers.current.delete(id),

        onMessage: (from, data: any) => {
            // Believed about themselves: this is where they say they are.
            if (data.k === "at") {
                const blob = peers.current.get(from) ?? { x: data.x, y: data.y }
                blob.x = data.x
                blob.y = data.y
                peers.current.set(from, blob)
                return
            }

            // A claim, not a fact. Only the host acts on it.
            if (data.k === "got-me" && room.isHost) {
                it.current = from
                room.send({ k: "it", who: from })
                return
            }

            // The host said who is it. Believed only from the host.
            if (data.k === "it" && from === room.hostId) {
                it.current = data.who
                if (data.who === room.id) taggedAt.current = clock.current
                return
            }

            // A late arrival wants the state. The host answers.
            if (data.k === "hello" && room.isHost) {
                room.send({ k: "it", who: it.current }, from)
            }
        },

        onOpen: () => {
            if (room.isHost) it.current = room.id   // alone, so I am it
            else room.send({ k: "hello" })
        },
    })

Two things worth pausing on.

got-me goes to the host rather than being acted on directly. A player reporting that they were tagged is telling the truth about themselves, but two players can report it in the same instant, and then every client picks a different winner. The host resolving it means everyone ends up with the same answer, even though the claim itself was honest.

it is only believed from === room.hostId. Without that check, any client could broadcast { k: "it", who: someoneElse } and move the game. The Rooms page has the general form of this.

The reply to hello uses room.send(data, from), which sends to that one peer instead of everybody.

5. The frame loop

    const sinceSend = useRef(0)

    useFrame((dt) => {
        const step = Math.min(dt, 1 / 20)
        clock.current += step

        // Move myself. Nobody else may.
        const dir = input.keyboard.wasd()
        move(me.current, dir.x, -dir.y, step)

        // Am I touching the player who is it? Then I am it now, and I say so.
        const amIt = it.current === room.id
        const canBeTagged = clock.current - taggedAt.current > IMMUNITY
        if (!amIt && canBeTagged && it.current !== null) {
            const chaser = peers.current.get(it.current)
            if (chaser && touching(me.current, chaser)) {
                taggedAt.current = clock.current
                it.current = room.id
                room.send({ k: "got-me" })
            }
        }

        // Position, fifteen times a second rather than sixty.
        sinceSend.current -= step
        if (sinceSend.current <= 0) {
            sinceSend.current = 1 / 15
            room.send({ k: "at", x: Math.round(me.current.x), y: Math.round(me.current.y) })
        }
    })

-dir.y because wasd() puts positive y upward, Unity-style, while the stage grows downward.

The tag check runs on the frame it happens and applies locally straight away, before the host has confirmed anything. That is what makes it feel immediate. The host's it message arrives a moment later and agrees, because the claim was honest.

6. Draw

Blobs are a handful of elements that move every frame, so drive them through refs rather than React state.

    const dots = useRef(new Map<number, any>())

    return (
        <View style={{ flexGrow: 1, backgroundColor: "#14181d" }}>
            <Text style={{ color: "#8fa", margin: 8 }}>
                {room.connected ? `${room.peers.length + 1} playing` : "connecting"}
            </Text>
            <View
                ref={(el) => dots.current.set(room.id, el)}
                style={{
                    position: "absolute", width: BLOB * 2, height: BLOB * 2,
                    borderRadius: BLOB, backgroundColor: "#ffd166",
                }}
            />
        </View>
    )
}

mount(<Game />)

Write positions in useFrame, after the movement:

        const mine = dots.current.get(room.id)
        if (mine) {
            mine.style.left = me.current.x - BLOB
            mine.style.top = me.current.y - BLOB
            mine.style.backgroundColor = amIt ? "#ff5d5d" : "#ffd166"
        }

For many blobs, or for anything arcade-shaped, draw with the batched painter instead: one crossing for the whole scene rather than one per element per property.

7. Play it with somebody

Save. The preview reloads with the new build.

To test two players, open the game's URL in two separate browser windows. Each window is a peer, so the count goes to 2 and one blob chases the other. Two windows of one browser is the minimum; two different browsers, or one window and one private window, is better, because they get separate sessions.

Do not use two tabs of the same window. A background tab has its animation frames throttled, so the hidden player runs at a crawl and its clock falls behind. What you see then looks exactly like broken synchronisation and is nothing of the kind. Bear in mind too that each instance is a real Unity build: two on one machine is a heavy load, and a slow second player may be your computer rather than your code.

That is also the only way to catch the class of bug this page is shaped to avoid. A game that works alone tells you nothing about a game with two people in it: the host path, the late-arrival path and the tag race only exist once somebody else is there.

Things worth trying deliberately:

  • Close the host's window. Someone else is promoted immediately and the game keeps going. Nothing you wrote does that: the room names the new host in the same message that reports the departure.
  • Join late. The second window should learn who is it immediately, from the hello exchange.
  • Tag at the same moment from both windows. Both clients end up agreeing, because the host picked.

8. When there is nobody to play with

Two states a multiplayer game has to render, and neither is an error:

if (!room.connected) return <Text>Reconnecting...</Text>

Not connected happens while the site is unreachable, and also when the room is full. A room holds 24 players; beyond that the connection is refused and retried with a backoff. The game cannot currently tell those two cases apart, so word it as "cannot get in right now" rather than guessing.

Alone in the room is not a failure at all. room.isHost is true when you are by yourself, so the game runs, you are it, and nothing waits for a peer who never arrives. That is why the code above needs no separate single-player path.

9. Finish it

Add a leaderboard for time spent not being it, ship a sound for the tag, and publish. Anyone can then read this source, fork it, and open it in Unity, where the room degrades to a no-op and the game runs alone.

Rooms The full API, the limits, and when a relay is the wrong tool
The Stage Fitting an arena to somebody else's window
Input Keyboard, pointer, gamepad and touch
Leaderboards Scores, and how far to trust them