@genex-ai/cli-demo 1.36.8-dev.912 → 1.36.9-dev.913
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/dist/index.js +127 -0
- package/package.json +1 -1
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +3 -2
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +48 -24
- package/templates/skills/genex-threejs-multiplayer/references/genex-netcode-feel-checklist.md +3 -2
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +1 -1
package/dist/index.js
CHANGED
|
@@ -876,6 +876,90 @@ async function writeGameConfigFiles(meta, log, cwd = process.cwd()) {
|
|
|
876
876
|
await writeIfAbsent(path3.join(cwd, ".env.development.local"), overrides, log);
|
|
877
877
|
}
|
|
878
878
|
}
|
|
879
|
+
var SLUG_CONFIG_FILES = ["src/genex.config.ts", "src/genex.config.js"];
|
|
880
|
+
var SLUG_ENV_FILES = [".env.production.local", ".env.production", ".env.local", ".env"];
|
|
881
|
+
var SLUG_LITERAL = /^(["'`])([a-z0-9][a-z0-9-]*)\1$/;
|
|
882
|
+
function topLevelFields(code, start) {
|
|
883
|
+
const fields = [];
|
|
884
|
+
let field = "";
|
|
885
|
+
let depth = 0;
|
|
886
|
+
for (let i = start; i < code.length; i++) {
|
|
887
|
+
const ch = code[i];
|
|
888
|
+
if (ch === "/" && code[i + 1] === "/") {
|
|
889
|
+
const end = code.indexOf("\n", i);
|
|
890
|
+
if (end === -1) return null;
|
|
891
|
+
i = end - 1;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (ch === "/" && code[i + 1] === "*") {
|
|
895
|
+
const end = code.indexOf("*/", i + 2);
|
|
896
|
+
if (end === -1) return null;
|
|
897
|
+
i = end + 1;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
901
|
+
let j = i + 1;
|
|
902
|
+
while (j < code.length && code[j] !== ch) j += code[j] === "\\" ? 2 : 1;
|
|
903
|
+
if (j >= code.length) return null;
|
|
904
|
+
field += code.slice(i, j + 1);
|
|
905
|
+
i = j;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
909
|
+
else if (ch === ")" || ch === "]") depth--;
|
|
910
|
+
else if (ch === "}") {
|
|
911
|
+
if (depth === 0) {
|
|
912
|
+
fields.push(field);
|
|
913
|
+
return fields;
|
|
914
|
+
}
|
|
915
|
+
depth--;
|
|
916
|
+
} else if (ch === "," && depth === 0) {
|
|
917
|
+
fields.push(field);
|
|
918
|
+
field = "";
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
field += ch;
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
function bakedConfigSlug(content) {
|
|
926
|
+
const object = /export\s+const\s+GENEX(?:\s*:[^=]+)?\s*=\s*\{/.exec(content);
|
|
927
|
+
if (!object) return null;
|
|
928
|
+
const fields = topLevelFields(content, object.index + object[0].length);
|
|
929
|
+
const value = fields?.map((f) => /^\s*(?:slug|["']slug["'])\s*:([\s\S]*)$/.exec(f)?.[1]?.trim()).find((v) => v !== void 0);
|
|
930
|
+
if (!value) return null;
|
|
931
|
+
const fallback = value.includes("??") ? value.slice(value.lastIndexOf("??") + 2).trim() : value;
|
|
932
|
+
return SLUG_LITERAL.exec(fallback)?.[2] ?? null;
|
|
933
|
+
}
|
|
934
|
+
function envFileSlug(content) {
|
|
935
|
+
let found = null;
|
|
936
|
+
for (const line of content.split(/\r?\n/)) {
|
|
937
|
+
const match = /^[ \t]*(?:export[ \t]+)?VITE_GENEX_SLUG[ \t]*=[ \t]*(.*)$/.exec(line);
|
|
938
|
+
if (!match) continue;
|
|
939
|
+
const raw = match[1].trim();
|
|
940
|
+
const quote2 = raw[0] === '"' || raw[0] === "'" ? raw[0] : null;
|
|
941
|
+
const value = quote2 ? raw.slice(1, raw.indexOf(quote2, 1) === -1 ? void 0 : raw.indexOf(quote2, 1)) : raw.replace(/[ \t]+#.*$/, "");
|
|
942
|
+
if (value.trim()) found = value.trim();
|
|
943
|
+
}
|
|
944
|
+
return found;
|
|
945
|
+
}
|
|
946
|
+
async function findForeignSlug(cwd, slug, env = process.env) {
|
|
947
|
+
const read = (rel) => fs2.readFile(path3.join(cwd, rel), "utf8").catch(() => null);
|
|
948
|
+
const judge = (file, found) => found === slug ? null : { file, found };
|
|
949
|
+
const fromShell = env.VITE_GENEX_SLUG?.trim();
|
|
950
|
+
if (fromShell) return judge("VITE_GENEX_SLUG (environment)", fromShell);
|
|
951
|
+
for (const rel of SLUG_ENV_FILES) {
|
|
952
|
+
const content = await read(rel);
|
|
953
|
+
const found = content === null ? null : envFileSlug(content);
|
|
954
|
+
if (found) return judge(rel, found);
|
|
955
|
+
}
|
|
956
|
+
for (const rel of SLUG_CONFIG_FILES) {
|
|
957
|
+
const content = await read(rel);
|
|
958
|
+
const found = content === null ? null : bakedConfigSlug(content);
|
|
959
|
+
if (found !== null) return judge(rel, found);
|
|
960
|
+
}
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
879
963
|
|
|
880
964
|
// src/lib/ssh.ts
|
|
881
965
|
import fs3 from "fs/promises";
|
|
@@ -2630,6 +2714,26 @@ async function detectEmbedSdkVersion(cwd = process.cwd()) {
|
|
|
2630
2714
|
return null;
|
|
2631
2715
|
}
|
|
2632
2716
|
}
|
|
2717
|
+
var MIN_MULTIPLAYER_SDK = { major: 0, minor: 17 };
|
|
2718
|
+
async function outdatedMultiplayerSdk(cwd = process.cwd()) {
|
|
2719
|
+
const readJson = async (rel) => {
|
|
2720
|
+
try {
|
|
2721
|
+
return JSON.parse(await fs13.readFile(path14.join(cwd, rel), "utf8"));
|
|
2722
|
+
} catch {
|
|
2723
|
+
return null;
|
|
2724
|
+
}
|
|
2725
|
+
};
|
|
2726
|
+
const pkg = await readJson("package.json");
|
|
2727
|
+
const range = pkg?.dependencies?.["@genex-ai/multiplayer"] ?? pkg?.devDependencies?.["@genex-ai/multiplayer"];
|
|
2728
|
+
if (!range) return null;
|
|
2729
|
+
const installed = (await readJson("node_modules/@genex-ai/multiplayer/package.json"))?.version;
|
|
2730
|
+
const version = typeof installed === "string" ? installed : range;
|
|
2731
|
+
const match = /^[\^~>=v\s]*(\d+)\.(\d+)/.exec(version);
|
|
2732
|
+
if (!match || /[|<]|\s-\s/.test(version)) return null;
|
|
2733
|
+
const [major, minor] = [Number(match[1]), Number(match[2])];
|
|
2734
|
+
const old = major < MIN_MULTIPLAYER_SDK.major || major === MIN_MULTIPLAYER_SDK.major && minor < MIN_MULTIPLAYER_SDK.minor;
|
|
2735
|
+
return old ? version : null;
|
|
2736
|
+
}
|
|
2633
2737
|
async function detectMultiplayer(cwd = process.cwd()) {
|
|
2634
2738
|
try {
|
|
2635
2739
|
const raw = await fs13.readFile(path14.join(cwd, "package.json"), "utf8");
|
|
@@ -5690,6 +5794,23 @@ async function deployGame(ctx, opts, log) {
|
|
|
5690
5794
|
return false;
|
|
5691
5795
|
}
|
|
5692
5796
|
}
|
|
5797
|
+
const expectedSlug = ctx.slug ?? meta?.slug;
|
|
5798
|
+
if (expectedSlug) {
|
|
5799
|
+
const foreign = await findForeignSlug(cwd, expectedSlug);
|
|
5800
|
+
if (foreign) {
|
|
5801
|
+
log.error(
|
|
5802
|
+
`${foreign.file} names the game "${foreign.found}", but this folder is the Genex game "${expectedSlug}". The build would sign players into "${foreign.found}". Set it to "${expectedSlug}" and deploy again. Nothing was deployed.`
|
|
5803
|
+
);
|
|
5804
|
+
return false;
|
|
5805
|
+
}
|
|
5806
|
+
}
|
|
5807
|
+
const oldSdk = await outdatedMultiplayerSdk(cwd);
|
|
5808
|
+
if (oldSdk) {
|
|
5809
|
+
log.error(
|
|
5810
|
+
`This game uses @genex-ai/multiplayer ${oldSdk}, which Genex's multiplayer server no longer accepts. Run \`npm i @genex-ai/multiplayer@^0.17.0\` and deploy again. Nothing was deployed.`
|
|
5811
|
+
);
|
|
5812
|
+
return false;
|
|
5813
|
+
}
|
|
5693
5814
|
if (!opts.noBuild && await hasBuildScript(cwd)) {
|
|
5694
5815
|
log.step("Building the production bundle\u2026");
|
|
5695
5816
|
const built = await run("npm", ["run", "build"]);
|
|
@@ -6813,6 +6934,12 @@ async function runPreview(opts) {
|
|
|
6813
6934
|
log.plain("");
|
|
6814
6935
|
log.plain(nudge);
|
|
6815
6936
|
}
|
|
6937
|
+
if (detections.multiplayer) {
|
|
6938
|
+
log.plain("");
|
|
6939
|
+
log.plain(
|
|
6940
|
+
"Multiplayer on this preview runs in rooms of its own, apart from the live game. Test it as two different players: open the preview-- address printed above in a normal and an incognito window, or send it to a friend. The game's page is yours only."
|
|
6941
|
+
);
|
|
6942
|
+
}
|
|
6816
6943
|
}
|
|
6817
6944
|
|
|
6818
6945
|
// src/commands/remix.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.9-dev.913",
|
|
4
4
|
"description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -469,8 +469,9 @@ Rules:
|
|
|
469
469
|
`[::1]` — not https, not a LAN IP), the page is not inside an iframe, and
|
|
470
470
|
`@genex-ai/embed-sdk` is 0.5.0+ (older: apply the pending platform update).
|
|
471
471
|
- **Multiplayer `connect()` fails in local test mode** — by design: no relay
|
|
472
|
-
credential exists there. Validate multiplayer on the
|
|
473
|
-
|
|
472
|
+
credential exists there. Validate multiplayer on the preview link (it plays in
|
|
473
|
+
rooms of its own; a normal and a private window are two players), and say
|
|
474
|
+
plainly when it wasn't.
|
|
474
475
|
- **Multiplayer join rejected with 401** — the join ran before
|
|
475
476
|
`waitForPlayer()` resolved, without `auth: () => getColyseusAuth()`, or with a
|
|
476
477
|
stale cached token on reconnect (read it fresh each call).
|
|
@@ -33,23 +33,42 @@ example, the shared-object/ball code, rotation, and host usage. Read
|
|
|
33
33
|
calling multiplayer done, run the mandatory
|
|
34
34
|
[netcode feel gate](references/genex-netcode-feel-checklist.md).
|
|
35
35
|
|
|
36
|
+
## Testing multiplayer: on the preview link, with two different players
|
|
37
|
+
|
|
38
|
+
**A preview build is multiplayer in rooms of its own.** `npx genex preview` (the
|
|
39
|
+
`preview--<slug>` address) plays in rooms the relay keeps apart from the live game: testers on the
|
|
40
|
+
preview link meet each other and never meet real players, and a published game's players never see
|
|
41
|
+
an untested build. Anyone with the preview link can join, as a guest or signed in, even before the
|
|
42
|
+
game is published. You do nothing for this: the game always passes its plain slug; the relay picks
|
|
43
|
+
the room.
|
|
44
|
+
|
|
45
|
+
- **Test it on the preview, as two DIFFERENT players:** the preview link is the `preview--<slug>`
|
|
46
|
+
address `npx genex preview` prints on its "Live" line (the dashboard's draft page is the owner's
|
|
47
|
+
only). Open it in a normal window and an incognito window (the incognito one plays as a guest),
|
|
48
|
+
or send it to a friend.
|
|
49
|
+
Two tabs on one account are ONE seat: the newer tab takes it (close code 4409).
|
|
50
|
+
- Until two players have played it, tell the user multiplayer is built but not yet verified, give
|
|
51
|
+
them the preview link and say exactly that: open it in a normal and an incognito window.
|
|
52
|
+
- Nobody can save or buy on a preview, the owner included. Test progression saves and purchases
|
|
53
|
+
after publishing.
|
|
54
|
+
|
|
36
55
|
## Install
|
|
37
56
|
|
|
38
57
|
```bash
|
|
39
|
-
npm i @genex-ai/multiplayer@^0.
|
|
58
|
+
npm i @genex-ai/multiplayer@^0.17.0
|
|
40
59
|
```
|
|
41
60
|
|
|
42
|
-
> Pin `@^0.
|
|
61
|
+
> Pin `@^0.17.0` (not a bare `npm i`, and not an older range): 0.17 speaks the relay's current
|
|
62
|
+
> wire protocol, and the relay **refuses every older SDK** — a game built on 0.16 or earlier
|
|
63
|
+
> cannot join a room at all (its joins fail with `unsupported protocol`). 0.17 also added a
|
|
64
|
+
> relay-stamped per-game player id (`p.uid`), relay-assigned teams for the `open` preset
|
|
65
|
+
> (`p.team`) and the `room:open` event. Earlier floors still hold: automatic TURN — the thing that
|
|
43
66
|
> lets players behind symmetric NAT or carrier CGNAT be heard at all — landed in 0.16, mesh voice
|
|
44
|
-
> in 0.15, room text chat in 0.14, host-only `setRoomOpen()` in 0.13
|
|
45
|
-
>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
> resolve does not have those — and because this package is still `0.x`, a caret range is
|
|
50
|
-
> minor-locked, so `^0.12.0` would resolve to 0.12.x and give you no voice at all.
|
|
51
|
-
|
|
52
|
-
This skill targets `@genex-ai/multiplayer` **≥ 0.16.0** (`objects`/`host` since 0.4; verified per-player `avatarUrl` since 0.12;
|
|
67
|
+
> in 0.15, room text chat in 0.14, host-only `setRoomOpen()` in 0.13. Because this package is
|
|
68
|
+
> still `0.x`, a caret range is minor-locked, so `^0.16.0` would resolve to 0.16.x and never join.
|
|
69
|
+
|
|
70
|
+
This skill targets `@genex-ai/multiplayer` **≥ 0.17.0** (relay protocol 3, `p.uid`/`p.team` and
|
|
71
|
+
`room:open` since 0.17; `objects`/`host` since 0.4; verified per-player `avatarUrl` since 0.12;
|
|
53
72
|
room text chat via `room.chat` since 0.14; mesh voice via `room.voice` since 0.15;
|
|
54
73
|
automatic TURN (voice connects behind symmetric NAT with no configuration) since 0.16;
|
|
55
74
|
`matchmake()` since 0.5; private lobbies since 0.7; auto-reconnect + `inputs`/`onHostTick`
|
|
@@ -132,6 +151,8 @@ mm.on("matched", () => syncSession());
|
|
|
132
151
|
mm.on("matchStart", () => syncSession()); // duel/arena/teams only
|
|
133
152
|
mm.on("matchEnded", (result) => showResult(result)); // duel/arena/teams only
|
|
134
153
|
mm.on("error", (e) => showQueueError(e)); // after SDK retries; fix, then mm.retry()
|
|
154
|
+
// 4409 = opened in another tab/device, 4401 = signed out: the handle stops, never requeues.
|
|
155
|
+
mm.on("disconnect", () => showMenu("This game is open somewhere else.")); // mm.retry() only on a click
|
|
135
156
|
|
|
136
157
|
// Every frame AND a low-rate timer: the handle swaps in a new Session after
|
|
137
158
|
// seating, terminal drop/requeue, and the next match.
|
|
@@ -330,8 +351,9 @@ halves of this rule are non-negotiable:
|
|
|
330
351
|
is never written (host bug, host left), every player is stuck on the waiting screen forever.
|
|
331
352
|
A ready-check or countdown is fine as an ADDITION layered on top of `status === 'playing'`
|
|
332
353
|
(host writes it to `shared` so it survives host migration), never as a replacement for it.
|
|
333
|
-
2. **Verify it for real before calling multiplayer done**:
|
|
334
|
-
(one regular + one incognito, so they're two
|
|
354
|
+
2. **Verify it for real before calling multiplayer done**: on the preview link (see "Testing
|
|
355
|
+
multiplayer"), open it in two browser windows (one regular + one incognito, so they're two
|
|
356
|
+
players), join with both, and watch the waiting
|
|
335
357
|
screen disappear on BOTH the moment the count reaches `minPlayers` (with
|
|
336
358
|
`minPlayers: 2, maxPlayers: 2`: the instant the second player joins). If it doesn't close on
|
|
337
359
|
both, the lobby is broken — fix it; do not ship a waiting room you haven't watched close.
|
|
@@ -669,7 +691,8 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
669
691
|
- `ids()` — all object ids seen.
|
|
670
692
|
- `room.isHost` / `room.host` — you are (or who is) the elected authority. Use to pick the single
|
|
671
693
|
writer of `shared` scores/rounds and the single simulator of host-owned objects. Settles within
|
|
672
|
-
the first patch after connect — read in your loop / react to `on('host')`, not once.
|
|
694
|
+
the first patch after connect — read in your loop / react to `on('host')`, not once. `isHost` is
|
|
695
|
+
false while reconnecting (until the fresh snapshot lands), so gating a write on it is safe.
|
|
673
696
|
`room.host === undefined` before that patch means **authority is not ready**:
|
|
674
697
|
do not start host-owned simulation and do not synthesize an "acting host" from
|
|
675
698
|
a locally sorted roster. Clients can briefly observe different rosters, so
|
|
@@ -930,11 +953,11 @@ function awardOnce(scorerUid: string, name: string, marker: `__${string}`, seq:
|
|
|
930
953
|
```
|
|
931
954
|
|
|
932
955
|
**Key scores by a STABLE identity, never the session id.** A session id dies on every reload —
|
|
933
|
-
the points orphan into a duplicate row and the player's color changes.
|
|
934
|
-
|
|
935
|
-
`
|
|
936
|
-
|
|
937
|
-
|
|
956
|
+
the points orphan into a duplicate row and the player's color changes. The relay stamps every
|
|
957
|
+
player with `uid` — their stable id **in this game**, the same across reloads, never their account
|
|
958
|
+
id — so key `scores`/colors/`isMe` by it. When an event only carries a session id, map it via
|
|
959
|
+
`room.players.get(sid)?.uid`. Never publish the account id (`user.id`) in player state yourself:
|
|
960
|
+
every peer reads it.
|
|
938
961
|
|
|
939
962
|
For host-simulated NPCs, the host claims and drives each enemy as an `object`; when the host
|
|
940
963
|
leaves, its enemies are reassigned to the new host, which reads their `stateRaw` and keeps
|
|
@@ -985,10 +1008,11 @@ from any still capture whether movement feels smooth.** Don't try — it leads t
|
|
|
985
1008
|
other (and the ball, if any). That's all a capture can prove. Local test mode (what the dev
|
|
986
1009
|
server boots into — the embed-auth skill) can NOT do this: it mints no relay credential, so `connect()`
|
|
987
1010
|
fails there by design and two local-test tabs never see each other — run the two-client check
|
|
988
|
-
on the
|
|
989
|
-
|
|
1011
|
+
on the preview link (see "Testing multiplayer"), and if multiplayer wasn't exercised, say
|
|
1012
|
+
exactly that in your handoff instead of implying it was.
|
|
990
1013
|
3. **Then say plainly:** *"Multiplayer smoothness depends on your network and can only be felt by a
|
|
991
|
-
person — open
|
|
1014
|
+
person — open the preview link in a normal and a private window, or send it to a friend, and
|
|
1015
|
+
tell me how it feels."* Stop there.
|
|
992
1016
|
|
|
993
1017
|
### When the human reports a problem, match the symptom (don't guess)
|
|
994
1018
|
|
|
@@ -1062,7 +1086,7 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
1062
1086
|
|
|
1063
1087
|
## Checklist
|
|
1064
1088
|
|
|
1065
|
-
- [ ] `npm i @genex-ai/multiplayer@^0.
|
|
1089
|
+
- [ ] `npm i @genex-ai/multiplayer@^0.17.0` (the only range the relay accepts; per-game `p.uid`, automatic TURN, mesh voice, room text chat, connected presence, supplier auth, confirmed controls, snap epochs, reconnect-safe host ticks, unowned-write warnings, cross-region exact-relay overrides); config wired into the build.
|
|
1066
1090
|
- [ ] The plan names one net model, the player-experience reason, start/quorum, late-join/backfill,
|
|
1067
1091
|
and below-quorum/end behavior. The agent inferred it unless the experience was genuinely ambiguous.
|
|
1068
1092
|
- [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
|
|
@@ -1081,7 +1105,7 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
1081
1105
|
dedupe keys, the victim applies its own damage, respawn publishes via `me.snap`, and
|
|
1082
1106
|
projectiles are hard-capped objects removed with `removeConfirmed`.
|
|
1083
1107
|
- [ ] Host score writes are exactly-once (marker in the same `shared` write) and keyed by the
|
|
1084
|
-
|
|
1108
|
+
relay-stamped `p.uid`, never the session id.
|
|
1085
1109
|
- [ ] Host renders objects OWNED BY OTHERS from the stream (authority follows ownership).
|
|
1086
1110
|
- [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
|
|
1087
1111
|
hang) and passes `auth: () => getColyseusAuth()` (the relay rejects tokenless joins —
|
package/templates/skills/genex-threejs-multiplayer/references/genex-netcode-feel-checklist.md
CHANGED
|
@@ -44,8 +44,9 @@ suite for the game.
|
|
|
44
44
|
|
|
45
45
|
## One focused proof with two distinct identities
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
Run it on the preview link (`npx genex preview`): a preview plays in rooms of its own, apart from
|
|
48
|
+
the live game. Use regular + incognito guest identities, two accounts/devices, or ask the user to
|
|
49
|
+
perform the exact sequence. Two tabs sharing one identity are one enforced seat.
|
|
49
50
|
|
|
50
51
|
1. Enter online using real clicks. Then press Space/Enter/the main action using real key input; confirm
|
|
51
52
|
focus did not re-trigger Play/Leave and both sessions remain live.
|
|
@@ -169,7 +169,7 @@ SKILL.md is the assignment story, verbatim. Everything else is Recipe 1/2 mechan
|
|
|
169
169
|
|
|
170
170
|
| Thing | Channel | Authority |
|
|
171
171
|
| --- | --- | --- |
|
|
172
|
-
| Each player's avatar (
|
|
172
|
+
| Each player's avatar (hp, `life`; the relay stamps `uid`) | `me.set` / `players` | each player |
|
|
173
173
|
| The team map (`id → red/blue`) | `shared` (`"teams"`) | the **host** — the SKILL.md reconciler, every tick |
|
|
174
174
|
| Team scores | `shared` (exactly-once marker — SKILL.md host section) | `room.isHost` |
|
|
175
175
|
| Attacks / defeats | `send` (Recipe 2 rules verbatim) | attacker / victim |
|