Skip to main content

Networking

src/engine/net connects a game to other players: a leaderboard, a live count of who is playing, and multiplayer rooms that friends join with a code or a link. Every call works without a backend: the leaderboard lists this device's best runs, presence says 1, and a room holds only you. Set one URL and the same code goes online. You write the game once.

Everything below is exported from @/engine.

Online or not: GAME_BACKEND_URL​

src/engine/net/config.ts holds two constants:

export const GAME_BACKEND_URL: string | null = null; // the game's backend, e.g. 'https://my-game-api.example.workers.dev'
export const GAME_ID: string = gameIdFrom('__GAME_SLUG__'); // names this game's on-device data

GAME_ID is your game's slug once filled in. While it is still the template's placeholder, the engine falls back to the slug in your app.json (then expo-constants) and warns once in development; set it anyway.

Pass GAME_BACKEND_URL to every hook. With null, everything stays on the device. With a URL, the hooks talk to the game's backend: a Cloudflare Worker with one Durable Object per room, per leaderboard and for presence, and D1 for scores. Its routes come ready in the backend scaffold; see the backend overview.

PieceWithout a backendWith a backend
useLeaderboardThis device's best runs (status: 'local')Shared by every player, live with live: true
usePresence{ count: 1, status: 'local' }How many have the game open right now
useRoomA room with only you (status: 'local')A real-time room for up to the backend's MAX_PLAYERS (8 in the scaffold)
requestTurn{ ok: false, reason: 'no backend' }The computer opponent's model (see Turns)
quickMatchA fresh room code with just you (local: true)A room shared with whoever else asks in the next 45 seconds
SavesKept on the deviceSynced (see Saves)

Smallest working example​

A best score on a leaderboard and a "playing now" count. This is the shape Tap Pop uses.

import React, { useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import { GAME_BACKEND_URL, useLeaderboard, usePresence, type GameDefinition, type GameScreenProps } from '@/engine';

const Game = ({ onExit }: GameScreenProps) => {
const board = useLeaderboard(GAME_BACKEND_URL, { board: 'tap-count' });
const presence = usePresence(GAME_BACKEND_URL);
const [taps, setTaps] = useState(0);

return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}>
{presence.status !== 'local' && <Text>{presence.count} playing now</Text>}
<Text>Best: {board.you?.score ?? 0}</Text>
<Pressable onPress={() => setTaps((n) => n + 1)}>
<Text>Tap ({taps})</Text>
</Pressable>
<Pressable onPress={() => void board.submit(taps).then(() => setTaps(0))}>
<Text>Submit</Text>
</Pressable>
{board.entries.map((e) => (
<Text key={`${e.player}-${e.rank}`}>
{e.rank}. {e.name} {e.score}
</Text>
))}
<Pressable onPress={onExit}>
<Text>Exit</Text>
</Pressable>
</View>
);
};

const game: GameDefinition = { id: 'tap-count', title: 'Tap Count', description: 'Tap, then post your count.', Component: Game };
export default game;

Online, every board name you pass must also be listed in the backend's BOARDS. An unknown board answers 404 and the score stays on the device.

Leaderboards​

import { GAME_BACKEND_URL, useLeaderboard } from '@/engine';

// Inside a component:
const board = useLeaderboard(GAME_BACKEND_URL, { board: 'rooftop-hop', order: 'asc', live: true });
await board.submit(timeMs, { meta: { level: 3 } });
useLeaderboard optionDefaultWhat it does
board'global'The board's name. One game can have many: one per track, one per day (daily-2026-11-11).
limit10Entries to show. Live updates carry the top 20.
livefalseKeeps a socket open and updates the list the moment anyone posts a top score.
order'desc'Sort order on the device: 'desc' for points, 'asc' for times. Online, the backend's BOARDS decides.
enabledtruefalse stays idle.

It returns a LeaderboardView:

FieldWhat it is
entriesLeaderboardEntry[]: { rank, player, name, score, at, meta? }. player is a public id.
youYour best and rank on this board, or null.
orderThe board's order.
status'local', 'loading', 'ready' or 'error'.
errorThe last error message, or null.
submit(score, options?)Posts a score, also keeps it on the device, and refreshes. Returns a SubmitOutcome.
refresh()Fetches the list again.
ghost(player?)A best run's replay: that player's, or the board's number 1 without player.

