@zvk/game-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +38 -0
  4. package/dist/core/channel.d.ts +67 -0
  5. package/dist/core/channel.js +153 -0
  6. package/dist/core/coalesced-publisher.d.ts +35 -0
  7. package/dist/core/coalesced-publisher.js +127 -0
  8. package/dist/core/game-bridge.d.ts +60 -0
  9. package/dist/core/game-bridge.js +118 -0
  10. package/dist/core/intents.d.ts +33 -0
  11. package/dist/core/intents.js +43 -0
  12. package/dist/core/serializable.d.ts +3 -0
  13. package/dist/core/serializable.js +71 -0
  14. package/dist/core/types.d.ts +17 -0
  15. package/dist/core/types.js +1 -0
  16. package/dist/dom/index.d.ts +68 -0
  17. package/dist/dom/index.js +244 -0
  18. package/dist/geometry/anchors.d.ts +48 -0
  19. package/dist/geometry/anchors.js +96 -0
  20. package/dist/geometry/index.d.ts +6 -0
  21. package/dist/geometry/index.js +6 -0
  22. package/dist/geometry/matrix.d.ts +45 -0
  23. package/dist/geometry/matrix.js +77 -0
  24. package/dist/geometry/placement.d.ts +24 -0
  25. package/dist/geometry/placement.js +49 -0
  26. package/dist/geometry/projection.d.ts +118 -0
  27. package/dist/geometry/projection.js +194 -0
  28. package/dist/geometry/types.d.ts +62 -0
  29. package/dist/geometry/types.js +79 -0
  30. package/dist/geometry/visibility.d.ts +30 -0
  31. package/dist/geometry/visibility.js +128 -0
  32. package/dist/index.d.ts +6 -0
  33. package/dist/index.js +5 -0
  34. package/dist/phaser/index.d.ts +187 -0
  35. package/dist/phaser/index.js +347 -0
  36. package/dist/react/index.d.ts +20 -0
  37. package/dist/react/index.js +47 -0
  38. package/dist/test-utils/assertions.d.ts +2 -0
  39. package/dist/test-utils/assertions.js +9 -0
  40. package/dist/test-utils/fixtures.d.ts +7 -0
  41. package/dist/test-utils/fixtures.js +9 -0
  42. package/dist/test-utils/index.d.ts +3 -0
  43. package/dist/test-utils/index.js +3 -0
  44. package/dist/test-utils/manual-scheduler.d.ts +10 -0
  45. package/dist/test-utils/manual-scheduler.js +43 -0
  46. package/docs/content/core-contracts.md +5 -0
  47. package/docs/content/dom.md +5 -0
  48. package/docs/content/errors-and-performance.md +5 -0
  49. package/docs/content/geometry.md +5 -0
  50. package/docs/content/index.md +5 -0
  51. package/docs/content/migration.md +5 -0
  52. package/docs/content/phaser.md +5 -0
  53. package/docs/content/react.md +5 -0
  54. package/docs/examples/react-phaser-overlay.tsx +14 -0
  55. package/package.json +109 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Add the initial headless game bridge, geometry, DOM, React, Phaser, and test utility contracts.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Schabel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @zvk/game-bridge
