From 28afccb52aad0aa20b41c4cf4cf1c61572eba66a Mon Sep 17 00:00:00 2001 From: FTWinston Date: Mon, 1 Jun 2026 09:55:49 +0100 Subject: [PATCH 01/18] Tweak Snapshot type so it treats regular arrays and maps the same as ArraySchema and MapSchema --- src/index.ts | 2 +- src/schema/createSnapshot.ts | 43 +++++++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8d137e8..0d34b7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,4 +8,4 @@ export { useQueueRoom } from './room/useQueueRoom'; export type { UseRoomResult } from './room/useRoom'; export type { UseLobbyRoomResult } from './room/useLobbyRoom'; export type { UseQueueRoomResult } from './room/useQueueRoom'; -export type { Snapshot } from './schema/createSnapshot'; +export type { MinimalReadonlyArray, MinimalReadonlyMap, Snapshot } from './schema/createSnapshot'; diff --git a/src/schema/createSnapshot.ts b/src/schema/createSnapshot.ts index 6f83a3f..ff638af 100644 --- a/src/schema/createSnapshot.ts +++ b/src/schema/createSnapshot.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Schema, ArraySchema, MapSchema } from "@colyseus/schema"; - /** Property key used by @colyseus/schema to tag decoded instances with their refId. */ const REF_ID_KEY = "~refId"; @@ -43,22 +42,50 @@ type DeepReadonly = T extends (infer R)[] ? { readonly [K in keyof T]: DeepReadonly } : T; +/** + * Like ReadonlyArray, but remove concat, so that ArraySchema + * implements it. + */ +export type MinimalReadonlyArray = Omit, 'concat'>; + +/** + * Like ReadonlyMap, but uses IterableIterator instead of MapIterator, + * so that MapSchema implements it. + */ +export type MinimalReadonlyMap = { + get(key: K): V | undefined; + has(key: K): boolean; + readonly size: number; + forEach(callbackfn: (value: V, key: K) => void, thisArg?: any): void; + entries(): IterableIterator<[K, V]>; + keys(): IterableIterator; + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[K, V]>; +}; + +// Helper to detect primitives +type Primitive = string | number | boolean | bigint | symbol | null | undefined; + +type MapKey = string | number | symbol; + /** * Transforms a Colyseus Schema type into an immutable, plain JavaScript type. * - * - `ArraySchema` becomes `readonly T[]` - * - `MapSchema` becomes `Readonly>` - * - `Schema` subclasses become plain objects with only data properties + * - `ArraySchema` (or `Array`) becomes `readonly T[]` + * - `MapSchema` (or `Map`) becomes `Readonly>` + * - `Schema` (or `object`) subclasses become plain objects with only data properties * - Primitives remain unchanged * * @template T - The Colyseus Schema type to snapshot */ export type Snapshot = DeepReadonly< - T extends ArraySchema + T extends MinimalReadonlyArray ? Snapshot[] - : T extends MapSchema - ? Record> - : T extends Schema + : T extends MinimalReadonlyMap + ? Record> + : T extends Primitive + ? T + : T extends object ? { [K in keyof OmitFunctions]: Snapshot[K]> } : T >; From c49ec586bb711639f0a0ec89afc41c4c711587b8 Mon Sep 17 00:00:00 2001 From: FTWinston Date: Mon, 1 Jun 2026 23:08:59 +0100 Subject: [PATCH 02/18] Change prepublishOnly to prepare, so github revisions can be used directly --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9e2cf9e..7237bac 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "tsdown --format esm,cjs", "lint": "eslint .", "test": "vitest", - "prepublishOnly": "npm run build" + "prepare": "npm run build" }, "exports": { ".": { From f014535c7b86dcd3d3a4f0ba013f41b932e7ed77 Mon Sep 17 00:00:00 2001 From: FTWinston Date: Mon, 1 Jun 2026 23:56:49 +0100 Subject: [PATCH 03/18] Rename MinimalReadonlyArray to IArray, rename MinimalReadonlyMap to IMap and simplify --- src/index.ts | 2 +- src/schema/createSnapshot.ts | 22 ++++++++++------------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0d34b7e..b78a3d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,4 +8,4 @@ export { useQueueRoom } from './room/useQueueRoom'; export type { UseRoomResult } from './room/useRoom'; export type { UseLobbyRoomResult } from './room/useLobbyRoom'; export type { UseQueueRoomResult } from './room/useQueueRoom'; -export type { MinimalReadonlyArray, MinimalReadonlyMap, Snapshot } from './schema/createSnapshot'; +export type { IArray, IMap, Snapshot } from './schema/createSnapshot'; diff --git a/src/schema/createSnapshot.ts b/src/schema/createSnapshot.ts index ff638af..1246757 100644 --- a/src/schema/createSnapshot.ts +++ b/src/schema/createSnapshot.ts @@ -46,26 +46,24 @@ type DeepReadonly = T extends (infer R)[] * Like ReadonlyArray, but remove concat, so that ArraySchema * implements it. */ -export type MinimalReadonlyArray = Omit, 'concat'>; +export type IArray = Omit, 'concat'>; /** * Like ReadonlyMap, but uses IterableIterator instead of MapIterator, * so that MapSchema implements it. */ -export type MinimalReadonlyMap = { - get(key: K): V | undefined; - has(key: K): boolean; - readonly size: number; - forEach(callbackfn: (value: V, key: K) => void, thisArg?: any): void; - entries(): IterableIterator<[K, V]>; - keys(): IterableIterator; - values(): IterableIterator; +export type IMap = Omit, typeof Symbol.iterator> & { [Symbol.iterator](): IterableIterator<[K, V]>; }; -// Helper to detect primitives +/** + * A helper to detect primitives + */ type Primitive = string | number | boolean | bigint | symbol | null | undefined; +/** + * Anything that can be a Map key. (MapSchema keys are always strings.) + */ type MapKey = string | number | symbol; /** @@ -79,9 +77,9 @@ type MapKey = string | number | symbol; * @template T - The Colyseus Schema type to snapshot */ export type Snapshot = DeepReadonly< - T extends MinimalReadonlyArray + T extends IArray ? Snapshot[] - : T extends MinimalReadonlyMap + : T extends IMap ? Record> : T extends Primitive ? T From 49bb64aec9ef0f6e89f1ff070553336e8cd9dec0 Mon Sep 17 00:00:00 2001 From: Endel Dreyer Date: Tue, 11 Aug 2026 14:59:43 -0300 Subject: [PATCH 04/18] fix: schema v5 compatibility for the snapshot layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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 --- src/index.ts | 2 +- src/schema/createSnapshot.ts | 77 +++++++++++++++++++++------ src/schema/getOrCreateSubscription.ts | 8 +++ src/tests/snapshotType.test.ts | 59 ++++++++++++++++++++ 4 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 src/tests/snapshotType.test.ts diff --git a/src/index.ts b/src/index.ts index 8d137e8..b78a3d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,4 +8,4 @@ export { useQueueRoom } from './room/useQueueRoom'; export type { UseRoomResult } from './room/useRoom'; export type { UseLobbyRoomResult } from './room/useLobbyRoom'; export type { UseQueueRoomResult } from './room/useQueueRoom'; -export type { Snapshot } from './schema/createSnapshot'; +export type { IArray, IMap, Snapshot } from './schema/createSnapshot'; diff --git a/src/schema/createSnapshot.ts b/src/schema/createSnapshot.ts index 87f43bd..e77a7fb 100644 --- a/src/schema/createSnapshot.ts +++ b/src/schema/createSnapshot.ts @@ -1,16 +1,20 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Schema, ArraySchema, MapSchema } from "@colyseus/schema"; -/** Property key used by @colyseus/schema to tag decoded instances with their refId. */ +/** Symbol used by @colyseus/schema v5 to tag decoded instances with their refId. */ +const REF_ID_SYMBOL: unique symbol = Symbol.for("$refId") as never; + +/** Property key used by @colyseus/schema v4 to tag decoded instances with their refId. */ const REF_ID_KEY = "~refId"; /** Cache of field-name arrays, keyed by Schema constructor. */ const fieldNamesByCtor = new WeakMap(); /** - * Returns the `@type`-decorated field names for a Schema class, - * reading from `Symbol.metadata` set by v4's `@type()` decorators. - * Memoized per constructor. + * Returns the declared field names for a Schema class, reading from + * `Symbol.metadata` set by `@type()` decorators / the `schema()` builder. + * Handles both v4 and v5 metadata shapes (v5 mixes `index → field` entries + * with `fieldName → index` reverse-lookup entries). Memoized per constructor. */ function getSchemaFieldNames(node: object): string[] | undefined { const ctor = node.constructor as Function | undefined; @@ -19,7 +23,13 @@ function getSchemaFieldNames(node: object): string[] | undefined { if (cached) return cached; const metadata = (ctor as any)?.[Symbol.metadata]; if (metadata && typeof metadata === "object") { - const names = Object.values(metadata as Record).map(f => f.name); + const names: string[] = []; + for (const entry of Object.values(metadata as Record)) { + // keep field descriptors; skip v5's fieldName → index reverse entries + if (entry && typeof entry === "object" && typeof (entry as { name?: unknown }).name === "string") { + names.push((entry as { name: string }).name); + } + } fieldNamesByCtor.set(ctor, names); return names; } @@ -43,23 +53,60 @@ type DeepReadonly = T extends (infer R)[] ? { readonly [K in keyof T]: DeepReadonly } : T; +/** + * Structural shape of a readonly array, minus `concat` — whose `ArraySchema` + * override (`ArraySchema` return) is incompatible with `ReadonlyArray`. + * Lets `Snapshot` accept a plain `Array`/`ReadonlyArray` interface, not only + * `ArraySchema`, so frontends can type props against shared interfaces. + */ +export type IArray = Omit, 'concat'>; + +/** + * Structural shape of a readonly map, using `IterableIterator` (as `MapSchema` + * does) rather than the lib's `MapIterator`. Lets `Snapshot` accept a plain + * `Map`/`ReadonlyMap` interface, not only `MapSchema`. + */ +export type IMap = Omit, typeof Symbol.iterator> & { + [Symbol.iterator](): IterableIterator<[K, V]>; +}; + +/** Primitive (non-object) types, passed through a snapshot unchanged. */ +type Primitive = string | number | boolean | bigint | symbol | null | undefined; + +/** Anything usable as a Map key. (MapSchema keys are always strings.) */ +type MapKey = string | number | symbol; + /** * Transforms a Colyseus Schema type into an immutable, plain JavaScript type. - * - * - `ArraySchema` becomes `readonly T[]` - * - `MapSchema` becomes `Readonly>` - * - `Schema` subclasses become plain objects with only data properties + * + * - `ArraySchema` (or `Array` / `ReadonlyArray`) becomes `readonly T[]` + * - `MapSchema` (or `Map` / `ReadonlyMap`) becomes `Readonly>` + * - `Schema` subclasses (and plain objects) become plain objects with only data properties * - Primitives remain unchanged - * - * @template T - The Colyseus Schema type to snapshot + * + * Colyseus's concrete `ArraySchema`/`MapSchema` are matched first for precise + * element inference; `ArraySchema`'s overridden member signatures don't infer + * cleanly through `IArray`, so the structural branches only catch plain types. + * The internal `~refId` tag added to decoded instances is dropped — the runtime + * snapshot only copies `@type`-decorated fields, never `~refId`. + * + * @template T - The Colyseus Schema (or equivalent plain) type to snapshot */ export type Snapshot = DeepReadonly< T extends ArraySchema ? Snapshot[] : T extends MapSchema ? Record> - : T extends Schema - ? { [K in keyof OmitFunctions]: Snapshot[K]> } + : T extends IArray + ? Snapshot[] + : T extends IMap + ? Record> + : T extends Primitive + ? T + : T extends object + // strip Schema-base internals: v4's `~refId` tag, v5's symbol-keyed + // members ($refId, $values) and `isTrackingPaused` + ? { [K in keyof OmitFunctions as Exclude]: Snapshot[K]> } : T >; @@ -86,7 +133,7 @@ export interface SnapshotContext { * by the decoder, or -1 if absent. */ function getRefId(node: object): number { - const refId = (node as any)[REF_ID_KEY]; + const refId = (node as any)[REF_ID_SYMBOL] ?? (node as any)[REF_ID_KEY]; return typeof refId === "number" ? refId : -1; } @@ -160,7 +207,7 @@ function createSnapshotForSchema( const fieldNames = getSchemaFieldNames(node); if (!fieldNames) { - throw new Error(`createSnapshotForSchema: no field metadata found on ${node.constructor?.name ?? 'unknown'}. Is @colyseus/schema v4 installed?`); + throw new Error(`createSnapshotForSchema: no field metadata found on ${node.constructor?.name ?? 'unknown'}. Is @colyseus/schema v4+ installed?`); } for (const fieldName of fieldNames) { diff --git a/src/schema/getOrCreateSubscription.ts b/src/schema/getOrCreateSubscription.ts index 80b999a..5e8790a 100644 --- a/src/schema/getOrCreateSubscription.ts +++ b/src/schema/getOrCreateSubscription.ts @@ -70,6 +70,14 @@ export function getOrCreateSubscription(roomState: Schema, decoder: Decoder): St originalDecode: decoder.decode, }; + // Schema v5 only collects (and returns) DataChange[] when a + // `triggerChanges` subscriber exists. Seed a no-op so `decode()` reports + // changes; `Callbacks.get()` may later replace it — that's fine, the slot + // just needs to be defined. + if (decoder.triggerChanges === undefined) { + decoder.triggerChanges = () => { }; + } + // Wrap the decoder's decode method to intercept all state changes. // decode() is the single entry point for both full state syncs and // incremental patches, and it calls triggerChanges internally before diff --git a/src/tests/snapshotType.test.ts b/src/tests/snapshotType.test.ts new file mode 100644 index 0000000..837961c --- /dev/null +++ b/src/tests/snapshotType.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { test, expect } from "vitest"; +import { Schema, ArraySchema, MapSchema, type } from "@colyseus/schema"; +import type { Snapshot } from "../schema/createSnapshot"; + +/** + * These assertions are checked at compile time — a wrong `Snapshot` makes + * this file fail to type-check. (Run `npx tsc --noEmit src/tests/snapshotType.test.ts`.) + * The runtime `test` below is a no-op so vitest still includes the file. + */ + +class Weapon extends Schema { + @type("string") name = ""; + @type("number") damage = 0; +} +class PlayerState extends Schema { + @type([Weapon]) loadout = new ArraySchema(); + @type({ map: Weapon }) weapons = new MapSchema(); +} + +// Plain interfaces — frontends can type props without importing @colyseus/schema. +interface IWeapon { name: string; damage: number; } +interface IPlayer { + loadout: ReadonlyArray; + weapons: ReadonlyMap; +} + +// Structural equivalence via bidirectional assignability (robust for the +// non-homomorphic, key-remapped mapped type that strips `~refId`). +type Ext = A extends B ? true : false; +function expectMutual(_: Ext extends true ? (Ext extends true ? true : never) : never) {} + +type W = { readonly name: string; readonly damage: number }; + +// Concrete Colyseus schema paths — element type preserved, `~refId` stripped. +expectMutual, W>(true); +expectMutual>, ReadonlyArray>(true); +expectMutual>, { readonly [k: string]: W }>(true); +expectMutual["loadout"], ReadonlyArray>(true); +expectMutual["weapons"], { readonly [k: string]: W }>(true); + +// Plain interface paths (the PR #9 feature). +expectMutual["loadout"], ReadonlyArray>(true); +expectMutual["weapons"], { readonly [k: string]: W }>(true); + +// Primitives pass through. +expectMutual, number>(true); +expectMutual, string>(true); +expectMutual, boolean>(true); + +// Regression guard: ArraySchema element must NOT collapse to `unknown`. +type IsUnknown = unknown extends T ? ([T] extends [{}] ? false : true) : false; +const elementNotUnknown: IsUnknown>[number]> = false; +void elementNotUnknown; + +test("Snapshot type assertions compile", () => { + expect(true).toBe(true); +}); From 157b96f882bd6f2f4fe53cc630f62df0017a270c Mon Sep 17 00:00:00 2001 From: Endel Dreyer Date: Tue, 11 Aug 2026 14:59:51 -0300 Subject: [PATCH 05/18] test: encoder lifecycle + 0.18 API fixes in harnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 generic needs an explicit cast in the bench comparison walker. Assisted-by: Claude Opus 4.8 --- src/tests/bench.test.ts | 1 + src/tests/benchCompare.test.tsx | 2 +- src/tests/schema/simulateState.ts | 6 ++++++ src/tests/useLobbyRoom.test.ts | 4 ++-- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tests/bench.test.ts b/src/tests/bench.test.ts index 5fb875d..2a2e57f 100644 --- a/src/tests/bench.test.ts +++ b/src/tests/bench.test.ts @@ -38,6 +38,7 @@ function simulate(make: () => T) { const apply = (fn: (s: T) => void) => { fn(server); decoder.decode(encoder.encode()); + encoder.discardChanges(); // encode lifecycle contract — see simulateState.ts }; apply(() => {}); return { server, client, decoder, apply }; diff --git a/src/tests/benchCompare.test.tsx b/src/tests/benchCompare.test.tsx index 9afbcb3..f694122 100644 --- a/src/tests/benchCompare.test.tsx +++ b/src/tests/benchCompare.test.tsx @@ -82,7 +82,7 @@ function baseSnapshot(node: any, ctx: BaseCtx): any { result = changed ? out : prev; } else if (Schema.isSchema(node)) { const out: any = {}; let changed = prev === undefined; - for (const f of fields(node)) { const v = node[f]; if (typeof v === "function") continue; const sv = baseSnapshot(v, ctx); out[f] = sv; if (!changed && prev && prev[f] !== sv) changed = true; } + for (const f of fields(node)) { const v = (node as any)[f]; if (typeof v === "function") continue; const sv = baseSnapshot(v, ctx); out[f] = sv; if (!changed && prev && prev[f] !== sv) changed = true; } result = changed ? out : prev; } else { result = node; } ctx.parent = saved; diff --git a/src/tests/schema/simulateState.ts b/src/tests/schema/simulateState.ts index 8017364..d78f847 100644 --- a/src/tests/schema/simulateState.ts +++ b/src/tests/schema/simulateState.ts @@ -20,6 +20,12 @@ export function simulateState(createState: () => TState) // decode operations decoder.decode(encoded); + + // clear the encoder's dirty state — part of the encode lifecycle contract + // (colyseus core does this after each patch); without it, the next + // encode() re-emits stale ops against a frozen index space and corrupts + // sequential array splices on the client. + encoder.discardChanges(); } // Run an initial encode/decode step, so the client state is initialized ... otherwise, the first call to onAdd on a MapSchema in the state will fail. diff --git a/src/tests/useLobbyRoom.test.ts b/src/tests/useLobbyRoom.test.ts index 8bda6ce..03dccc6 100644 --- a/src/tests/useLobbyRoom.test.ts +++ b/src/tests/useLobbyRoom.test.ts @@ -46,8 +46,8 @@ describe('useLobbyRoom', () => { await act(async () => { }); const initial: RoomAvailable[] = [ - { roomId: "r1", clients: 1, maxClients: 4, metadata: null, name: "game", publicAddress: "" }, - { roomId: "r2", clients: 2, maxClients: 4, metadata: null, name: "game", publicAddress: "" }, + { roomId: "r1", clients: 1, maxClients: 4, metadata: null, name: "game" }, + { roomId: "r2", clients: 2, maxClients: 4, metadata: null, name: "game" }, ]; await act(async () => { emit("rooms", initial); }); From b16bc29a722a5cb55cff972c6efefc83c96a289f Mon Sep 17 00:00:00 2001 From: Endel Dreyer Date: Tue, 11 Aug 2026 14:59:51 -0300 Subject: [PATCH 06/18] deps: target colyseus 0.18, bump to v0.2.0 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 --- package-lock.json | 118 +++++++++++++++++++++++++--------------------- package.json | 13 ++--- 2 files changed, 70 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8b2fb9d..2b657c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@colyseus/react", - "version": "0.1.15", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@colyseus/react", - "version": "0.1.15", + "version": "0.1.17", "devDependencies": { - "@colyseus/schema": "^4.0.8", - "@colyseus/sdk": "^0.17.26", + "@colyseus/schema": "^5.0.11", + "@colyseus/sdk": "^0.18.1", + "@colyseus/shared-types": "^0.18.1", "@eslint/js": "^9.9.0", "@testing-library/react": "^16.3.1", "@types/react": "^18.3.3", @@ -171,20 +172,10 @@ } } }, - "node_modules/@colyseus/msgpackr": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@colyseus/msgpackr/-/msgpackr-1.11.2.tgz", - "integrity": "sha512-MuwPFhizFKC3zmGfy0fpo+kcnZdNdnQHFVjw81v4WXHCelDeCX8yNRVtuEm8kGlHqq7qiASLC0pu0RPqYOhxXg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, "node_modules/@colyseus/schema": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@colyseus/schema/-/schema-4.0.8.tgz", - "integrity": "sha512-yHSxoK9Q6qpWP/kSfDJhxXUMl6dE0vBEiJ1Y8R330aLpfVE/u1et+vFzrJlHucrNMZJeuvN+9EZjvvj+52wwBA==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@colyseus/schema/-/schema-5.0.11.tgz", + "integrity": "sha512-j+k0WdOKMl9d0XyK64NhVQW5F2jvAUIbfB3+kh4J1JRF4SCzXc/ZVY6NqDcCNPHVJeU0GJ1q6dmy4i14utNtHw==", "dev": true, "license": "MIT", "bin": { @@ -192,20 +183,25 @@ "schema-debug": "bin/schema-debug" }, "peerDependencies": { - "typescript": "^5.9.3" + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@colyseus/sdk": { - "version": "0.17.32", - "resolved": "https://registry.npmjs.org/@colyseus/sdk/-/sdk-0.17.32.tgz", - "integrity": "sha512-s2Gl+zA7EB8boaELBGJjTCxdiPB8bKBt522Kz2hXWyituWSmnP2pjTFD5wpWRDXn7rIzN7wwDPmrB6BB8npaqw==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@colyseus/sdk/-/sdk-0.18.1.tgz", + "integrity": "sha512-jtHOOUggEK0LPNUaI4x68HM2bY8QOz850Xk7lTeO5iUGlJ6pOccR1tf822OGaBsMBzFSz5SPMGTvjluN05WZoA==", "dev": true, "license": "MIT", "dependencies": { "@colyseus/better-call": "^1.3.1", - "@colyseus/msgpackr": "^1.11.2", - "@colyseus/schema": "^4.0.7", - "@colyseus/shared-types": "^0.17.6", + "@colyseus/schema": "^5.0.8", + "@colyseus/shared-types": "^0.18.1", + "msgpackr": "^2.0.1", "tslib": "^2.1.0", "ws": "^8.13.0" }, @@ -216,7 +212,7 @@ "url": "https://github.com/sponsors/endel" }, "peerDependencies": { - "@colyseus/core": "0.17.x" + "@colyseus/core": "0.18.x" }, "peerDependenciesMeta": { "@colyseus/better-call": { @@ -228,9 +224,10 @@ } }, "node_modules/@colyseus/shared-types": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@colyseus/shared-types/-/shared-types-0.17.6.tgz", - "integrity": "sha512-GeBB9uNrjG+BWa4/LAVyAHj65ll0WKLNjWDieKiphhmbfGXgQOcmvWVxKQc8KKu+n7f5Hmrwr6IEk9Dm2Y0Qgw==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@colyseus/shared-types/-/shared-types-0.18.1.tgz", + "integrity": "sha512-JAcPox/bopsTGosmZ0O0Tw0OeoaH6EsE5QmTEfSWssSFoHE8/Kzpfr/8DHVTpP8n9thPWc5Wo30kO7Thkt3rvQ==", + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0" @@ -967,9 +964,9 @@ } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -981,9 +978,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -995,9 +992,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -1009,9 +1006,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -1023,9 +1020,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -1037,9 +1034,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -1672,6 +1669,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@testing-library/dom": { @@ -3324,10 +3322,20 @@ "dev": true, "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3339,12 +3347,12 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, "node_modules/nanoid": { diff --git a/package.json b/package.json index f27178e..2ac7302 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@colyseus/react", - "version": "0.1.17", + "version": "0.2.0", "type": "module", "scripts": { "dev": "tsdown --watch --format esm,cjs", @@ -21,14 +21,15 @@ "dist" ], "peerDependencies": { - "@colyseus/schema": "^4.0.8", - "@colyseus/sdk": "^0.17.26", - "@colyseus/shared-types": "^0.17.6", + "@colyseus/schema": "^5.0.8", + "@colyseus/sdk": "^0.18.1", + "@colyseus/shared-types": "^0.18.1", "react": ">=18.3.1" }, "devDependencies": { - "@colyseus/schema": "^4.0.8", - "@colyseus/sdk": "^0.17.26", + "@colyseus/schema": "^5.0.11", + "@colyseus/sdk": "^0.18.1", + "@colyseus/shared-types": "^0.18.1", "@eslint/js": "^9.9.0", "@testing-library/react": "^16.3.1", "@types/react": "^18.3.3", From cb05e263d0fd7c5c1daa8ecbbb9e9ed7dab489a8 Mon Sep 17 00:00:00 2001 From: Endel Dreyer Date: Tue, 11 Aug 2026 15:00:22 -0300 Subject: [PATCH 07/18] feat: React hooks for the 0.18 Predict netcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/context/createRoomContext.ts | 129 ++++++++++++- src/index.ts | 14 +- src/predict/predictRegistry.ts | 56 ++++++ src/predict/useAttachAll.ts | 45 +++++ src/predict/useEntityInstance.ts | 51 +++++ src/predict/useEventChannel.ts | 87 +++++++++ src/predict/useInput.ts | 47 +++++ src/predict/useLatch.ts | 49 +++++ src/predict/usePredict.ts | 51 +++++ src/predict/usePredictLoop.ts | 95 ++++++++++ src/predict/useReconciler.ts | 87 +++++++++ src/predict/useSessionEntity.ts | 43 +++++ src/schema/createSnapshot.ts | 31 +++ src/tests/predictHooks.test.tsx | 312 +++++++++++++++++++++++++++++++ 14 files changed, 1092 insertions(+), 5 deletions(-) create mode 100644 src/predict/predictRegistry.ts create mode 100644 src/predict/useAttachAll.ts create mode 100644 src/predict/useEntityInstance.ts create mode 100644 src/predict/useEventChannel.ts create mode 100644 src/predict/useInput.ts create mode 100644 src/predict/useLatch.ts create mode 100644 src/predict/usePredict.ts create mode 100644 src/predict/usePredictLoop.ts create mode 100644 src/predict/useReconciler.ts create mode 100644 src/predict/useSessionEntity.ts create mode 100644 src/tests/predictHooks.test.tsx diff --git a/src/context/createRoomContext.ts b/src/context/createRoomContext.ts index 0c0c69c..9eb05e3 100644 --- a/src/context/createRoomContext.ts +++ b/src/context/createRoomContext.ts @@ -1,10 +1,20 @@ -import type { InferState, ExtractRoomClientMessages, NormalizeRoomType } from "@colyseus/shared-types"; +import type { InferState, InferInput, ExtractRoomClientMessages, NormalizeRoomType } from "@colyseus/shared-types"; import { useSyncExternalStore, useEffect, type ReactNode, type DependencyList } from "react"; -import { Room } from "@colyseus/sdk"; +import type { Room, Predict, PredictGetOptions, InputHandle, InputOptions, Reconciler, AttachConfig } from "@colyseus/sdk"; +// not re-exported from the sdk root — only from the /predict subpath +import type { PredictedEventChannel } from "@colyseus/sdk/predict"; import { useRoom as useRoomLifecycle, type UseRoomResult } from "../room/useRoom"; import { useRoomState as useRoomStateOriginal } from "../schema/useRoomState"; import { useRoomMessage as useRoomMessageStandalone } from "../room/useRoomMessage"; import type { Snapshot } from "../schema/createSnapshot"; +import { usePredict as usePredictStandalone } from "../predict/usePredict"; +import { useInput as useInputStandalone } from "../predict/useInput"; +import { useEntityInstance as useEntityInstanceStandalone } from "../predict/useEntityInstance"; +import { useSessionEntity as useSessionEntityStandalone } from "../predict/useSessionEntity"; +import { useAttachAll as useAttachAllStandalone } from "../predict/useAttachAll"; +import { useReconciler as useReconcilerStandalone, type UseReconcilerOptions } from "../predict/useReconciler"; +import { usePredictLoop as usePredictLoopStandalone, type PredictLoopOptions } from "../predict/usePredictLoop"; +import { useEventChannel as useEventChannelStandalone, type UseEventChannelOptions } from "../predict/useEventChannel"; interface RoomProviderProps { connect: (() => Promise>) | null | undefined | false; @@ -12,6 +22,17 @@ interface RoomProviderProps { children: ReactNode; } +/** Options for {@link createRoomContext}. */ +export interface CreateRoomContextOptions { + /** + * Options for the room's shared `Predict` instance (`@colyseus/sdk` 0.18+), + * applied when the instance is first created — e.g. + * `{ mode: "lerp", delay: 100 }`. Centralizing them here keeps every + * predict-aware hook of this context on the same instance configuration. + */ + predict?: PredictGetOptions; +} + /** * Creates a set of hooks and a Provider for sharing a Colyseus room * across React reconciler boundaries (e.g. DOM + React Three Fiber). @@ -38,7 +59,9 @@ interface RoomProviderProps { * room.send("action", data); * ``` */ -export function createRoomContext>() { +export function createRoomContext>( + contextOptions: CreateRoomContextOptions = {}, +) { // Closure-scoped external store — bridges reconciler boundaries. let snapshot: UseRoomResult = { room: undefined, error: undefined, isConnecting: true }; const listeners = new Set<() => void>(); @@ -114,5 +137,103 @@ export function createRoomContext>() { useRoomMessageStandalone(room as any, type as any, callback); } - return { RoomProvider, useRoom, useRoomState, useRoomMessage }; + /** The room resolved from the store (undefined while connecting). */ + function useStoreRoom(): Room | undefined { + return useSyncExternalStore(subscribe, getSnapshot).room; + } + + /** + * Returns the room's shared `Predict` instance, created with this + * context's `predict` options. See the standalone `usePredict` for details. + */ + function usePredict(): Predict | undefined { + return usePredictStandalone(useStoreRoom(), contextOptions.predict); + } + + /** + * Returns the room's `InputHandle` (stage on `input.data`, then `send()` + * once per fixed step). See the standalone `useInput` for details. + */ + function useInput] extends [never] ? any : InferInput>( + options?: InputOptions + ): InputHandle | undefined { + return useInputStandalone(useStoreRoom(), options); + } + + /** + * Selects a decoded schema instance from room state, re-rendering only when + * the selected identity changes. See the standalone `useEntityInstance`. + */ + function useEntityInstance( + select: (state: State, room: Room) => S | undefined + ): S | undefined { + return useEntityInstanceStandalone(useStoreRoom(), select); + } + + /** + * Returns your own decoded entity (`state[collectionKey].get(sessionId)`). + * See the standalone `useSessionEntity` for details. + */ + function useSessionEntity(collectionKey: Parameters>[1]) { + return useSessionEntityStandalone(useStoreRoom(), collectionKey); + } + + /** + * Attaches passive smoothing to a root-state collection, detaching on + * unmount. See the standalone `useAttachAll` for details. + */ + function useAttachAll( + key: keyof State & string, + config: AttachConfig, + deps?: DependencyList + ): void { + useAttachAllStandalone(useStoreRoom(), key, config, deps, contextOptions.predict); + } + + /** + * Runs active prediction for the entity you control, handling late spawn, + * instance replacement and disposal. See the standalone `useReconciler`. + */ + function useReconciler( + select: (state: State, room: Room) => S | undefined, + options: UseReconcilerOptions + ): Reconciler | undefined { + return useReconcilerStandalone(useStoreRoom(), select, options, contextOptions.predict); + } + + /** + * Owns the per-frame prediction driver (`predict.tick` + your send loop). + * See the standalone `usePredictLoop` for details. + */ + function usePredictLoop( + onSteps: (steps: number, now: number) => void, + options?: PredictLoopOptions + ): (now?: number) => void { + return usePredictLoopStandalone(useStoreRoom(), onSteps, options, contextOptions.predict); + } + + /** + * Owns an optimistic event channel with reactive settlement. + * See the standalone `useEventChannel` for details. + */ + function useEventChannel( + options: UseEventChannelOptions + ): PredictedEventChannel | undefined { + return useEventChannelStandalone(useStoreRoom(), options, contextOptions.predict); + } + + return { + RoomProvider, + useRoom, + useRoomState, + useRoomMessage, + usePredict, + useInput, + useEntityInstance, + useSessionEntity, + useAttachAll, + useReconciler, + usePredictLoop, + useEventChannel, + }; } diff --git a/src/index.ts b/src/index.ts index b78a3d4..1ecf5b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ export { useRoomState } from './schema/useRoomState'; export { useRoom } from './room/useRoom'; export { useRoomMessage } from './room/useRoomMessage'; -export { createRoomContext } from './context/createRoomContext'; +export { createRoomContext, type CreateRoomContextOptions } from './context/createRoomContext'; export { createLobbyContext } from './context/createLobbyContext'; export { useLobbyRoom } from './room/useLobbyRoom'; export { useQueueRoom } from './room/useQueueRoom'; @@ -9,3 +9,15 @@ export type { UseRoomResult } from './room/useRoom'; export type { UseLobbyRoomResult } from './room/useLobbyRoom'; export type { UseQueueRoomResult } from './room/useQueueRoom'; export type { IArray, IMap, Snapshot } from './schema/createSnapshot'; +export { getSource } from './schema/createSnapshot'; + +// Predict hooks (@colyseus/sdk 0.18+ netcode) +export { usePredict } from './predict/usePredict'; +export { useInput } from './predict/useInput'; +export { useEntityInstance } from './predict/useEntityInstance'; +export { useSessionEntity } from './predict/useSessionEntity'; +export { useAttachAll } from './predict/useAttachAll'; +export { useReconciler, type UseReconcilerOptions } from './predict/useReconciler'; +export { usePredictLoop, type PredictLoopOptions } from './predict/usePredictLoop'; +export { useEventChannel, type UseEventChannelOptions } from './predict/useEventChannel'; +export { useLatch, type Latch } from './predict/useLatch'; diff --git a/src/predict/predictRegistry.ts b/src/predict/predictRegistry.ts new file mode 100644 index 0000000..87b1259 --- /dev/null +++ b/src/predict/predictRegistry.ts @@ -0,0 +1,56 @@ +import { Predict, type Room, type PredictGetOptions } from "@colyseus/sdk"; + +interface PredictEntry { + predict: Predict; + refCount: number; + disposeTimeout: ReturnType | null; +} + +/** + * Shared Predict instances, one per room. `Predict.get()` constructs a fresh, + * independent instance on every call, so sharing across hooks/components has + * to happen here. + */ +const entriesByRoom = new WeakMap(); + +/** + * Returns the room's shared `Predict`, creating it on first acquire. + * `options` only apply at creation (first caller wins) — pass them + * consistently, or centralize them via `createRoomContext({ predict })`. + */ +export function acquirePredict(room: Room, options?: PredictGetOptions): Predict { + let entry = entriesByRoom.get(room); + if (!entry) { + entry = { + predict: Predict.get(room as never, options) as unknown as Predict, + refCount: 0, + disposeTimeout: null, + }; + entriesByRoom.set(room, entry); + } + if (entry.disposeTimeout !== null) { + clearTimeout(entry.disposeTimeout); + entry.disposeTimeout = null; + } + entry.refCount++; + return entry.predict as Predict; +} + +/** + * Releases one acquire. Disposal is deferred a tick so a StrictMode + * unmount/remount pair reuses the instance instead of recreating it. + */ +export function releasePredict(room: Room): void { + const entry = entriesByRoom.get(room); + if (!entry) return; + entry.refCount--; + if (entry.refCount <= 0 && entry.disposeTimeout === null) { + entry.disposeTimeout = setTimeout(() => { + entry.disposeTimeout = null; + if (entry.refCount <= 0) { + entry.predict.dispose(); + entriesByRoom.delete(room); + } + }, 0); + } +} diff --git a/src/predict/useAttachAll.ts b/src/predict/useAttachAll.ts new file mode 100644 index 0000000..0c96444 --- /dev/null +++ b/src/predict/useAttachAll.ts @@ -0,0 +1,45 @@ +import type { InferState } from "@colyseus/shared-types"; +import type { Room, AttachConfig, PredictGetOptions } from "@colyseus/sdk"; +import { useEffect, useRef, type DependencyList } from "react"; +import { usePredict } from "./usePredict"; + +/** + * React hook that attaches passive smoothing to every entity of a root-state + * collection — the hook form of `predict.attachAll(key, config)`, with the + * detach handled on unmount / key change. + * + * `config` is captured when the attach runs; pass `deps` to force a + * re-attach when configuration values change (like `useEffect` deps). + * Remember to attach angular fields (`angle: true`) in a separate call from + * linear ones. + * + * @param room - The connected room (or undefined/null while connecting) + * @param key - Root-state key of the collection (e.g. `"players"`) + * @param config - Attach config (`{ mode: "lerp", fields: ["x", "y"] }`) + * @param deps - Optional dependency list that re-attaches on change + * + * @example + * ```tsx + * useAttachAll(room, "players", { mode: "lerp", fields: ["x", "y", "z"] }); + * useAttachAll(room, "players", { mode: "lerp", fields: ["heading"], angle: true }); + * ``` + */ +export function useAttachAll>( + room: Room | null | undefined, + key: keyof State & string, + config: AttachConfig, + deps: DependencyList = [], + predictOptions?: PredictGetOptions, +): void { + const predict = usePredict(room, predictOptions); + + // Captured at attach time; `deps` forces a re-attach. + const configRef = useRef(config); + configRef.current = config; + + useEffect(() => { + if (!predict) return; + return predict.attachAll(key as never, configRef.current as never); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [predict, key, ...deps]); +} diff --git a/src/predict/useEntityInstance.ts b/src/predict/useEntityInstance.ts new file mode 100644 index 0000000..325b522 --- /dev/null +++ b/src/predict/useEntityInstance.ts @@ -0,0 +1,51 @@ +import type { InferState } from "@colyseus/shared-types"; +import { type Room, SchemaSerializer } from "@colyseus/sdk"; +import { useCallback, useRef, useSyncExternalStore } from "react"; +import { getOrCreateSubscription } from "../schema/getOrCreateSubscription"; + +/** + * React hook that selects a **decoded schema instance** from room state and + * re-renders only when the selected identity changes (appears, is replaced, + * or is removed). + * + * Unlike `useRoomState` — which returns immutable snapshots — this returns + * the live decoded instance, which is what the Predict APIs key off + * (`predict.value(instance, field)`, `predict.reconciler(instance, ...)`). + * + * The selector must return a stable reference (a schema instance, collection, + * or primitive), never a freshly-allocated object — results are compared with + * `Object.is` on every state change. + * + * @param room - The connected room (or undefined/null while connecting) + * @param select - Selects the instance: `(state, room) => instance` + * @returns The selected instance, or undefined while unavailable + * + * @example + * ```tsx + * const me = useEntityInstance(room, (s, r) => s.players.get(r.sessionId)); + * ``` + */ +export function useEntityInstance, S = unknown>( + room: Room | null | undefined, + select: (state: State, room: Room) => S | undefined, +): S | undefined { + const serializer = room?.serializer as SchemaSerializer | undefined; + const decoder = serializer?.decoder; + const state = room?.state; + + const selectRef = useRef(select); + selectRef.current = select; + + const subscribe = useCallback((callback: () => void) => { + if (!state || !decoder) return () => { }; + const subscription = getOrCreateSubscription(state as never, decoder); + subscription.listeners.add(callback); + return () => subscription.listeners.delete(callback); + }, [state, decoder]); + + const getSnapshot = useCallback(() => { + return (room && state) ? selectRef.current(state, room) : undefined; + }, [room, state]); + + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/src/predict/useEventChannel.ts b/src/predict/useEventChannel.ts new file mode 100644 index 0000000..2b69fcd --- /dev/null +++ b/src/predict/useEventChannel.ts @@ -0,0 +1,87 @@ +import type { InferState } from "@colyseus/shared-types"; +import type { Room, PredictGetOptions } from "@colyseus/sdk"; +// not re-exported from the sdk root — only from the /predict subpath +import type { PredictedEventChannel, PredictedEventChannelOptions, ConfirmOn } from "@colyseus/sdk/predict"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { usePredict } from "./usePredict"; + +/** Options for {@link useEventChannel}: the SDK channel options plus `confirmOn`. */ +export type UseEventChannelOptions = + PredictedEventChannelOptions & { confirmOn?: ConfirmOn }; + +/** + * React hook that owns an optimistic event channel — the hook form of + * `predict.defineEvent(options)`, with the channel's schema listeners torn + * down on unmount, **plus reactivity**: the component calling this hook + * re-renders whenever the channel settles (predict / confirm / reject / + * unpredicted), so flag-shaped derives read naturally during render: + * + * ```tsx + * const pickups = useEventChannel(room, { + * confirmOn: { collection: "items", field: "alive", equals: false }, + * }); + * const hidden = !item.alive || pickups?.has(id); // reactive + * ``` + * + * The returned object is the real SDK channel — hand it to `ctx.predict` + * inside a reconciler step, or call `channel.predict(payload)` from UI. + * + * Only the component calling the hook re-renders on channel activity; create + * the channel where you render from it (or lift it and pass state down). + * `options` are captured at channel creation. + * + * @param room - The connected room (or undefined/null while connecting) + * @param options - `defineEvent` options (callbacks, `confirmOn`, `uniqueBy`, ...) + * @returns The channel, or undefined while there is no room + */ +export function useEventChannel>( + room: Room | null | undefined, + options: UseEventChannelOptions, + predictOptions?: PredictGetOptions, +): PredictedEventChannel | undefined { + const predict = usePredict(room, predictOptions); + + // Captured when the channel is created. + const optionsRef = useRef(options); + optionsRef.current = options; + + const [channel, setChannel] = useState>(); + + // Local mini-store: injected callback wrappers bump the version so the + // calling component re-renders on channel activity. + const versionRef = useRef(0); + const listenersRef = useRef(new Set<() => void>()); + + useEffect(() => { + if (!predict) return; + + const opts = optionsRef.current; + const notify = () => { + versionRef.current++; + listenersRef.current.forEach((listener) => listener()); + }; + const wrap = (fn?: (...args: A) => void) => + (...args: A) => { notify(); fn?.(...args); }; + + const ch = predict.defineEvent({ + ...opts, + onPredict: wrap(opts.onPredict), + onConfirm: wrap(opts.onConfirm), + onReject: wrap(opts.onReject), + onUnpredicted: wrap(opts.onUnpredicted), + } as never); + setChannel(ch); + return () => { + ch.dispose(); + setChannel((current) => (current === ch ? undefined : current)); + }; + }, [predict]); + + const subscribe = useCallback((callback: () => void) => { + listenersRef.current.add(callback); + return () => listenersRef.current.delete(callback); + }, []); + useSyncExternalStore(subscribe, () => versionRef.current); + + return channel; +} diff --git a/src/predict/useInput.ts b/src/predict/useInput.ts new file mode 100644 index 0000000..3a10de9 --- /dev/null +++ b/src/predict/useInput.ts @@ -0,0 +1,47 @@ +import type { InferState, InferInput } from "@colyseus/shared-types"; +import type { Room, InputHandle, InputOptions } from "@colyseus/sdk"; +import { useMemo, useRef } from "react"; + +/** + * React hook that returns the room's `InputHandle` — the single surface that + * stages and sends input (`@colyseus/sdk` 0.18+). + * + * `room.input()` is idempotent (later calls return the same handle), so the + * handle is stable for the lifetime of the room. Stage fields on + * `input.data`, then call `input.send()` once per fixed step — typically from + * the `usePredictLoop` callback. + * + * `options` are applied when the handle is first resolved for a room. + * + * @param room - The connected room (or undefined/null while connecting) + * @param options - `room.input()` options (`type`, `mode`, `historySize`, ...) + * @returns The room's input handle, or undefined while there is no room + * + * @example + * ```tsx + * const input = useInput(room); + * // per fixed step: + * input.data.moveX = axis(); + * input.send(); + * ``` + */ +export function useInput< + T = any, + State = InferState, + I = [InferInput] extends [never] ? any : InferInput, +>( + room: Room | null | undefined, + options?: InputOptions, +): InputHandle | undefined { + // Options only matter when the handle is first created; keep them out of deps. + const optionsRef = useRef(options); + optionsRef.current = options; + + return useMemo( + // the typeof guard tolerates pre-0.18 rooms and room-like test doubles + () => (room && typeof room.input === "function" + ? (room.input(optionsRef.current as never) as InputHandle) + : undefined), + [room], + ); +} diff --git a/src/predict/useLatch.ts b/src/predict/useLatch.ts new file mode 100644 index 0000000..e9d9675 --- /dev/null +++ b/src/predict/useLatch.ts @@ -0,0 +1,49 @@ +import { useMemo, useRef } from "react"; + +/** + * An edge latch bridging React events to fixed-step input sampling. + * See {@link useLatch}. + */ +export interface Latch { + /** Record that the tap/press happened (call from the React event handler). */ + latch(): void; + /** Read-and-clear: returns true at most once per latch (call inside the step loop). */ + consume(): boolean; + /** Read without clearing. */ + peek(): boolean; +} + +/** + * React hook for the "latch, then consume" input recipe. + * + * Input is sampled once per **fixed step**, not per render frame — a tap on a + * 0-step frame must not be lost, and a tap spanning a multi-step frame must + * not fire twice. Latch the press in the event handler; consume it inside the + * send loop so it lands on exactly one step: + * + * ```tsx + * const jump = useLatch(); + *