Skip to content

Commit acae2a8

Browse files
committed
useRoomMessage(), useLobbyRoom(), useQueueRoom(), createLobbyContext(). closes #7
1 parent 27a7209 commit acae2a8

File tree

10 files changed

+480
-9
lines changed

10 files changed

+480
-9
lines changed

README.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,108 @@ const players = useRoomState(room, (state) => state.players);
8383

8484
Only components that read `players` will re-render when the players map changes.
8585

86+
### `useRoomMessage(room, type, callback)`
87+
88+
Subscribes to Colyseus room messages. The callback is kept in a ref so it is always up-to-date without re-subscribing. Automatically unsubscribes when the room changes or the component unmounts.
89+
90+
```tsx
91+
import { useRoom, useRoomMessage } from "@colyseus/react";
92+
93+
function Chat() {
94+
const { room } = useRoom(() => client.joinOrCreate("game_room"));
95+
const [messages, setMessages] = useState<string[]>([]);
96+
97+
useRoomMessage(room, "chat", (message) => {
98+
setMessages((prev) => [...prev, message]);
99+
});
100+
101+
return (
102+
<ul>
103+
{messages.map((msg, i) => <li key={i}>{msg}</li>)}
104+
</ul>
105+
);
106+
}
107+
```
108+
109+
Pass `"*"` as the type to listen to all message types.
110+
111+
### `useLobbyRoom(callback, deps?)`
112+
113+
Connects to a Colyseus [Lobby Room](https://docs.colyseus.io/builtin-rooms/lobby/) and provides a live-updating list of available rooms. The list is automatically maintained as rooms are created, updated, and removed.
114+
115+
```tsx
116+
import { Client } from "@colyseus/sdk";
117+
import { useLobbyRoom } from "@colyseus/react";
118+
119+
const client = new Client("ws://localhost:2567");
120+
121+
function Lobby() {
122+
const { rooms, error, isConnecting } = useLobbyRoom(
123+
() => client.joinOrCreate("lobby"),
124+
);
125+
126+
if (isConnecting) return <p>Connecting...</p>;
127+
if (error) return <p>Error: {error.message}</p>;
128+
129+
return (
130+
<ul>
131+
{rooms.map((room) => (
132+
<li key={room.roomId}>
133+
{room.name}{room.clients}/{room.maxClients} players
134+
</li>
135+
))}
136+
</ul>
137+
);
138+
}
139+
```
140+
141+
**Return value:**
142+
143+
| Field | Type | Description |
144+
|---|---|---|
145+
| `rooms` | `RoomAvailable<Metadata>[]` | Live list of available rooms |
146+
| `room` | `Room \| undefined` | The underlying lobby room connection |
147+
| `error` | `Error \| undefined` | Connection error, if any |
148+
| `isConnecting` | `boolean` | `true` while connecting to the lobby |
149+
150+
### `useQueueRoom(connect, consume, deps?)`
151+
152+
Manages the full lifecycle of a Colyseus matchmaking queue: connecting to the queue room, tracking group size, receiving a seat reservation, confirming, and consuming the seat to join the match room. Cleans up both rooms on unmount.
153+
154+
```tsx
155+
import { Client } from "@colyseus/sdk";
156+
import { useQueueRoom } from "@colyseus/react";
157+
158+
const client = new Client("ws://localhost:2567");
159+
160+
function Matchmaking() {
161+
const { room, clients, isWaiting, error } = useQueueRoom(
162+
() => client.joinOrCreate("queue", { rank: 1200 }),
163+
(reservation) => client.consumeSeatReservation(reservation),
164+
);
165+
166+
if (error) return <p>Error: {error.message}</p>;
167+
if (room) return <GameScreen room={room} />;
168+
if (isWaiting) return <p>Waiting for match... {clients} players in group</p>;
169+
return <p>Connecting...</p>;
170+
}
171+
```
172+
173+
The first argument connects to the queue room. The second argument is called with the `SeatReservation` once a match is found — use `client.consumeSeatReservation()` to join the match room.
174+
175+
**Return value:**
176+
177+
| Field | Type | Description |
178+
|---|---|---|
179+
| `room` | `Room \| undefined` | The match room, once the seat has been consumed |
180+
| `queue` | `Room \| undefined` | The queue room while waiting (undefined after match is joined) |
181+
| `clients` | `number` | Number of clients in the current matchmaking group |
182+
| `seat` | `SeatReservation \| undefined` | The seat reservation, once received |
183+
| `error` | `Error \| undefined` | Connection or matchmaking error |
184+
| `isWaiting` | `boolean` | `true` while connected to the queue and waiting for a match |
185+
186+
## Contexts
187+
86188
### `createRoomContext()`
87189

88190
Creates a set of hooks and a `RoomProvider` component that share a single room connection across React reconciler boundaries (e.g. DOM + React Three Fiber). The room is stored in a closure-scoped external store rather than React Context, so the hooks work in any reconciler tree that imports them.
@@ -129,6 +231,62 @@ function UI() {
129231

130232
The returned `useRoom()` and `useRoomState(selector?)` work identically to the standalone hooks but don't require you to pass the room as an argument.
131233

234+
### `createLobbyContext()`
235+
236+
Creates a `LobbyProvider` and `useLobby` hook for sharing lobby room data globally across your app — useful when you need room metadata available persistently alongside an active game room, not just on a lobby screen. Like `createRoomContext`, it uses a closure-scoped external store so the hook works across reconciler boundaries.
237+
238+
```tsx
239+
import { Client } from "@colyseus/sdk";
240+
import { createLobbyContext, createRoomContext } from "@colyseus/react";
241+
242+
const client = new Client("ws://localhost:2567");
243+
244+
const { LobbyProvider, useLobby } = createLobbyContext<MyMetadata>();
245+
const { RoomProvider, useRoom, useRoomState } = createRoomContext();
246+
```
247+
248+
**Wrap your app with `LobbyProvider` (can nest with `RoomProvider`):**
249+
250+
```tsx
251+
function App() {
252+
return (
253+
<LobbyProvider connect={() => client.joinLobby()}>
254+
<RoomProvider connect={() => client.joinOrCreate("game_room")}>
255+
<UI />
256+
<Canvas>
257+
<GameScene />
258+
</Canvas>
259+
</RoomProvider>
260+
</LobbyProvider>
261+
);
262+
}
263+
```
264+
265+
`LobbyProvider` accepts a `connect` callback (same as `useLobbyRoom`) and an optional `deps` array. The lobby connection persists independently of the game room.
266+
267+
**Access lobby data from any component — even deep inside the game:**
268+
269+
```tsx
270+
function RoomBrowser() {
271+
const { rooms, error, isConnecting } = useLobby();
272+
273+
if (isConnecting) return <p>Loading rooms...</p>;
274+
if (error) return <p>Error: {error.message}</p>;
275+
276+
return (
277+
<ul>
278+
{rooms.map((room) => (
279+
<li key={room.roomId}>
280+
{room.metadata.displayName}{room.clients}/{room.maxClients}
281+
</li>
282+
))}
283+
</ul>
284+
);
285+
}
286+
```
287+
288+
The returned `useLobby()` hook provides the same fields as `useLobbyRoom` (`rooms`, `room`, `error`, `isConnecting`).
289+
132290
## Credits
133291

134292
Inspiration and previous work by [@pedr0fontoura](https://github.com/pedr0fontoura)[use-colyseus](https://github.com/pedr0fontoura/use-colyseus/).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@colyseus/react",
3-
"version": "0.1.8",
3+
"version": "0.1.9",
44
"type": "module",
55
"scripts": {
66
"dev": "tsdown --watch --format esm,cjs",

src/context/createLobbyContext.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useSyncExternalStore, useEffect, type ReactNode, type DependencyList } from "react";
2+
import { Room } from "@colyseus/sdk";
3+
import { useLobbyRoom as useLobbyRoomOriginal, type UseLobbyRoomResult } from "../room/useLobbyRoom";
4+
5+
interface LobbyProviderProps {
6+
connect: (() => Promise<Room>) | null | undefined | false;
7+
deps?: DependencyList;
8+
children: ReactNode;
9+
}
10+
11+
/**
12+
* Creates a LobbyProvider and useLobby hook for sharing lobby room data
13+
* (available rooms + metadata) globally across your app.
14+
*
15+
* Uses a closure-scoped external store (not React Context), so the hook
16+
* works in any reconciler tree that imports it.
17+
*
18+
* This is useful when you need lobby metadata available persistently
19+
* alongside an active game room — not just on a lobby screen.
20+
*
21+
* @template Metadata - The type of room metadata
22+
*
23+
* @example
24+
* ```tsx
25+
* const { LobbyProvider, useLobby } = createLobbyContext<MyMetadata>();
26+
*
27+
* // Wrap your app (can nest with RoomProvider)
28+
* <LobbyProvider connect={() => client.joinLobby()}>
29+
* <RoomProvider connect={() => client.joinOrCreate("game")}>
30+
* <App />
31+
* </RoomProvider>
32+
* </LobbyProvider>
33+
*
34+
* // In any component:
35+
* const { rooms } = useLobby();
36+
* rooms.map(r => r.metadata.displayName)
37+
* ```
38+
*/
39+
export function createLobbyContext<Metadata = any>() {
40+
let snapshot: UseLobbyRoomResult<Metadata> = {
41+
rooms: [],
42+
room: undefined,
43+
error: undefined,
44+
isConnecting: true,
45+
};
46+
const listeners = new Set<() => void>();
47+
48+
function subscribe(listener: () => void) {
49+
listeners.add(listener);
50+
return () => listeners.delete(listener);
51+
}
52+
53+
function getSnapshot(): UseLobbyRoomResult<Metadata> {
54+
return snapshot;
55+
}
56+
57+
function setSnapshot(next: UseLobbyRoomResult<Metadata>) {
58+
snapshot = next;
59+
for (const listener of listeners) listener();
60+
}
61+
62+
/**
63+
* Manages the lobby room lifecycle. Render once near the root of your app.
64+
* Lobby state is shared via the closure-scoped external store, not React Context.
65+
*/
66+
function LobbyProvider({ connect, deps = [], children }: LobbyProviderProps) {
67+
const { rooms, room, error, isConnecting } = useLobbyRoomOriginal<Metadata>(connect, deps);
68+
69+
useEffect(() => {
70+
setSnapshot({ rooms, room, error, isConnecting });
71+
}, [rooms, room, error, isConnecting]);
72+
73+
return children;
74+
}
75+
76+
/**
77+
* Returns the list of available rooms, the lobby room instance,
78+
* error, and connection status.
79+
* Works in DOM tree, R3F tree, or any other reconciler tree.
80+
*/
81+
function useLobby(): UseLobbyRoomResult<Metadata> {
82+
return useSyncExternalStore(subscribe, getSnapshot);
83+
}
84+
85+
return { LobbyProvider, useLobby };
86+
}
Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useSyncExternalStore, useEffect, type ReactNode, type DependencyList } from "react";
22
import { Schema } from "@colyseus/schema";
33
import { Room } from "@colyseus/sdk";
4-
import { useRoom as useRoomLifecycle, type UseRoomResult } from "./useRoom";
5-
import { useRoomState as useRoomStateOriginal } from "./schema/useRoomState";
6-
import type { Snapshot } from "./schema/createSnapshot";
4+
import { useRoom as useRoomLifecycle, type UseRoomResult } from "../room/useRoom";
5+
import { useRoomState as useRoomStateOriginal } from "../schema/useRoomState";
6+
import { useRoomMessage as useRoomMessageStandalone } from "../room/useRoomMessage";
7+
import type { Snapshot } from "../schema/createSnapshot";
78

89
interface RoomProviderProps<T, State> {
910
connect: (() => Promise<Room<T, State>>) | null | undefined | false;
@@ -88,5 +89,17 @@ export function createRoomContext<T = any, State extends Schema = Schema>() {
8889
return useRoomStateOriginal(room, selector);
8990
}
9091

91-
return { RoomProvider, useRoom, useRoomState };
92+
/**
93+
* Subscribes to room messages without needing to pass the room.
94+
* The room is resolved from the store automatically.
95+
*/
96+
function useRoomMessage(
97+
type: string | number | "*",
98+
callback: (...args: any[]) => void
99+
): void {
100+
const { room } = useSyncExternalStore(subscribe, getSnapshot);
101+
useRoomMessageStandalone(room, type, callback);
102+
}
103+
104+
return { RoomProvider, useRoom, useRoomState, useRoomMessage };
92105
}

src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
export { useRoomState } from './schema/useRoomState';
2-
export { useRoom } from './useRoom';
3-
export { createRoomContext } from './createRoomContext';
4-
export type { UseRoomResult } from './useRoom';
2+
export { useRoom } from './room/useRoom';
3+
export { useRoomMessage } from './room/useRoomMessage';
4+
export { createRoomContext } from './context/createRoomContext';
5+
export { createLobbyContext } from './context/createLobbyContext';
6+
export { useLobbyRoom } from './room/useLobbyRoom';
7+
export { useQueueRoom } from './room/useQueueRoom';
8+
export type { UseRoomResult } from './room/useRoom';
9+
export type { UseLobbyRoomResult } from './room/useLobbyRoom';
10+
export type { UseQueueRoomResult } from './room/useQueueRoom';
511
export type { Snapshot } from './schema/createSnapshot';

src/room/useLobbyRoom.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Room, type RoomAvailable } from "@colyseus/sdk";
2+
import { useState, useEffect, type DependencyList } from "react";
3+
import { useRoom } from "./useRoom";
4+
5+
export interface UseLobbyRoomResult<Metadata = any> {
6+
rooms: RoomAvailable<Metadata>[];
7+
room: Room | undefined;
8+
error: Error | undefined;
9+
isConnecting: boolean;
10+
}
11+
12+
export function useLobbyRoom<Metadata = any>(
13+
callback: (() => Promise<Room>) | null | undefined | false,
14+
deps: DependencyList = []
15+
): UseLobbyRoomResult<Metadata> {
16+
const { room, error, isConnecting } = useRoom(callback, deps);
17+
const [rooms, setRooms] = useState<RoomAvailable<Metadata>[]>([]);
18+
19+
useEffect(() => {
20+
if (!room) {
21+
setRooms([]);
22+
return;
23+
}
24+
25+
const unsubs: (() => void)[] = [];
26+
27+
unsubs.push(room.onMessage("rooms", (roomList: RoomAvailable<Metadata>[]) => {
28+
setRooms(roomList);
29+
}));
30+
31+
unsubs.push(room.onMessage("+", ([roomId, roomData]: [string, RoomAvailable<Metadata>]) => {
32+
setRooms(prev => {
33+
const index = prev.findIndex(r => r.roomId === roomId);
34+
if (index !== -1) {
35+
const next = [...prev];
36+
next[index] = roomData;
37+
return next;
38+
}
39+
return [...prev, roomData];
40+
});
41+
}));
42+
43+
unsubs.push(room.onMessage("-", (roomId: string) => {
44+
setRooms(prev => prev.filter(r => r.roomId !== roomId));
45+
}));
46+
47+
return () => {
48+
unsubs.forEach(unsub => unsub());
49+
};
50+
}, [room]);
51+
52+
return { rooms, room, error, isConnecting };
53+
}

0 commit comments

Comments
 (0)