Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
28afccb
Tweak Snapshot type so it treats regular arrays and maps the same as …
FTWinston Jun 1, 2026
c49ec58
Change prepublishOnly to prepare, so github revisions can be used dir…
FTWinston Jun 1, 2026
f014535
Rename MinimalReadonlyArray to IArray, rename MinimalReadonlyMap to I…
FTWinston Jun 1, 2026
49bb64a
fix: schema v5 compatibility for the snapshot layer
endel Aug 11, 2026
157b96f
test: encoder lifecycle + 0.18 API fixes in harnesses
endel Aug 11, 2026
b16bc29
deps: target colyseus 0.18, bump to v0.2.0
endel Aug 11, 2026
cb05e26
feat: React hooks for the 0.18 Predict netcode
endel Aug 11, 2026
f03532f
docs: netcode hooks section in README
endel Aug 11, 2026
2e72be1
refactor: getSource() → getSchemaInstance()
endel Aug 11, 2026
b2acca6
release: 0.18.0 — version now tracks colyseus
endel Aug 11, 2026
069c06b
Merge PR #9: Snapshot support for plain Array/Map interfaces
endel Aug 11, 2026
1f8392f
docs: credit #9 in the 0.18.0 CHANGELOG
endel Aug 11, 2026
9d6b47d
refactor: useLatch() → useInputBuffer()
endel Aug 11, 2026
c4bb4a1
Merge SSR support (#12) into 0.18
endel Aug 17, 2026
bd857e9
Ship a "use client" directive
endel Aug 17, 2026
eff91f2
Add SSR regression suites
endel Aug 17, 2026
867a813
fix: keep selector-built snapshot roots stable across renders
endel Aug 18, 2026
1304d89
release 0.18.1
endel Aug 18, 2026
95ab711
release 0.18.2: follow the sdk's smoothMs rename
endel Aug 20, 2026
4cce6b0
lockfile: resolve @colyseus/sdk 0.18.2
endel Aug 20, 2026
e158db6
Merge main into 0.18
endel Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,57 @@
# Changelog

## 0.18.2

- Pairs with `@colyseus/sdk` 0.18.2, which merges the `smoothing`/`damping` options into one `smoothMs` (time constant in ms; `smoothMs = 1000 / old value`). No API change in the hooks themselves — option types flow through from the SDK.

## 0.18.1

### Fixes

- Selectors that build their own container no longer crash the component with `Maximum update depth exceeded`. `useRoomState(room, (s) => Array.from(s.players.values()))` and `(s) => ({ message: s.myString })` now hold a stable reference between renders. ([#13](https://github.com/colyseus/react-tools/pull/13), reported by [@under-undefined](https://github.com/under-undefined), who also contributed the regression tests — thank you!)
- Object-literal selectors return plain snapshot data, the way array selectors already did. `(s) => ({ player: s.players.get(id) })` previously handed back the live schema node, which also left `getSchemaInstance()` unable to resolve it.

## 0.18.0

Targets Colyseus 0.18 / `@colyseus/schema` v5. From this release on, the package version tracks Colyseus itself — `@colyseus/react` 0.18.x pairs with colyseus 0.18 — hence the jump from 0.1.17.

### Breaking

- Peer dependencies are now `@colyseus/sdk` ^0.18.1, `@colyseus/schema` ^5.0.8 and `@colyseus/shared-types` ^0.18.1. Projects on colyseus 0.17 / schema v4 should stay on the 0.1.x line.

### Features

- **Predict hooks** — React bindings for the 0.18 Predict netcode (client prediction, reconciliation, remote smoothing). Every hook exists standalone (`useX(room, ...)`) and room-bound from `createRoomContext(options)`, with shared Predict options passed once via `createRoomContext({ predict: { mode: "lerp", delay: 100 } })`. See the README's netcode section for usage and the structure-vs-motion rule.
- `usePredict` — the room's shared `Predict` instance. `Predict.get()` constructs a fresh instance per call, so the hook memoizes one per room, reference-counts consumers, and disposes it (StrictMode-safe, deferred) when the last consumer unmounts.
- `useInput` — the room's `InputHandle` (`room.input()`, idempotent and stable per room).
- `usePredictLoop` — the frame driver: `predict.tick(now)` once per frame, then your fixed-step send callback. Owns its own `requestAnimationFrame`, or with `{ external: true }` returns a `drive(now)` function for a frame loop you already have (e.g. R3F `useFrame`).
- `useReconciler` — active prediction for the entity you control, with the lifecycle handled: waits for late spawn, recreates the controller when the server replaces the instance, disposes on unmount.
- `useAttachAll` — passive smoothing for a root-state collection (`predict.attachAll()`) as an effect, detached on unmount.
- `useEventChannel` — optimistic events (`predict.defineEvent()`) with teardown plus reactivity: the calling component re-renders on predict/confirm/reject, so `has(key)`-style derives work in plain render code.
- `useEntityInstance` / `useSessionEntity` — select decoded schema instances (what the Predict APIs key off), re-rendering only on identity change, never on field updates.
- `useInputBuffer` — the buffer-then-consume tap recipe: `press()` in the event handler, `consume()` lands it on exactly one fixed step.
- `getSchemaInstance(snapshot)` — bridges `useRoomState` snapshots back to their decoded instance for `predict.value()` reads (recorded during snapshot creation via a `WeakMap`, so lookups are free).

### Fixes

- Schema v5 compatibility for the snapshot layer:
- `refId` is read via v5's `Symbol.for("$refId")` tag, with the v4 `~refId` string key kept as a fallback.
- Field-name derivation tolerates v5's mixed `Metadata` shape (index→field objects plus name→index reverse entries).
- A no-op `decoder.triggerChanges` is seeded — v5 only collects and returns `DataChange[]` when a subscriber exists, and the subscription wrapper relies on the returned changes.
- `Snapshot<T>` strips the Schema base internals that v5 declares publicly (`isTrackingPaused`, the symbol-keyed `$refId`/`$values`), and accepts plain `Array`/`Map`-shaped interfaces — the newly exported `IArray` / `IMap` types — so frontends can type component props without importing `@colyseus/schema`. Covered by compile-time assertions in `snapshotType.test.ts`. ([#9](https://github.com/colyseus/react-tools/pull/9) by [@FTWinston](https://github.com/FTWinston))
- The `prepare` script (replacing `prepublishOnly`) builds `dist/` on install, so git revisions work directly: `npm install colyseus/react-tools#0.18`. ([#9](https://github.com/colyseus/react-tools/pull/9) by [@FTWinston](https://github.com/FTWinston))
- Server-rendering a component that reads room state no longer crashes with `Missing getServerSnapshot, which is required for server-rendered content`. Every hook that reads a room — `useRoomState`, `useColyseusState`, the `createRoomContext` / `createLobbyContext` hooks, and the predict hooks — now renders on the server as if no room were connected, and picks up the live room right after hydration. ([#12](https://github.com/colyseus/react-tools/pull/12) by [@under-undefined](https://github.com/under-undefined))
- The package now ships a `"use client"` directive, so importing it from a Next.js App Router Server Component no longer fails the build with `You're importing a component that needs useState`. Marking your own component `"use client"` is no longer required just to import a hook.

### Tests

- Test harnesses now call `discardChanges()` after each `encode()`, matching what colyseus core does — without it, schema v5 re-emits stale ops against a frozen index space and corrupts sequential array-splice patches (this had 4 array tests failing).
- New `predictHooks.test.tsx` suite covering the Predict hooks (lifecycle, ref-counting, StrictMode, event-channel reactivity, entity selection).

## 0.1.19

The colyseus 0.17 / schema v4 line; 0.18.1 carries the same fixes.

### Fixes

- Selectors that build their own container no longer crash the component with `Maximum update depth exceeded`. `useRoomState(room, (s) => Array.from(s.players.values()))` and `(s) => ({ message: s.myString })` now hold a stable reference between renders. ([#13](https://github.com/colyseus/react-tools/pull/13), reported by [@under-undefined](https://github.com/under-undefined), who also contributed the regression tests — thank you!)
Expand Down
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ npm install @colyseus/react

**Peer dependencies:** `@colyseus/sdk`, `@colyseus/schema`, and `react` (>=18.3.1).

## Server-side rendering

Every hook is safe to render on the server. A room is a live client-side
connection and is never serialized for hydration, so on the server the hooks
render as though nothing is connected — `useRoomState()` returns `undefined`,
`useRoom()` reports `isConnecting: true`, and the predict hooks return
`undefined`. Connection happens in an effect, so the live room arrives on the
re-render right after hydration.

Render the connecting state rather than assuming state exists:

```tsx
const players = useRoomState((state) => state.players);
if (!players) return <Spinner />; // server render + first client paint
```

The package ships a `"use client"` directive, so you can import it from a
Next.js App Router Server Component without the build failing. The hooks
themselves still run on the client, as any React hook does.

## Hooks

### `useRoom(callback, deps?)`
Expand Down Expand Up @@ -185,6 +205,82 @@ The first argument connects to the queue room. The second argument is called wit
| `error` | `Error \| undefined` | Connection or matchmaking error |
| `isWaiting` | `boolean` | `true` while connected to the queue and waiting for a match |

## Netcode hooks (Colyseus 0.18+)

React bindings for the `@colyseus/sdk` Predict tools — client prediction, reconciliation and remote smoothing. See the [netcode docs](https://docs.colyseus.io/netcode/client-prediction) for the underlying concepts.

**The one rule:** structure renders through snapshots, motion renders through predict reads. `useRoomState` re-renders at patch rate with **raw synced values** — rendering positions from it gives you jittery patch-rate motion. Predicted/smoothed values change every frame and must stay out of the React render cycle: read them with `predict.value(instance, field)` inside your frame loop (R3F `useFrame`, or the `usePredictLoop` callback) and write into refs.

All hooks below exist in two forms: standalone (`useX(room, ...)`) and bound (returned by `createRoomContext(options)`, no room argument). Pass Predict options once via `createRoomContext({ predict: { mode: "lerp", delay: 100 } })`.

### `usePredict(room, options?)`

Returns the room's shared `Predict` instance. `Predict.get()` constructs a fresh instance per call, so the hook memoizes one per room, reference-counts consumers, and disposes it when the last consumer unmounts.

### `useInput(room, options?)`

Returns the room's `InputHandle` (`room.input()` — idempotent, stable per room). Stage fields on `input.data`, then `input.send()` once per fixed step inside the `usePredictLoop` callback.

### `usePredictLoop(room, onSteps, options?)`

Owns the per-frame driver: calls `predict.tick(now)` once per frame and hands you the number of fixed steps due — stage + send exactly that many inputs inside the callback ("send before you read"). By default it runs its own `requestAnimationFrame`; with `{ external: true }` it returns a `drive(now?)` function to call from a frame loop you already have:

```tsx
// React Three Fiber — drive at an early priority so ticks precede reads:
const drive = usePredictLoop(onSteps, { external: true });
useFrame(() => drive(), -1);
```

### `useReconciler(room, select, options)`

Active prediction for the entity you control — `predict.reconciler()` with the lifecycle handled: waits for the entity to spawn (`select: (state, room) => instance`), recreates the controller if the server replaces the instance, and disposes on unmount. `options.input` defaults to the room's handle.

```tsx
const me = useReconciler(
(state, room) => state.players.get(room.sessionId),
{ step: (ctx, p, cmd) => applyInput(p, cmd, ctx.dt), smoothing: 15, snap: 5 },
);
```

### `useAttachAll(room, key, config, deps?)`

Passive smoothing for a root-state collection — `predict.attachAll()` as an effect, detached on unmount. Attach angular fields in a separate call:

```tsx
useAttachAll("players", { mode: "lerp", fields: ["x", "y", "z"] });
useAttachAll("players", { mode: "lerp", fields: ["heading"], angle: true });
```

### `useEventChannel(room, options)`

Optimistic event channel — `predict.defineEvent()` with teardown on unmount, **plus reactivity**: the calling component re-renders on predict/confirm/reject/unpredicted, so flag-shaped derives work in plain render code:

```tsx
const pickups = useEventChannel({
confirmOn: { collection: "items", field: "alive", equals: false },
});
const hidden = !item.alive || pickups?.has(id);
```

### `useEntityInstance(room, select)` / `useSessionEntity(room, collectionKey)`

Select a **decoded schema instance** (what the Predict APIs key off), re-rendering only when its identity changes — never on field updates. `useSessionEntity` is the common case: your own entity by `sessionId`.

### `getSchemaInstance(snapshot)`

Bridges `useRoomState` snapshots back to their decoded instance, for `predict.value()` reads on entities you render from snapshot lists:

```tsx
const source = getSchemaInstance(playerSnapshot);
useFrame(() => { ref.current.position.x = predict.value(source, "x"); });
```

### `useInputBuffer()`

The ["buffer, then consume"](https://docs.colyseus.io/netcode/recipes#taps-between-steps-buffer-then-consume) input recipe as a hook: `press()` in the React event handler, `consume()` inside the send loop so a tap lands on exactly one fixed step — never lost on a 0-step frame, never doubled on a multi-step frame.

For a complete working example (R3F + prediction + remote lerp), see [r3f-lobby-car-prototype](https://github.com/endel/r3f-lobby-car-prototype).

## Contexts

### `createRoomContext()`
Expand Down
Loading
Loading