submit options: name (defaults to the player's chosen name), meta (small JSON, up to 1 KB, shown with the entry), ghost (a replay string up to 64 KB, kept only when it is the player's new best) and order. The outcome is { ok, local, improved, best, queued?, error? }. When the network fails, the score is kept and sent with the next leaderboard call (queued: true). The device keeps only the best waiting score per board.

Ghosts. For anything raced or timed, sample your run (for example the position 10 times a second), encode it as a string and send it as ghost. Then await board.ghost() returns { board, player, name, score, ghost } for the number 1 run, and you replay it.

Outside React: fetchLeaderboard(url, query), submitScore(url, score, options), fetchGhost(url, query), connectLeaderboardLive(url, board, handlers) and flushPendingScores(url).

Presence​

import { GAME_BACKEND_URL, usePresence } from '@/engine';

// Inside a component:
const presence = usePresence(GAME_BACKEND_URL); // { count, status }

count is how many copies of the game are open right now, this one included. status is 'local' without a backend (count 1), otherwise 'connecting', 'open', 'reconnecting' or 'closed'. Hide the chip when it is 'local'. Pass { enabled: false } to stay idle. connectPresence(url, handlers) is the non-React version.

Rooms​

A room is a group of players who send each other their state many times a second, plus one-off events and a small shared state. The backend relays states as they come (relay mode), or batches them at a fixed rate (tick mode) when the game needs one authority.

import { useFrame } from '@react-three/fiber';
import { GAME_BACKEND_URL, useRoom } from '@/engine';

type CarState = { x: number; z: number; heading: number };

const room = useRoom<CarState, 'honk'>(GAME_BACKEND_URL, { roomId: code });

// Inside the scene: send your own state every frame; it goes out at sendHz.
useFrame(() => room.sendState({ x, z, heading }));

// Draw the others, interpolated about 100 ms in the past so they glide.
room.players.filter((p) => !p.isMe).map((p) => <Ghost key={p.id} get={() => room.sample(p.id)} />);

room.sendEvent('honk');
room.setRoomState({ track: 'dunes' });
useRoom optionDefaultWhat it does
roomIdrequiredThe room code, case-insensitive (stored upper-case). An empty code stays disconnected.
enabledtruefalse stays disconnected, for example until the player taps "Play with friends".
namethe player's chosen nameDisplay name in the room. Changing it renames you live.
playerIdthis device's idOverride for tests.
spectatefalseWatch without a seat: receive everything, send no state.
sendHz15How often sendState really sends, from 1 to 30. The latest state wins.
interpolationDelayMs100 ms, or two ticks in tick modeHow far in the past sample draws remote players.
onMessagenoneEvery server message. Kept in a ref, so an inline function is fine.

It returns a RoomView:

FieldWhat it is
status'idle', 'local', 'connecting', 'open', 'reconnecting', 'full', 'replaced' or 'closed'. full and replaced stay closed.
meYour public id in the room, or null until welcomed.
playersEveryone, you included, each with isMe. Re-renders on join, leave and rename, not on every state.
roomStateThe shared state.
presence{ count, spectators } in this room.
rttRound trip in ms, or null.
connectionThe live RoomConnection, for per-frame reads in useFrame. null while disabled.
sendState(state)Queues your latest state.
sendEvent(name, data?, { echo? })A one-off happening to the others (echo sends it back to you too). Queued while reconnecting, up to 32.
setRoomState(patch)Shallow-merges into the shared state. null deletes a key.
sample(id, lerp?)A player's interpolated state for this frame.

Messages are JSON and at most 4 KB each; the shared room state is at most 8 KB. sample blends numbers, arrays and objects with lerpState. For an angle, write your own lerp that uses lerpAngle, which goes the short way round.