2
+
3
+ `@zvk/game-bridge` is a headless boundary between a browser-game runtime and DOM UI. It owns immutable external-store channels, typed UI intents, exact affine projection, and optional DOM, React, and structurally typed Phaser adapters. Games keep simulation, entity selection, camera policy, rendering, persistence, and action semantics.
4
+
5
+ ```sh
6
+ bun add @zvk/game-bridge
7
+ # Add react and/or phaser only when using those adapter subpaths.
8
+ ```
9
+
10
+ ## Two lanes
11
+
12
+ The lower-frequency `state` lane carries app-defined HUD/menu data. The high-frequency `frame` lane atomically carries one transform and its selected projected anchors. Publishing one lane never notifies subscribers to the other.
13
+
14
+ ```ts
15
+ import { createGameBridge } from "@zvk/game-bridge";
16
+
17
+ const { bridge, producer } = createGameBridge({
18
+ initialState: { score: 0 },
19
+ onIntent: ({ intent }) => runtime.dispatch(intent)
20
+ });
21
+
22
+ producer.state.publish({ score: 1 });
23
+ const stop = bridge.state.subscribe(() => render(bridge.state.getSnapshot()));
24
+ bridge.intents.dispatch({ type: "menu.close" });
25
+ stop();
26
+ ```
27
+
28
+ React consumers use `@zvk/game-bridge/react`; Phaser connectors use `@zvk/game-bridge/phaser`; pure math lives at `@zvk/game-bridge/geometry`; browser measurement lives at `@zvk/game-bridge/dom`; deterministic fixtures live at `@zvk/game-bridge/test-utils`.
29
+
30
+ For a world overlay, join a projected anchor ID from `bridge.frame` to semantic state from `bridge.state`, then map `anchor.overlayPoint` into an app component or `@zvk/game-ui/canvas-overlay-layer`. The packages integrate through plain data and do not depend on each other.
31
+
32
+ The package is SSR/import safe: root and geometry need no optional peers or browser globals; React uses a fixed server snapshot; DOM and Phaser work begins only when their explicit factories are called. Cleanup is idempotent, frame work is latest-value-wins, and runtime failures return discriminated data without implicit logging.
33
+
34
+ See [package overview](docs/content/index.md), [geometry](docs/content/geometry.md), [Phaser compatibility](docs/content/phaser.md), and [migration](docs/content/migration.md).
35
+
36
+ ## Non-goals
37
+
38
+ This is not an engine, ECS, simulation store, renderer, UI kit, event bus, entity registry, persistence layer, or networking system. It publishes only app-selected plain-data anchors and intents.
@@ -0,0 +1,67 @@
1
+ export type BridgeListener = () => void;
2
+ export type BridgeUnsubscribe = () => void;
3
+ export type BridgeEquality<T> = (left: T, right: T) => boolean;
4
+ export type BridgeChannelLifecycle = "open" | "closed";
5
+ export interface BridgeChannelStatus {
6
+ readonly lifecycle: BridgeChannelLifecycle;
7
+ readonly revision: number;
8
+ readonly acceptedPublications: number;
9
+ readonly skippedPublications: number;
10
+ readonly listenerCount: number;
11
+ readonly closeReason?: string;
12
+ }
13
+ export type BridgePublishResult = {
14
+ readonly status: "published";
15
+ readonly revision: number;
16
+ } | {
17
+ readonly status: "unchanged";
18
+ readonly revision: number;
19
+ } | {
20
+ readonly status: "closed";
21
+ readonly revision: number;
22
+ readonly reason?: string;
23
+ } | {
24
+ readonly status: "queued";
25
+ readonly revision: number;
26
+ } | {
27
+ readonly status: "rejected";
28
+ readonly revision: number;
29
+ readonly reason: "equality-threw";
30
+ readonly error: unknown;
31
+ };
32
+ export type BridgeCloseResult = {
33
+ readonly status: "closed";
34
+ readonly revision: number;
35
+ } | {
36
+ readonly status: "already-closed";
37
+ readonly revision: number;
38
+ };
39
+ export interface BridgeReader<TSnapshot> {
40
+ readonly getSnapshot: () => TSnapshot;
41
+ readonly getServerSnapshot: () => TSnapshot;
42
+ readonly subscribe: (listener: BridgeListener) => BridgeUnsubscribe;
43
+ readonly getRevision: () => number;
44
+ readonly getStatus: () => BridgeChannelStatus;
45
+ }
46
+ export interface BridgePublisher<TSnapshot> {
47
+ readonly publish: (snapshot: TSnapshot) => BridgePublishResult;
48
+ readonly close: (reason?: string) => BridgeCloseResult;
49
+ }
50
+ export interface BridgeChannel<TSnapshot> {
51
+ readonly reader: BridgeReader<TSnapshot>;
52
+ readonly publisher: BridgePublisher<TSnapshot>;
53
+ }
54
+ export interface CreateBridgeChannelOptions<TSnapshot> {
55
+ readonly initialSnapshot: TSnapshot;
56
+ readonly serverSnapshot?: TSnapshot;
57
+ readonly equals?: BridgeEquality<TSnapshot>;
58
+ readonly onSubscriberError?: (error: unknown, context: {
59
+ readonly revision: number;
60
+ readonly listenerIndex: number;
61
+ }) => void;
62
+ readonly onPublicationError?: (error: unknown, context: {
63
+ readonly phase: "equality";
64
+ readonly revision: number;
65
+ }) => void;
66
+ }
67
+ export declare function createBridgeChannel<TSnapshot>(options: CreateBridgeChannelOptions<TSnapshot>): BridgeChannel<TSnapshot>;
@@ -0,0 +1,153 @@
1
+ function throwAsynchronously(error) {
2
+ queueMicrotask(() => {
3
+ throw error;
4
+ });
5
+ }
6
+ function assertOptionalFunction(value, name) {
7
+ if (value !== undefined && typeof value !== "function") {
8
+ throw new TypeError(`${name} must be a function when provided`);
9
+ }
10
+ }
11
+ export function createBridgeChannel(options) {
12
+ if (options === null || typeof options !== "object") {
13
+ throw new TypeError("createBridgeChannel options must be an object");
14
+ }
15
+ assertOptionalFunction(options.equals, "equals");
16
+ assertOptionalFunction(options.onSubscriberError, "onSubscriberError");
17
+ assertOptionalFunction(options.onPublicationError, "onPublicationError");
18
+ const equals = options.equals ?? Object.is;
19
+ const serverSnapshot = options.serverSnapshot ?? options.initialSnapshot;
20
+ let snapshot = options.initialSnapshot;
21
+ let revision = 0;
22
+ let acceptedPublications = 0;
23
+ let skippedPublications = 0;
24
+ let lifecycle = "open";
25
+ let closeReason;
26
+ let listeners = [];
27
+ let notifying = false;
28
+ const publicationQueue = [];
29
+ const reportPublicationError = (error) => {
30
+ if (options.onPublicationError === undefined) {
31
+ return;
32
+ }
33
+ try {
34
+ options.onPublicationError(error, { phase: "equality", revision });
35
+ }
36
+ catch (observerError) {
37
+ throwAsynchronously(observerError);
38
+ }
39
+ };
40
+ const notify = () => {
41
+ const stableListeners = listeners.slice();
42
+ notifying = true;
43
+ try {
44
+ for (let index = 0; index < stableListeners.length; index += 1) {
45
+ const entry = stableListeners[index];
46
+ if (entry === undefined)
47
+ continue;
48
+ try {
49
+ entry.listener();
50
+ }
51
+ catch (error) {
52
+ if (options.onSubscriberError === undefined) {
53
+ throwAsynchronously(error);
54
+ continue;
55
+ }
56
+ try {
57
+ options.onSubscriberError(error, { revision, listenerIndex: index });
58
+ }
59
+ catch (observerError) {
60
+ throwAsynchronously(observerError);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ finally {
66
+ notifying = false;
67
+ }
68
+ };
69
+ const commit = (candidate) => {
70
+ let unchanged;
71
+ try {
72
+ unchanged = equals(snapshot, candidate);
73
+ }
74
+ catch (error) {
75
+ skippedPublications += 1;
76
+ reportPublicationError(error);
77
+ return { status: "rejected", revision, reason: "equality-threw", error };
78
+ }
79
+ if (unchanged) {
80
+ skippedPublications += 1;
81
+ return { status: "unchanged", revision };
82
+ }
83
+ snapshot = candidate;
84
+ revision += 1;
85
+ acceptedPublications += 1;
86
+ notify();
87
+ return { status: "published", revision };
88
+ };
89
+ const drain = () => {
90
+ while (lifecycle === "open" && publicationQueue.length > 0) {
91
+ const candidate = publicationQueue.shift();
92
+ commit(candidate);
93
+ }
94
+ if (lifecycle === "closed")
95
+ publicationQueue.length = 0;
96
+ };
97
+ const publish = (candidate) => {
98
+ if (lifecycle === "closed") {
99
+ return closeReason === undefined
100
+ ? { status: "closed", revision }
101
+ : { status: "closed", revision, reason: closeReason };
102
+ }
103
+ if (notifying) {
104
+ publicationQueue.push(candidate);
105
+ return { status: "queued", revision };
106
+ }
107
+ const result = commit(candidate);
108
+ drain();
109
+ return result;
110
+ };
111
+ const close = (reason) => {
112
+ if (lifecycle === "closed")
113
+ return { status: "already-closed", revision };
114
+ lifecycle = "closed";
115
+ closeReason = reason;
116
+ publicationQueue.length = 0;
117
+ listeners = [];
118
+ return { status: "closed", revision };
119
+ };
120
+ const reader = {
121
+ getSnapshot: () => snapshot,
122
+ getServerSnapshot: () => serverSnapshot,
123
+ subscribe: (listener) => {
124
+ if (typeof listener !== "function")
125
+ throw new TypeError("listener must be a function");
126
+ if (lifecycle === "closed")
127
+ return () => { };
128
+ const entry = { listener, active: true };
129
+ listeners.push(entry);
130
+ return () => {
131
+ if (!entry.active)
132
+ return;
133
+ entry.active = false;
134
+ const index = listeners.indexOf(entry);
135
+ if (index >= 0)
136
+ listeners.splice(index, 1);
137
+ };
138
+ },
139
+ getRevision: () => revision,
140
+ getStatus: () => {
141
+ const status = {
142
+ lifecycle,
143
+ revision,
144
+ acceptedPublications,
145
+ skippedPublications,
146
+ listenerCount: listeners.length,
147
+ ...(closeReason === undefined ? {} : { closeReason }),
148
+ };
149
+ return status;
150
+ },
151
+ };
152
+ return { reader, publisher: { publish, close } };
153
+ }
@@ -0,0 +1,35 @@
1
+ import type { BridgeCloseResult, BridgePublisher, BridgePublishResult } from "./channel.js";
2
+ export interface BridgeScheduler<THandle = unknown> {
3
+ readonly request: (flush: () => void) => THandle;
4
+ readonly cancel: (handle: THandle) => void;
5
+ }
6
+ export type BridgeStageResult = {
7
+ readonly status: "scheduled";
8
+ } | {
9
+ readonly status: "replaced-pending";
10
+ } | {
11
+ readonly status: "scheduler-error";
12
+ readonly error: unknown;
13
+ } | {
14
+ readonly status: "closed";
15
+ readonly reason?: string;
16
+ };
17
+ export type BridgeFlushResult = BridgePublishResult | {
18
+ readonly status: "empty";
19
+ };
20
+ export type BridgeCancelResult = {
21
+ readonly status: "cancelled";
22
+ } | {
23
+ readonly status: "empty";
24
+ } | {
25
+ readonly status: "closed";
26
+ };
27
+ export interface CoalescedPublisher<TSnapshot> {
28
+ readonly stage: (snapshot: TSnapshot) => BridgeStageResult;
29
+ readonly flush: () => BridgeFlushResult;
30
+ readonly cancel: () => BridgeCancelResult;
31
+ readonly close: (reason?: string) => BridgeCloseResult;
32
+ }
33
+ export declare function createCoalescedPublisher<TSnapshot, THandle = unknown>(publisher: BridgePublisher<TSnapshot>, scheduler: BridgeScheduler<THandle>, options?: {
34
+ readonly onSchedulerError?: (error: unknown, phase: "request" | "cancel") => void;
35
+ }): CoalescedPublisher<TSnapshot>;
@@ -0,0 +1,127 @@
1
+ function throwAsynchronously(error) {
2
+ queueMicrotask(() => {
3
+ throw error;
4
+ });
5
+ }
6
+ export function createCoalescedPublisher(publisher, scheduler, options = {}) {
7
+ if (publisher === null || typeof publisher !== "object" || typeof publisher.publish !== "function" || typeof publisher.close !== "function") {
8
+ throw new TypeError("publisher must provide publish and close functions");
9
+ }
10
+ if (scheduler === null || typeof scheduler !== "object" || typeof scheduler.request !== "function" || typeof scheduler.cancel !== "function") {
11
+ throw new TypeError("scheduler must provide request and cancel functions");
12
+ }
13
+ if (options.onSchedulerError !== undefined && typeof options.onSchedulerError !== "function") {
14
+ throw new TypeError("onSchedulerError must be a function when provided");
15
+ }
16
+ let staged;
17
+ let hasStaged = false;
18
+ let handle;
19
+ let hasHandle = false;
20
+ let closed = false;
21
+ let closeReason;
22
+ let generation = 0;
23
+ const reportSchedulerError = (error, phase) => {
24
+ if (options.onSchedulerError === undefined) {
25
+ throwAsynchronously(error);
26
+ return;
27
+ }
28
+ try {
29
+ options.onSchedulerError(error, phase);
30
+ }
31
+ catch (observerError) {
32
+ throwAsynchronously(observerError);
33
+ }
34
+ };
35
+ const clearPending = () => {
36
+ staged = undefined;
37
+ hasStaged = false;
38
+ handle = undefined;
39
+ hasHandle = false;
40
+ };
41
+ const cancelHandle = () => {
42
+ if (!hasHandle)
43
+ return;
44
+ const pendingHandle = handle;
45
+ handle = undefined;
46
+ hasHandle = false;
47
+ try {
48
+ scheduler.cancel(pendingHandle);
49
+ }
50
+ catch (error) {
51
+ reportSchedulerError(error, "cancel");
52
+ }
53
+ };
54
+ const commitStaged = () => {
55
+ if (!hasStaged)
56
+ return { status: "empty" };
57
+ const candidate = staged;
58
+ staged = undefined;
59
+ hasStaged = false;
60
+ return publisher.publish(candidate);
61
+ };
62
+ const stage = (snapshot) => {
63
+ if (closed) {
64
+ return closeReason === undefined
65
+ ? { status: "closed" }
66
+ : { status: "closed", reason: closeReason };
67
+ }
68
+ if (hasStaged) {
69
+ staged = snapshot;
70
+ return { status: "replaced-pending" };
71
+ }
72
+ staged = snapshot;
73
+ hasStaged = true;
74
+ const requestedGeneration = generation;
75
+ let callbackRanSynchronously = false;
76
+ try {
77
+ const requestedHandle = scheduler.request(() => {
78
+ callbackRanSynchronously = true;
79
+ if (closed || requestedGeneration !== generation)
80
+ return;
81
+ handle = undefined;
82
+ hasHandle = false;
83
+ commitStaged();
84
+ });
85
+ if (!callbackRanSynchronously) {
86
+ handle = requestedHandle;
87
+ hasHandle = true;
88
+ }
89
+ return { status: "scheduled" };
90
+ }
91
+ catch (error) {
92
+ clearPending();
93
+ reportSchedulerError(error, "request");
94
+ return { status: "scheduler-error", error };
95
+ }
96
+ };
97
+ const flush = () => {
98
+ if (!hasStaged)
99
+ return { status: "empty" };
100
+ generation += 1;
101
+ cancelHandle();
102
+ return commitStaged();
103
+ };
104
+ const cancel = () => {
105
+ if (closed)
106
+ return { status: "closed" };
107
+ if (!hasStaged && !hasHandle)
108
+ return { status: "empty" };
109
+ generation += 1;
110
+ cancelHandle();
111
+ staged = undefined;
112
+ hasStaged = false;
113
+ return { status: "cancelled" };
114
+ };
115
+ const close = (reason) => {
116
+ if (closed)
117
+ return publisher.close(reason);
118
+ closed = true;
119
+ closeReason = reason;
120
+ generation += 1;
121
+ cancelHandle();
122
+ staged = undefined;
123
+ hasStaged = false;
124
+ return publisher.close(reason);
125
+ };
126
+ return { stage, flush, cancel, close };
127
+ }
@@ -0,0 +1,60 @@
1
+ import type { GameBridgeTransformSnapshotV1, ProjectedWorldAnchor } from "../geometry/index.js";
2
+ import type { BridgeEquality, BridgePublisher, BridgeReader } from "./channel.js";
3
+ import type { IntentEnvelope, IntentSink } from "./intents.js";
4
+ import type { GameBridgeDiagnostic, JsonValue } from "./types.js";
5
+ export type GameBridgeFrameStatus = "idle" | "not-ready" | "ready" | "disconnected" | "destroyed";
6
+ export interface GameBridgeFrame<TAnchorMetadata extends JsonValue = JsonValue> {
7
+ readonly protocolVersion: 1;
8
+ readonly sourceFrameId: number;
9
+ readonly capturedAtMs: number;
10
+ readonly clockId: string;
11
+ readonly status: GameBridgeFrameStatus;
12
+ readonly transform: GameBridgeTransformSnapshotV1 | null;
13
+ readonly anchors: readonly ProjectedWorldAnchor<TAnchorMetadata>[];
14
+ readonly diagnostics: readonly GameBridgeDiagnostic[];
15
+ }
16
+ export declare function createIdleGameBridgeFrame<TAnchorMetadata extends JsonValue = JsonValue>(options?: {
17
+ readonly sourceFrameId?: number;
18
+ readonly capturedAtMs?: number;
19
+ readonly clockId?: string;
20
+ }): GameBridgeFrame<TAnchorMetadata>;
21
+ export interface CreateGameBridgeOptions<TState, TIntent extends JsonValue, TAnchorMetadata extends JsonValue = JsonValue> {
22
+ readonly initialState: TState;
23
+ readonly serverState?: TState;
24
+ readonly stateEquals?: BridgeEquality<TState>;
25
+ readonly initialFrame?: GameBridgeFrame<TAnchorMetadata>;
26
+ readonly onIntent?: (envelope: IntentEnvelope<TIntent>) => void;
27
+ readonly now?: () => number;
28
+ readonly onSubscriberError?: (error: unknown, context: {
29
+ readonly lane: "state" | "frame";
30
+ readonly revision: number;
31
+ readonly listenerIndex: number;
32
+ }) => void;
33
+ }
34
+ export interface GameBridgeStatus {
35
+ readonly lifecycle: "open" | "destroyed";
36
+ readonly stateRevision: number;
37
+ readonly frameRevision: number;
38
+ readonly nextIntentSequence: number;
39
+ }
40
+ export type GameBridgeDestroyResult = {
41
+ readonly status: "destroyed";
42
+ } | {
43
+ readonly status: "already-destroyed";
44
+ };
45
+ export interface GameBridge<TState, TIntent extends JsonValue, TAnchorMetadata extends JsonValue = JsonValue> {
46
+ readonly state: BridgeReader<TState>;
47
+ readonly frame: BridgeReader<GameBridgeFrame<TAnchorMetadata>>;
48
+ readonly intents: IntentSink<TIntent>;
49
+ readonly getStatus: () => GameBridgeStatus;
50
+ readonly destroy: () => GameBridgeDestroyResult;
51
+ }
52
+ export interface GameBridgeProducer<TState, TAnchorMetadata extends JsonValue = JsonValue> {
53
+ readonly state: BridgePublisher<TState>;
54
+ readonly frame: BridgePublisher<GameBridgeFrame<TAnchorMetadata>>;
55
+ }
56
+ export interface CreatedGameBridge<TState, TIntent extends JsonValue, TAnchorMetadata extends JsonValue = JsonValue> {
57
+ readonly bridge: GameBridge<TState, TIntent, TAnchorMetadata>;
58
+ readonly producer: GameBridgeProducer<TState, TAnchorMetadata>;
59
+ }
60
+ export declare function createGameBridge<TState, TIntent extends JsonValue, TAnchorMetadata extends JsonValue = JsonValue>(options: CreateGameBridgeOptions<TState, TIntent, TAnchorMetadata>): CreatedGameBridge<TState, TIntent, TAnchorMetadata>;
@@ -0,0 +1,118 @@
1
+ import { createBridgeChannel } from "./channel.js";
2
+ import { createIntentSink } from "./intents.js";
3
+ export function createIdleGameBridgeFrame(options = {}) {
4
+ const sourceFrameId = options.sourceFrameId ?? 0;
5
+ const capturedAtMs = options.capturedAtMs ?? 0;
6
+ const clockId = options.clockId ?? "bridge-idle";
7
+ if (!Number.isSafeInteger(sourceFrameId) || sourceFrameId < 0) {
8
+ throw new RangeError("sourceFrameId must be a non-negative safe integer");
9
+ }
10
+ if (!Number.isFinite(capturedAtMs))
11
+ throw new RangeError("capturedAtMs must be finite");
12
+ if (clockId.length === 0)
13
+ throw new RangeError("clockId must not be empty");
14
+ return {
15
+ protocolVersion: 1,
16
+ sourceFrameId,
17
+ capturedAtMs,
18
+ clockId,
19
+ status: "idle",
20
+ transform: null,
21
+ anchors: [],
22
+ diagnostics: [],
23
+ };
24
+ }
25
+ export function createGameBridge(options) {
26
+ if (options === null || typeof options !== "object") {
27
+ throw new TypeError("createGameBridge options must be an object");
28
+ }
29
+ if (options.onSubscriberError !== undefined && typeof options.onSubscriberError !== "function") {
30
+ throw new TypeError("onSubscriberError must be a function when provided");
31
+ }
32
+ const stateChannel = createBridgeChannel({
33
+ initialSnapshot: options.initialState,
34
+ ...(options.serverState === undefined ? {} : { serverSnapshot: options.serverState }),
35
+ ...(options.stateEquals === undefined ? {} : { equals: options.stateEquals }),
36
+ ...(options.onSubscriberError === undefined
37
+ ? {}
38
+ : {
39
+ onSubscriberError: (error, context) => options.onSubscriberError?.(error, { lane: "state", ...context }),
40
+ }),
41
+ });
42
+ const initialFrame = options.initialFrame ?? createIdleGameBridgeFrame();
43
+ const frameChannel = createBridgeChannel({
44
+ initialSnapshot: initialFrame,
45
+ ...(options.onSubscriberError === undefined
46
+ ? {}
47
+ : {
48
+ onSubscriberError: (error, context) => options.onSubscriberError?.(error, { lane: "frame", ...context }),
49
+ }),
50
+ });
51
+ const intentPort = createIntentSink({
52
+ ...(options.onIntent === undefined ? {} : { onIntent: options.onIntent }),
53
+ ...(options.now === undefined ? {} : { now: options.now }),
54
+ });
55
+ let lifecycle = "open";
56
+ const getStatus = () => ({
57
+ lifecycle,
58
+ stateRevision: stateChannel.reader.getRevision(),
59
+ frameRevision: frameChannel.reader.getRevision(),
60
+ nextIntentSequence: intentPort.getNextSequence(),
61
+ });
62
+ const destroy = () => {
63
+ if (lifecycle === "destroyed")
64
+ return { status: "already-destroyed" };
65
+ lifecycle = "destroyed";
66
+ intentPort.destroy();
67
+ const current = frameChannel.reader.getSnapshot();
68
+ if (current.status !== "destroyed") {
69
+ frameChannel.publisher.publish({
70
+ protocolVersion: 1,
71
+ sourceFrameId: current.sourceFrameId,
72
+ capturedAtMs: current.capturedAtMs,
73
+ clockId: current.clockId,
74
+ status: "destroyed",
75
+ transform: null,
76
+ anchors: [],
77
+ diagnostics: current.diagnostics,
78
+ });
79
+ }
80
+ frameChannel.publisher.close("bridge-destroyed");
81
+ stateChannel.publisher.close("bridge-destroyed");
82
+ return { status: "destroyed" };
83
+ };
84
+ const destroyedPublication = () => ({
85
+ status: "closed",
86
+ revision: stateChannel.reader.getRevision(),
87
+ reason: "bridge-destroyed",
88
+ });
89
+ const statePublisher = {
90
+ publish: (snapshot) => lifecycle === "destroyed"
91
+ ? destroyedPublication()
92
+ : stateChannel.publisher.publish(snapshot),
93
+ close: (reason) => stateChannel.publisher.close(reason),
94
+ };
95
+ const framePublisher = {
96
+ publish: (frame) => lifecycle === "destroyed"
97
+ ? {
98
+ status: "closed",
99
+ revision: frameChannel.reader.getRevision(),
100
+ reason: "bridge-destroyed",
101
+ }
102
+ : frameChannel.publisher.publish(frame),
103
+ close: (reason) => frameChannel.publisher.close(reason),
104
+ };
105
+ return {
106
+ bridge: {
107
+ state: stateChannel.reader,
108
+ frame: frameChannel.reader,
109
+ intents: intentPort.sink,
110
+ getStatus,
111
+ destroy,
112
+ },
113
+ producer: {
114
+ state: statePublisher,
115
+ frame: framePublisher,
116
+ },
117
+ };
118
+ }
@@ -0,0 +1,33 @@
1
+ import type { JsonValue } from "./types.js";
2
+ export interface IntentEnvelope<TIntent extends JsonValue> {
3
+ readonly protocolVersion: 1;
4
+ readonly sequence: number;
5
+ readonly issuedAtMs: number;
6
+ readonly source?: string;
7
+ readonly intent: TIntent;
8
+ }
9
+ export type IntentDispatchResult = {
10
+ readonly status: "handled";
11
+ readonly sequence: number;
12
+ } | {
13
+ readonly status: "unhandled";
14
+ readonly sequence: number;
15
+ } | {
16
+ readonly status: "destroyed";
17
+ readonly sequence: number;
18
+ };
19
+ export interface IntentSink<TIntent extends JsonValue> {
20
+ readonly dispatch: (intent: TIntent, options?: {
21
+ readonly source?: string;
22
+ readonly issuedAtMs?: number;
23
+ }) => IntentDispatchResult;
24
+ }
25
+ export interface CreatedIntentSink<TIntent extends JsonValue> {
26
+ readonly sink: IntentSink<TIntent>;
27
+ readonly getNextSequence: () => number;
28
+ readonly destroy: () => void;
29
+ }
30
+ export declare function createIntentSink<TIntent extends JsonValue>(options?: {
31
+ readonly onIntent?: (envelope: IntentEnvelope<TIntent>) => void;
32
+ readonly now?: () => number;
33
+ }): CreatedIntentSink<TIntent>;