Skip to content

Colyseus 0.18: Predict netcode hooks, schema v5 compatibility, v0.18.0 - #11

Merged
endel merged 21 commits into
mainfrom
0.18
Aug 25, 2026
Merged

endel merged 21 commits into
mainfrom
0.18

Conversation

@endel

@endel endel commented Aug 11, 2026 •

Copy link
Copy Markdown
Member

Summary

Targets Colyseus 0.18 / @colyseus/schema v5, adds React bindings for the new Predict netcode, and moves the package to 0.18.0 — from this release on, @colyseus/react versioning tracks colyseus itself.

Netcode hooks (new src/predict module)

React bindings for the 0.18 Predict tools (client prediction, reconciliation, remote smoothing). Every hook exists standalone (useX(room, ...)) and room-bound via createRoomContext({ predict }):

  • usePredict — one shared, ref-counted Predict per room (StrictMode-safe deferred dispose)
  • useInput — the room's InputHandle
  • usePredictLoop — the frame driver; owns rAF, or { external: true } for an existing loop (e.g. R3F useFrame)
  • useReconciler — active prediction with lifecycle handled (late spawn, instance replacement, dispose)
  • useAttachAll — passive smoothing as an effect
  • useEventChannel — optimistic events with teardown plus reactivity
  • useEntityInstance / useSessionEntity — decoded-instance selection, identity-change re-renders only
  • useInputBuffer — the buffer-then-consume tap recipe (renamed from useLatch during review)
  • getSchemaInstance(snapshot) — bridges snapshots back to their decoded instance for predict.value() reads (renamed from getSource during review — self-describing, matches the "decoded schema instance" vocabulary)

Schema v5 compatibility

  • refId read via v5's Symbol.for("$refId") (v4 ~refId fallback kept)
  • tolerate v5's mixed Metadata shape when deriving field names
  • seed a no-op decoder.triggerChanges so decode() returns DataChange[] without an external subscriber
  • Snapshot<T> strips v5's publicly-declared Schema internals and accepts plain IArray/IMap shapes so frontends can type props without importing @colyseus/schema

Release

Test plan

  • npx vitest run — 154 passed, 3 skipped (includes new predictHooks.test.tsx and snapshotType.test.ts suites)
  • Test harnesses fixed for the v5 encoder lifecycle (discardChanges() after each encode())

🤖 Generated with Claude Code

FTWinston and others added 21 commits June 1, 2026 09:55
- refId now read via the v5 `Symbol.for("$refId")` tag (v4 "~refId" kept
  as fallback)
- tolerate v5's mixed Metadata shape (index→field objects + name→index
  reverse entries) when deriving field names
- seed a no-op `decoder.triggerChanges` — v5 only collects (and returns)
  DataChange[] when a subscriber exists, and the subscription wrapper
  relies on the returned changes
- Snapshot<T> strips Schema base internals that v5 declares publicly
  (`isTrackingPaused`, symbol-keyed $refId/$values), and accepts plain
  Array/Map-shaped interfaces (IArray/IMap) so frontends can type props
  without importing @colyseus/schema; compile-time assertions in
  snapshotType.test.ts

Assisted-by: Claude Opus 4.8
encode() does not clear the encoder's dirty state — the caller must run
discardChanges() after each patch (colyseus core does). Without it, the
next encode() re-emits stale ops against a frozen index space, which
schema v5 punishes with corrupted sequential array-splice patches
(client decoded [C, C] where the server had [C]). Fixes 4 array tests.

Also: RoomAvailable lost `publicAddress` in 0.18, and v5's Schema<any>
generic needs an explicit cast in the bench comparison walker.

Assisted-by: Claude Opus 4.8
Peer + dev deps: @colyseus/sdk ^0.18.1, @colyseus/schema ^5.0.8,
@colyseus/shared-types ^0.18.1.

Assisted-by: Claude Opus 4.8
New src/predict module, exported standalone and as room-bound variants
from createRoomContext({ predict }):

- usePredict — one shared Predict per room (Predict.get() constructs a
  fresh instance per call), ref-counted with StrictMode-safe deferred
  dispose
- useInput — the room's InputHandle (room.input() is idempotent)
- usePredictLoop — the frame driver: predict.tick(now) once per frame,
  then the caller's send loop; owns rAF, or `{ external: true }` returns
  drive(now) for an existing loop (e.g. R3F useFrame at priority -1)