connectRoom(url, options) is the non-React version. It also has serverNow() (the server's clock, estimated), on(listener) and subscribe(listener). StateBuffer, lerpState, lerpAngle and ReconnectingSocket are exported for your own netcode.

Who decides. In relay mode every device trusts the others. When an outcome must be fair (hits, tags, who grabbed the flag), either run the rules on the backend in tick mode or let one device host the match, as the shooter module does: the oldest player's device runs the match and the others follow its snapshots.

Quick match. quickMatch(GAME_BACKEND_URL, { size: 4 }) asks the backend's POST /match for a room: the same code for everyone who asks within 45 seconds, until size players (2 to 16, default 4) have it. Join the code with useRoom.

import { quickMatch } from '@/engine';

const match = await quickMatch(GAME_BACKEND_URL, { size: 2 });
if (match.ok) setRoomId(match.roomId); // { ok, local, roomId, seat, size }
else showCodeToShare(match.reason); // offline, slow or refused

It never throws. Without a backend it answers a fresh code with just this player (local: true), like every other net call; offline or on an error it answers { ok: false, reason } (timeoutMs defaults to 8000).

import { makeRoomCode, roomInviteUrl, useLaunchRoomId } from '@/engine';

const launchRoom = useLaunchRoomId(); // the code from a ?room=CODE link, or null
const code = launchRoom ?? makeRoomCode(); // e.g. "K7QXM"
const link = roomInviteUrl(code); // the share link with ?room=CODE, or null
FunctionWhat it does
makeRoomCode(length?)A fresh code, 5 characters by default, without look-alike characters (no 0, O, 1, I or L).
roomFromUrl(url)The room parameter of a link (query or fragment), upper-cased, or null.
useLaunchRoomId()The room the game was opened into. It also reads React Native's initial URL and links that arrive while the game is open.
getLaunchRoomId(), getLaunchUrl()The same, read once, from the link the app passed the game.
getShareUrl()The game's public share link, if the app around the game knows it.
roomInviteUrl(code)The share link with ?room=CODE, or null when the share link is unknown.

The game's share link and launch link come from the app that hosts the game. Inside the Moonspell app (and its web player) the app passes them in. In a browser the engine reads them from the page's query string: getShareUrl() returns the share parameter and getLaunchUrl() the launch parameter, so a plain browser tab with no ?share= gets null. In your own Expo build on a phone there is no shell module, so getShareUrl() and roomInviteUrl() always return null. When they do, share the code itself as a fallback, as the shooter module does:

import { Share } from 'react-native';
import { roomInviteUrl } from '@/engine';

const url = roomInviteUrl(code);
await Share.share(url ? { message: `Join my match: ${url}`, url } : { message: `Join my match with code ${code}` });

useLaunchRoomId() still picks up ?room=CODE from the page URL on web, and from a deep link on phones if your build has a URL scheme.

Player identity​

FunctionWhat it does
getPlayerId()This device's secret player id, created on first use. Send it only to the backend, never show it. The backend hashes it into the public id others see.
getPlayerName(), setPlayerName(name)The name the player chose, or null. setPlayerName cleans it (no control characters, single spaces, at most 20 characters).
usePlayerName()[name or null, setName], re-rendering when any component changes the name.
defaultPlayerName(), displayName()"Player 4821", stable per device, and the chosen name or that default.
cleanPlayerName(raw)The same cleaning rules as the backend.
hasBackend(url)Whether a URL is set and looks like http(s) or ws(s).

Gotchas​

  • List every board on the backend. Each board your game passes must be in the backend's BOARDS, by name or as a family ("daily-*" covers daily-2026-11-11). Otherwise scores stay on the device and the list stays empty.
  • One code path. Do not branch on GAME_BACKEND_URL for gameplay. Branch only on what you show: hide presence at 'local', hide the invite button when hasBackend(GAME_BACKEND_URL) is false.
  • Send state, not events, for movement. sendState is throttled and only the latest one goes out. sendEvent is for things that happen once.
  • Read per-frame data from room.connection inside useFrame, not from room.players, which only re-renders on joins and leaves.
  • Rooms are live. A room exists while players are connected. Turns across days need their own storage on the backend.
  • Never point a shipped build at a local backend. http://127.0.0.1:8787 is for testing with wrangler dev only.

See also​

  • Saves and progression: the same player id names the player's saves.
  • Turns: useTurnRoom builds online turn-based play on useRoom.
  • Shooter module: host-runs-the-match multiplayer on relay rooms.
  • Examples: Tap Pop (a leaderboard), Moon Chess (rooms from a link), Flag Rush (rooms and invites).
  • Backend overview: the Worker, BOARDS, room modes and how to deploy.