Files

145 lines
4.7 KiB
Markdown

# React room binding (optional — copy if it fits your app)
For the common "everyone on this app plays together" shape, copy this hook to
`src/lib/multiplayer/use-p2p-room.ts` and adapt it freely — it is yours, not
part of the kit. It captures `room`/`name` on first render, so changing them
later requires remounting the component (key it on the room code).
```ts
/**
* React binding for P2PRoom. Identity and room id are captured once on mount
* (useState initializers) so re-renders never tear down the mesh: the P2PRoom
* instance lives exactly as long as the component that mounted it, and
* changing `room`/`name` requires a remount (key the component on them).
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { P2PRoom, type PeerInfo } from "./p2p";
export interface UseP2PRoomOptions {
/** Defaults to a per-deployment room derived from the hostname. */
room?: string;
name?: string;
}
export interface P2PRoomHandle {
selfId: string;
room: string;
/** Remote peers only (self excluded), with live connection diagnostics. */
peers: PeerInfo[];
joined: boolean;
/** Unreliable game-state fanout to every connected peer. */
broadcast: (data: unknown) => void;
/** Reliable ordered send to one peer (or all when peerId is omitted). */
send: (data: unknown, peerId?: string) => void;
/** Subscribe to incoming messages; returns an unsubscribe function. */
onMessage: (
fn: (from: string, data: unknown, channel: "state" | "reliable") => void,
) => () => void;
}
function defaultRoom(): string {
if (typeof window === "undefined") return "room-ssr";
// DNS labels can be 63 chars; the signaling ID regex caps room ids at 64 —
// truncate so `room-` + label always fits.
return `room-${window.location.hostname.split(".")[0]}`.slice(0, 64);
}
export function useP2PRoom(options: UseP2PRoomOptions = {}): P2PRoomHandle {
const [selfId] = useState(() => `p-${Math.random().toString(36).slice(2, 10)}`);
const [room] = useState(() => options.room ?? defaultRoom());
const [name] = useState(() => options.name ?? selfId);
const [peers, setPeers] = useState<PeerInfo[]>([]);
const [joined, setJoined] = useState(false);
const roomRef = useRef<P2PRoom | null>(null);
const listeners = useRef(
new Set<(from: string, data: unknown, channel: "state" | "reliable") => void>(),
);
useEffect(() => {
const p2p = new P2PRoom({
room,
selfId,
name,
onPeersChanged: setPeers,
onMessage: (from, data, channel) => {
for (const fn of listeners.current) fn(from, data, channel);
},
// `joined` flips on the FIRST successful poll — join() itself resolves
// even when the first poll fails (the loop keeps retrying).
onConnected: () => setJoined(true),
});
roomRef.current = p2p;
void p2p.join();
return () => {
roomRef.current = null;
p2p.close();
};
}, [room, selfId, name]);
// Stable identities (both close over refs) so consumers can safely list
// these in effect deps without re-subscribing every render.
const broadcast = useCallback((data: unknown) => roomRef.current?.broadcast(data), []);
const send = useCallback(
(data: unknown, peerId?: string) => roomRef.current?.send(data, peerId),
[],
);
const onMessage = useCallback(
(fn: (from: string, data: unknown, channel: "state" | "reliable") => void) => {
listeners.current.add(fn);
return () => {
listeners.current.delete(fn);
};
},
[],
);
return { selfId, room, peers, joined, broadcast, send, onMessage };
}
```
Used in a component:
```tsx
import { useP2PRoom } from "@/lib/multiplayer";
function Game() {
const p2p = useP2PRoom({ name: "ani" }); // room defaults per deployment
const [positions, setPositions] = useState<Record<string, Pos>>({});
useEffect(
() =>
p2p.onMessage((from, data, channel) => {
if (channel === "state") {
setPositions((p) => ({ ...p, [from]: data as Pos }));
}
}),
[p2p.onMessage],
);
// Game-rate state: broadcast on the unreliable channel (stale packets drop).
// 20-30 sends/s is plenty; interpolate between updates for smooth motion.
useEffect(() => {
let raf = 0;
let lastSent = 0;
const loop = (now: number) => {
if (now - lastSent >= 50) {
// ~20 sends/s
p2p.broadcast(myPositionRef.current);
lastSent = now;
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [p2p.broadcast]);
// One-shot actions (chat, "start game"): reliable + ordered.
const sendChat = (text: string) => p2p.send({ chat: text });
return <Board peers={p2p.peers} positions={positions} />;
}
```