- useReconciler — active prediction with the lifecycle handled: waits
  for late spawn, recreates on instance replacement, disposes on unmount
- useAttachAll — passive smoothing as an effect (detach on unmount)
- useEventChannel — defineEvent with teardown plus reactivity: injected
  callback wrappers re-render the caller, so has(key) derives work in
  render code
- useEntityInstance / useSessionEntity — select decoded instances,
  re-rendering only on identity change
- useLatch — the latch-then-consume tap recipe
- getSource(snapshot) — bridges useRoomState snapshots back to their
  decoded instance for predict.value() reads (recorded for free during
  snapshot creation via WeakMap)

Assisted-by: Claude Opus 4.8
Documents the structure-vs-motion split (snapshots at patch rate,
predict reads per frame outside the render cycle) and each hook.

Assisted-by: Claude Opus 4.8
"source" said where the object came from, not what you get back. The
new name matches the "decoded schema instance" vocabulary used across
the docs and the entity hooks. Pre-release rename — no alias kept.

Assisted-by: Claude Fable 5
CHANGELOG for the 0.18 branch (predict hooks, schema v5 compat, peer
dep bumps) and version bump 0.2.0 → 0.18.0 so @colyseus/react pairs
with colyseus's own version numbers from here on.

Assisted-by: Claude Fable 5
The IArray/IMap types and structural Snapshot branches were already
absorbed into this branch (49bb64a) in extended form — concrete
ArraySchema/MapSchema branches kept first for element inference, v5
internals stripped — so conflicts resolve to the branch's version.
New from the PR: prepublishOnly → prepare, so git revisions
(npm i colyseus/react-tools#0.18) build dist on install.

Assisted-by: Claude Fable 5
Assisted-by: Claude Fable 5
"Latch" is electronics jargon; input buffering is the gamedev term for
the same mechanism. Latch → InputBuffer, latch() → press(); consume()
and peek() unchanged. Docs recipe wording moves to "buffer, then
consume" alongside.

Assisted-by: Claude Fable 5
The predict layer added three store reads that main does not have —
useStoreRoom(), useEntityInstance and useEventChannel — each throwing the
same missing-getServerSnapshot error, so each needed its own server
snapshot on top of the merge.

0.18.0 is unpublished, so the fix folds into its notes instead of bumping.

Assisted-by: Claude Opus 5
A Next.js App Router Server Component importing any hook failed the build
outright — "You're importing a component that needs useState" — because the
package never declared its own client boundary. Verified against Next 14.2:
fails without the directive, builds clean with it.

Assisted-by: Claude Opus 5
Every hook is swept under renderToString rather than spot-checked: the
missing-getServerSnapshot throw is per-call-site, so any new store read
reintroduces it. 23 of these cases were red before #12 landed, including
useSessionEntity and useReconciler by way of useEntityInstance.

The hydration suite carries a deliberate-mismatch control — without it an
empty mismatch list proves nothing.

Assisted-by: Claude Opus 5
createSnapshot caches by refId, and a root the selector builds itself —
Array.from(state.players.values()), an object literal — has none, so every
getSnapshot() minted a fresh reference and React re-rendered until it hit
"Maximum update depth exceeded".

Rather than diff the finished trees, the previous result is handed down as that
container's previousResult, letting the structural sharing already in the
Map/Array/Schema helpers settle it in a single pass. Object literals get the
same helper, which also stops them leaking the live schema nodes that
`{ player: state.players.get(id) }` used to hand out.

Assisted-by: Claude Opus 5
Assisted-by: Claude Opus 5
sdk 0.18.2 merges smoothing/damping into one smoothMs (time constant,
ms) — the hooks re-export those option types, so only the docs example
and the version floor move.

Assisted-by: Claude Fable 5
Assisted-by: Claude Fable 5
main's 0.1.19 fix landed on this line as 0.18.1 already, so every code
conflict resolves to the 0.18 side — it carries the schema v5 and predict
work on top. Only the 0.1.19 changelog entry is new here, filed under the
0.1.x history it belongs to.

Assisted-by: Claude Opus 5
@endel
endel merged commit 4f9733c into main Aug 25, 2026
1 check passed
@endel
endel deleted the 0.18 branch August 25, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants