Build a Real-Time Game
This guide walks through building a real-time multiplayer game. You'll define game events, set up player-specific pipes, build a lobby system, sync player actions, publish server-authoritative state, and handle disconnections gracefully.
A game needs events for player actions and server-authoritative state. Player events are lightweight and frequent — just a position update. The game.state event is heavier: the full game state published by the server at a regular tick rate.
// lib/realtime-events.ts
import { z } from 'zod';
export const realtimeEvents = {
'player.moved': z.object({
playerId: z.string(),
x: z.number(),
y: z.number(),
}),
'player.joined': z.object({
playerId: z.string(),
playerName: z.string(),
}),
'player.left': z.object({
playerId: z.string(),
}),
'game.state': z.object({
players: z.array(z.object({
id: z.string(),
name: z.string(),
x: z.number(),
y: z.number(),
score: z.number(),
})),
round: z.number(),
status: z.enum(['waiting', 'playing', 'finished']),
}),
} as const;Each game lobby gets its own pipe. Players can subscribe and publish to the lobby pipe (for movement and join/leave events). Each player also gets a private pipe for player-specific events like targeted notifications or kick messages.
// app/api/realtime/[...all]/route.ts
import { createPipeHandler } from 'hotpipe/server';
export const { POST } = createPipeHandler({
secret: process.env.HOTPIPE_SECRET!,
authorize: async (req) => {
const { session } = await getAuth(req);
if (!session) return null;
const lobby = await getActiveLobby(session.userId);
if (!lobby) return null;
return {
userId: session.userId,
pipes: {
[`lobby-${lobby.id}`]: { subscribe: true, publish: true },
[`player-${session.userId}`]: { subscribe: true },
},
};
},
});The lobby subscribes to join and leave events to show who's in the game. When a player connects, your server publishes a player.joined event. When they disconnect or quit, it publishes player.left.
function GameLobby({ lobbyId }: { lobbyId: string }) {
const [players, setPlayers] = useState<{ id: string; name: string }[]>([]);
usePipe(`lobby-${lobbyId}`, {
'player.joined': (data) => {
setPlayers((prev) => [...prev, { id: data.playerId, name: data.playerName }]);
},
'player.left': (data) => {
setPlayers((prev) => prev.filter((p) => p.id !== data.playerId));
},
});
return (
<div>
<h2>Lobby</h2>
<ul>
{players.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}Players publish movement events directly from the client. Since the lobby pipe has publish permissions, every player's movement broadcasts to all other players in the lobby instantly. This gives you the lowest possible latency for player-to-player actions.
function GameCanvas({ lobbyId }: { lobbyId: string }) {
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(new Map());
const { publish } = usePipe(`lobby-${lobbyId}`, {
'player.moved': (data) => {
setPositions((prev) => new Map(prev).set(data.playerId, { x: data.x, y: data.y }));
},
});
function handleKeyDown(e: KeyboardEvent) {
const delta = { x: 0, y: 0 };
if (e.key === 'ArrowUp') delta.y = -1;
if (e.key === 'ArrowDown') delta.y = 1;
if (e.key === 'ArrowLeft') delta.x = -1;
if (e.key === 'ArrowRight') delta.x = 1;
const current = positions.get(currentPlayer.id) ?? { x: 0, y: 0 };
publish('player.moved', {
playerId: currentPlayer.id,
x: current.x + delta.x,
y: current.y + delta.y,
});
}
// ... render players at their positions
}Client-published movement is fast but untrusted. For the canonical game state — scores, round progression, collision detection — publish from the server. Run a game loop on your server that computes the authoritative state and broadcasts it at a fixed tick rate.
// lib/realtime-server.ts
import { realtime } from '@/lib/realtime-server';
async function tickGameState(lobbyId: string) {
const state = await computeGameState(lobbyId);
await realtime.pipe(`lobby-${lobbyId}`).publish('game.state', {
players: state.players,
round: state.round,
status: state.status,
});
}On the client, use both event types. Apply player.moved events immediately for smooth visuals, then snap to the authoritative game.state when it arrives from the server.
function GameCanvas({ lobbyId }: { lobbyId: string }) {
const [gameState, setGameState] = useState<GameState | null>(null);
usePipe(`lobby-${lobbyId}`, {
'game.state': (data) => {
setGameState(data);
},
'player.moved': (data) => {
// optimistic update between server ticks
setGameState((prev) => {
if (!prev) return prev;
return {
...prev,
players: prev.players.map((p) =>
p.id === data.playerId ? { ...p, x: data.x, y: data.y } : p
),
};
});
},
});
if (!gameState) return <div>Waiting for game state...</div>;
return (
<div>
{gameState.players.map((p) => (
<div key={p.id} style={{ position: 'absolute', left: p.x * 32, top: p.y * 32 }}>
{p.name}
</div>
))}
</div>
);
}When a player reconnects after a brief drop, they need to catch up to the current game state. Watch the status field and fetch the latest server state when the connection comes back. The next game.state tick will also bring them current, but fetching immediately avoids a stale frame.
function GameCanvas({ lobbyId }: { lobbyId: string }) {
const [gameState, setGameState] = useState<GameState | null>(null);
const { status } = usePipe(`lobby-${lobbyId}`, {
'game.state': (data) => setGameState(data),
'player.moved': (data) => { /* ... */ },
});
const prevStatus = useRef(status);
useEffect(() => {
if (prevStatus.current === 'reconnecting' && status === 'connected') {
// fetch the latest server-authoritative state to sync back up
fetchGameState(lobbyId).then(setGameState);
}
prevStatus.current = status;
}, [status]);
// ... render
}
