@genex-ai/multiplayer 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.
- package/README.md +139 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +87 -0
- package/dist/schema.d.ts +51 -0
- package/dist/schema.js +20 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @genex-ai/multiplayer
|
|
2
|
+
|
|
3
|
+
Tiny realtime multiplayer for genex games.
|
|
4
|
+
|
|
5
|
+
**Relay model:** whatever you write to `me` or `shared` is synced to everyone; everything else stays local. The server does **no physics, no prediction, no interpolation** — you run your own game logic, this just shares state.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { connect } from '@genex-ai/multiplayer';
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Connect
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
type State = { x: number; y: number; z: number }; // YOUR per-player state — any JSON shape
|
|
17
|
+
|
|
18
|
+
const room = await connect<State>({
|
|
19
|
+
url: 'wss://demo-colyseus.glotech.world', // the genex multiplayer server
|
|
20
|
+
room: '<game-id>', // everyone with the same id shares a room (use the project slug)
|
|
21
|
+
name: 'ada', // optional display name
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Different `room` ids are **fully isolated** — players in game A never see game B.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## `me` — publish your own state
|
|
30
|
+
|
|
31
|
+
Replaces your state wholesale. Call on a **fixed tick (~10–20Hz)**, not every render frame — each call is one network message.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
room.me.set({ x, y, z });
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## `players` — everyone in the room
|
|
38
|
+
|
|
39
|
+
A fresh `Map` each read; **includes you** (skip with `id === room.id`). It's the latest snapshot — snap to it (or smooth client-side if you want; there's no built-in interpolation).
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
for (const [id, p] of room.players) {
|
|
43
|
+
if (id === room.id) continue;
|
|
44
|
+
draw(p.name, p.state.x, p.state.y, p.state.z); // p.state is typed as State
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## `shared` — shared room state
|
|
49
|
+
|
|
50
|
+
A key/value store (any JSON) synced to everyone. Use for world state, scores, the current round, etc.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
room.shared.set('round', 3);
|
|
54
|
+
room.shared.get('round'); // 3
|
|
55
|
+
room.shared.keys(); // ['round', ...]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## `on` — events
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
room.on('join', (id, state) => {}); // a player appeared (also fires for YOU on connect)
|
|
62
|
+
room.on('change', (id, state) => {}); // a player updated their me-state
|
|
63
|
+
room.on('leave', (id) => {}); // a player left
|
|
64
|
+
room.on('shared', (key, value) => {}); // a shared key changed
|
|
65
|
+
room.on('hit', (payload) => {}); // a custom event someone sent (see `send`)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`on(...)` returns an unsubscribe function.
|
|
69
|
+
|
|
70
|
+
## `send` — custom one-off events
|
|
71
|
+
|
|
72
|
+
Fire-and-forget to all **other** clients (not stored in state). For shots, emotes, chat, pings.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
room.send('hit', { from: room.id, target: 'xyz', dmg: 10 });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## `leave`
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
room.leave();
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Complete minimal example (move a cube, see others as cubes)
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { connect } from '@genex-ai/multiplayer';
|
|
90
|
+
type S = { x: number; z: number };
|
|
91
|
+
|
|
92
|
+
const room = await connect<S>({ url: 'wss://demo-colyseus.glotech.world', room: GAME_ID });
|
|
93
|
+
|
|
94
|
+
const me = { x: 0, z: 0 };
|
|
95
|
+
|
|
96
|
+
// publish my position ~15Hz (NOT per frame)
|
|
97
|
+
setInterval(() => room.me.set(me), 66);
|
|
98
|
+
|
|
99
|
+
// render loop (your engine / three.js): draw every player at their latest position
|
|
100
|
+
function frame() {
|
|
101
|
+
for (const [id, p] of room.players) {
|
|
102
|
+
const cube = cubeFor(id); // create-or-reuse a mesh per id
|
|
103
|
+
cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // snap — no interpolation
|
|
104
|
+
}
|
|
105
|
+
requestAnimationFrame(frame);
|
|
106
|
+
}
|
|
107
|
+
frame();
|
|
108
|
+
|
|
109
|
+
// drop a player's cube when they leave
|
|
110
|
+
room.on('leave', (id) => removeCube(id));
|
|
111
|
+
|
|
112
|
+
// input just mutates `me`; the tick above syncs it
|
|
113
|
+
onKey('right', () => { me.x += 1; });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Rules of thumb
|
|
119
|
+
|
|
120
|
+
- **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
|
|
121
|
+
- **Tick `me.set` at ~10–20Hz**, render at your own framerate. One `me.set` = one network message.
|
|
122
|
+
- **`room` (game id) must be stable per game** — use the project slug. Same id = same room; different id = isolated.
|
|
123
|
+
- **No interpolation / prediction.** You get the latest state; snap to it, or smooth it yourself.
|
|
124
|
+
- `me.set` replaces your whole state object — send the full object each time, not a partial.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Persistent worlds (optional)
|
|
129
|
+
|
|
130
|
+
The relay is in-memory — room state is gone when everyone leaves or the server restarts. To persist world state across restarts, save/load a JSON blob via the API (keyed by the project slug):
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// load on boot
|
|
134
|
+
const { data } = await fetch(`${API}/api/projects/${slug}/state`).then(r => r.json());
|
|
135
|
+
initWorld(data ?? defaultWorld());
|
|
136
|
+
|
|
137
|
+
// save whenever (from ONE authority — e.g. a host client — to avoid races)
|
|
138
|
+
fetch(`${API}/api/projects/${slug}/state`, { method: 'PUT', body: JSON.stringify(world) });
|
|
139
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export { PlayerState, RoomState } from './schema.js';
|
|
2
|
+
import '@colyseus/schema';
|
|
3
|
+
|
|
4
|
+
type ConnectOptions = {
|
|
5
|
+
/** Colyseus server url, e.g. "ws://localhost:2567". */
|
|
6
|
+
url: string;
|
|
7
|
+
/** Game id — everyone who passes the same id shares a room; different ids are
|
|
8
|
+
* fully isolated. Use your project slug (or any stable per-game string). */
|
|
9
|
+
room: string;
|
|
10
|
+
/** Display name for this player (anonymous is fine). */
|
|
11
|
+
name?: string;
|
|
12
|
+
/** Extra join options passed to the room. */
|
|
13
|
+
options?: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
type PlayerView<TState> = {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
/** This player's latest synced state (what they passed to `me.set`). */
|
|
19
|
+
state: TState;
|
|
20
|
+
};
|
|
21
|
+
type Session<TState extends Record<string, unknown>> = {
|
|
22
|
+
/** Your own session id. */
|
|
23
|
+
readonly id: string;
|
|
24
|
+
/**
|
|
25
|
+
* Everyone in the room, **including you** — skip yourself with
|
|
26
|
+
* `if (id === room.id)`. Returns a fresh Map on each read.
|
|
27
|
+
*/
|
|
28
|
+
readonly players: Map<string, PlayerView<TState>>;
|
|
29
|
+
readonly me: {
|
|
30
|
+
/**
|
|
31
|
+
* Publish your synced state (replaces it wholesale). Call on a **fixed tick**
|
|
32
|
+
* (~10–20Hz), not every render frame — each call sends one network message.
|
|
33
|
+
*/
|
|
34
|
+
set(state: TState): void;
|
|
35
|
+
};
|
|
36
|
+
readonly shared: {
|
|
37
|
+
/** Read a shared value (any JSON), or `undefined`. */
|
|
38
|
+
get(key: string): unknown;
|
|
39
|
+
/** Set a shared value (any JSON). Any client may set any key (no auth — demo). */
|
|
40
|
+
set(key: string, value: unknown): void;
|
|
41
|
+
/** All shared keys currently set. */
|
|
42
|
+
keys(): string[];
|
|
43
|
+
};
|
|
44
|
+
/** A player appeared/updated. **Also fires for you** on connect — filter `id === room.id`. */
|
|
45
|
+
on(event: 'join' | 'change', cb: (id: string, state: TState) => void): () => void;
|
|
46
|
+
/** A player left. */
|
|
47
|
+
on(event: 'leave', cb: (id: string) => void): () => void;
|
|
48
|
+
/** A shared key changed (fires on first set; for live values just read `shared.get`). */
|
|
49
|
+
on(event: 'shared', cb: (key: string, value: unknown) => void): () => void;
|
|
50
|
+
/** A custom event another client sent via `send(type, ...)`. */
|
|
51
|
+
on(event: string & {}, cb: (payload: unknown) => void): () => void;
|
|
52
|
+
/** Fire-and-forget event to all OTHER clients (not stored in state). */
|
|
53
|
+
send(type: string, payload?: unknown): void;
|
|
54
|
+
/** Leave the room. */
|
|
55
|
+
leave(): void;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Connect to a genex multiplayer room. You must supply your player-state type:
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* const room = await connect<{ x: number; y: number; z: number }>({
|
|
62
|
+
* url: 'ws://localhost:2567', room: gameId, name: 'ada',
|
|
63
|
+
* });
|
|
64
|
+
* room.me.set({ x, y, z }); // typed + validated by the compiler
|
|
65
|
+
* room.players.get(id)?.state.x; // number — no cast
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* Relay model: no physics, no prediction, no interpolation. Whatever you write
|
|
69
|
+
* to `me` or `shared` syncs to everyone; everything else stays local.
|
|
70
|
+
*/
|
|
71
|
+
declare function connect<TState extends Record<string, unknown> = never>(opts: ConnectOptions): Promise<Session<TState>>;
|
|
72
|
+
|
|
73
|
+
export { type ConnectOptions, type PlayerView, type Session, connect };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { Client, getStateCallbacks } from "colyseus.js";
|
|
3
|
+
var RELAY_ROOM_TYPE = "play";
|
|
4
|
+
function parseObject(s) {
|
|
5
|
+
if (!s) return {};
|
|
6
|
+
try {
|
|
7
|
+
const v = JSON.parse(s);
|
|
8
|
+
return v && typeof v === "object" ? v : {};
|
|
9
|
+
} catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function parseValue(s) {
|
|
14
|
+
if (s == null) return void 0;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(s);
|
|
17
|
+
} catch {
|
|
18
|
+
return void 0;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function connect(opts) {
|
|
22
|
+
const client = new Client(opts.url);
|
|
23
|
+
const room = await client.joinOrCreate(RELAY_ROOM_TYPE, {
|
|
24
|
+
gameId: opts.room,
|
|
25
|
+
name: opts.name,
|
|
26
|
+
...opts.options
|
|
27
|
+
});
|
|
28
|
+
const emitters = /* @__PURE__ */ new Map();
|
|
29
|
+
const bridged = /* @__PURE__ */ new Set();
|
|
30
|
+
const reserved = /* @__PURE__ */ new Set(["join", "leave", "change", "shared"]);
|
|
31
|
+
const emit = (event, ...args) => emitters.get(event)?.forEach((cb) => cb(...args));
|
|
32
|
+
const $ = getStateCallbacks(room);
|
|
33
|
+
$(room.state).players.onAdd((player, id) => {
|
|
34
|
+
emit("join", id, parseObject(player.data));
|
|
35
|
+
$(player).onChange(() => emit("change", id, parseObject(player.data)));
|
|
36
|
+
});
|
|
37
|
+
$(room.state).players.onRemove((_player, id) => emit("leave", id));
|
|
38
|
+
$(room.state).shared.onAdd((value, key) => emit("shared", key, parseValue(value)));
|
|
39
|
+
const on = ((event, cb) => {
|
|
40
|
+
let set = emitters.get(event);
|
|
41
|
+
if (!set) emitters.set(event, set = /* @__PURE__ */ new Set());
|
|
42
|
+
set.add(cb);
|
|
43
|
+
if (!reserved.has(event) && !bridged.has(event)) {
|
|
44
|
+
bridged.add(event);
|
|
45
|
+
room.onMessage(event, (msg) => emit(event, msg));
|
|
46
|
+
}
|
|
47
|
+
return () => {
|
|
48
|
+
set?.delete(cb);
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
id: room.sessionId,
|
|
53
|
+
get players() {
|
|
54
|
+
const out = /* @__PURE__ */ new Map();
|
|
55
|
+
room.state?.players?.forEach(
|
|
56
|
+
(p, id) => out.set(id, { id, name: p.name, state: parseObject(p.data) })
|
|
57
|
+
);
|
|
58
|
+
return out;
|
|
59
|
+
},
|
|
60
|
+
me: {
|
|
61
|
+
set(state) {
|
|
62
|
+
room.send("state", JSON.stringify(state));
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
shared: {
|
|
66
|
+
get(key) {
|
|
67
|
+
return parseValue(room.state?.shared?.get(key));
|
|
68
|
+
},
|
|
69
|
+
set(key, value) {
|
|
70
|
+
room.send("shared", { key, value: JSON.stringify(value) });
|
|
71
|
+
},
|
|
72
|
+
keys() {
|
|
73
|
+
return room.state?.shared ? Array.from(room.state.shared.keys()) : [];
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
on,
|
|
77
|
+
send(type, payload) {
|
|
78
|
+
room.send(type, payload);
|
|
79
|
+
},
|
|
80
|
+
leave() {
|
|
81
|
+
void room.leave();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export {
|
|
86
|
+
connect
|
|
87
|
+
};
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as _colyseus_schema from '@colyseus/schema';
|
|
2
|
+
import { SchemaType, MapSchema } from '@colyseus/schema';
|
|
3
|
+
|
|
4
|
+
declare const PlayerState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
5
|
+
name: "string";
|
|
6
|
+
data: "string";
|
|
7
|
+
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
8
|
+
name: "string";
|
|
9
|
+
data: "string";
|
|
10
|
+
}>>, typeof _colyseus_schema.Schema>;
|
|
11
|
+
type PlayerState = SchemaType<typeof PlayerState>;
|
|
12
|
+
declare const RoomState: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
13
|
+
players: {
|
|
14
|
+
map: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
15
|
+
name: "string";
|
|
16
|
+
data: "string";
|
|
17
|
+
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
18
|
+
name: "string";
|
|
19
|
+
data: "string";
|
|
20
|
+
}>>, typeof _colyseus_schema.Schema>;
|
|
21
|
+
default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
|
|
22
|
+
name: "string";
|
|
23
|
+
data: "string";
|
|
24
|
+
}>, string>;
|
|
25
|
+
};
|
|
26
|
+
shared: {
|
|
27
|
+
map: "string";
|
|
28
|
+
default: MapSchema<string, string>;
|
|
29
|
+
};
|
|
30
|
+
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
31
|
+
players: {
|
|
32
|
+
map: _colyseus_schema.SchemaWithExtendsConstructor<{
|
|
33
|
+
name: "string";
|
|
34
|
+
data: "string";
|
|
35
|
+
}, _colyseus_schema.AssignableProps<_colyseus_schema.InferSchemaInstanceType<{
|
|
36
|
+
name: "string";
|
|
37
|
+
data: "string";
|
|
38
|
+
}>>, typeof _colyseus_schema.Schema>;
|
|
39
|
+
default: MapSchema<_colyseus_schema.InferSchemaInstanceType<{
|
|
40
|
+
name: "string";
|
|
41
|
+
data: "string";
|
|
42
|
+
}>, string>;
|
|
43
|
+
};
|
|
44
|
+
shared: {
|
|
45
|
+
map: "string";
|
|
46
|
+
default: MapSchema<string, string>;
|
|
47
|
+
};
|
|
48
|
+
}>>, typeof _colyseus_schema.Schema>;
|
|
49
|
+
type RoomState = SchemaType<typeof RoomState>;
|
|
50
|
+
|
|
51
|
+
export { PlayerState, RoomState };
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/schema.ts
|
|
2
|
+
import { schema, MapSchema } from "@colyseus/schema";
|
|
3
|
+
var PlayerState = schema(
|
|
4
|
+
{
|
|
5
|
+
name: "string",
|
|
6
|
+
data: "string"
|
|
7
|
+
},
|
|
8
|
+
"PlayerState"
|
|
9
|
+
);
|
|
10
|
+
var RoomState = schema(
|
|
11
|
+
{
|
|
12
|
+
players: { map: PlayerState, default: new MapSchema() },
|
|
13
|
+
shared: { map: "string", default: new MapSchema() }
|
|
14
|
+
},
|
|
15
|
+
"RoomState"
|
|
16
|
+
);
|
|
17
|
+
export {
|
|
18
|
+
PlayerState,
|
|
19
|
+
RoomState
|
|
20
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@genex-ai/multiplayer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The multiplayer client wrapper + shared room schema for genex.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./schema": {
|
|
12
|
+
"types": "./dist/schema.d.ts",
|
|
13
|
+
"import": "./dist/schema.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@colyseus/schema": "^3.0.76",
|
|
24
|
+
"colyseus.js": "^0.16.22"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"three": ">=0.160.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"three": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"tsup": "^8.0.0"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/me-ai-org/genex-demo.git",
|
|
40
|
+
"directory": "packages/multiplayer"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"main": "./dist/index.js",
|
|
48
|
+
"types": "./dist/index.d.ts"
|
|
49
|
+
}
|