@genex-ai/cli-demo 0.53.0-dev.117 → 0.53.0-dev.118

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.53.0-dev.117",
3
+ "version": "0.53.0-dev.118",
4
4
  "description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,7 +77,11 @@ hits, layer two or three of:
77
77
  With the bundled physics pack this is built in — `physics.paused = true` /
78
78
  `physics.timeScale = 0.2` (see the physics skill's pause/slow-mo section) plus
79
79
  `anims.setPaused(true)` / `anims.setTimeScale(0.2)` for the character's
80
- animations. Don't hand-roll a second clock.
80
+ animations. Don't hand-roll a second clock. **In multiplayer, hitstop is local
81
+ presentation feedback:** never pause the network pump, reconnect/quorum timers,
82
+ remote interpolation, or another player's/host's simulation. Slow/freeze only
83
+ your locally owned gameplay clock and visuals; the authoritative hit is still
84
+ deduped and applied once through the multiplayer skill's normal event path.
81
85
 
82
86
  One layer per small event, three for the biggest — uniform intensity flattens
83
87
  everything back out.
@@ -99,4 +103,6 @@ everything back out.
99
103
  - Restart requires reloading the page (kills the retry loop — and reloads
100
104
  re-run auth and asset loading).
101
105
  - Shake/flash spam with no cause — feedback inflation reads as noise.
106
+ - Multiplayer hitstop pauses networking or host simulation, turning impact feedback
107
+ into packet bursts, quorum lag, or a freeze for players who were not hit.
102
108
  - Feel constants scattered through the code where nobody dares touch them.
@@ -29,20 +29,23 @@ whole reason this skill exists.
29
29
  Read [references/realtime-patterns.md](references/realtime-patterns.md) for the complete movement
30
30
  example, the shared-object/ball code, rotation, and host usage. Read
31
31
  [references/genre-recipes.md](references/genre-recipes.md) for ready-made per-genre setups
32
- (sports/ball, shooter, co-op with host-simulated enemies) — pick the one matching the game.
32
+ (sports/ball, shooter, co-op with host-simulated enemies) — pick the one matching the game. Before
33
+ calling multiplayer done, run the mandatory
34
+ [netcode feel gate](references/genex-netcode-feel-checklist.md).
33
35
 
34
36
  ## Install
35
37
 
36
38
  ```bash
37
- npm i @genex-ai/multiplayer@^0.10.1
39
+ npm i @genex-ai/multiplayer@^0.10.2
38
40
  ```
39
41
 
40
- > Pin `@^0.10.1` (not a bare `npm i`): live connected-player presence, supplier-form `connect()`
42
+ > Pin `@^0.10.2` (not a bare `npm i`): unowned object writes now warn instead of failing silently;
43
+ > live connected-player presence, supplier-form `connect()`
41
44
  > auth, regional relay selection (`getColyseusUrls()` + `urls`)
42
45
  > landed in 0.10; confirmed object controls, snaps, host-tick teardown, and reconnect rebasing
43
46
  > in 0.9. An older resolve does not have those.
44
47
 
45
- This skill targets `@genex-ai/multiplayer` **≥ 0.10.1** (`objects`/`host` since 0.4;
48
+ This skill targets `@genex-ai/multiplayer` **≥ 0.10.2** (`objects`/`host` since 0.4;
46
49
  `matchmake()` since 0.5; private lobbies since 0.7; auto-reconnect + `inputs`/`onHostTick`
47
50
  since 0.8; soft ownership handoff since 0.8.4; confirmed controls, snap epochs, and host-tick
48
51
  lifecycle guarantees since 0.9; regional relay selection via `getColyseusUrls()` since 0.10).
@@ -56,18 +59,30 @@ ownership, match seating/adjudication, and rate/size caps — but a modified cli
56
59
  lie about its own position or score. Great for friends and casual lobbies; don't promise
57
60
  ranked-grade fairness.
58
61
 
59
- ## Choose one net model first
62
+ ## Choose one net model from the player experience
60
63
 
61
- - **`connect()` shared world:** everyone for this slug shares a room. Use for
62
- persistent/co-op spaces where the game may remain valid with one player.
63
- Pass auth as a FUNCTION so each explicit connect attempt reads fresh; after
64
- a terminal disconnect the game starts a new connect flow.
65
- - **`matchmake()` — capped matches:** the queue seats players into separate
66
- rooms. Use for duels, races, teams, and finite arenas. It requires
67
- `genex.matchmaking` in `package.json`; auth is a FUNCTION; the handle may
68
- replace `mm.session`, so the game polls and rebinds it.
64
+ Infer this yourself when the experience is clear. Do **not** make the player choose an SDK API,
65
+ preset, or config. Ask one plain-language question only when the design genuinely supports both
66
+ models and the answer changes the experience for example: *"Should this be one ongoing arena
67
+ people drop into, or a fresh fair match that waits for everyone and then starts together?"*
69
68
 
70
- Do not combine the requirements or silently convert one model into the other.
69
+ | Player experience | Model | Why |
70
+ | --- | --- | --- |
71
+ | One ongoing drop-in world; late joiners enter what is already happening; solo play remains valid | `connect()` | One shared room, no queue or round formation |
72
+ | Fresh bounded match/mission; quorum, fair start, capacity, teams, or parallel sessions matter | `matchmake()` | Server forms capped rooms and exposes queue/waiting state |
73
+
74
+ Genre names do not decide this. A sumo game may be an always-online drop-in ring (`connect()`) or a
75
+ fair-start bout (`matchmake()`). A co-op game may be an ongoing shared space (`connect()`) or a
76
+ bounded dungeon run (`matchmake()`). Write one plan line before coding:
77
+
78
+ > Net model: `<connect|matchmake>` — `<player-experience reason>`; start/quorum: `<rule>`;
79
+ > late join/backfill: `<rule>`; below-quorum/end tail: `<rule>`.
80
+
81
+ Use `connect()` when the game is honestly one always-online world. Use `matchmake()` when it promises
82
+ a match, run, race, mission, teams, a waiting count, or a synchronized start. If the brief says
83
+ "100 players in the same world," or mixes a shared hub with instanced matches, do not guess: surface
84
+ the platform/capacity mismatch or ask which experience matters. Do not combine the requirements or
85
+ silently convert one model into the other.
71
86
 
72
87
  ## Matchmaking (competitive presets — server-owned)
73
88
 
@@ -372,6 +387,25 @@ const lobby = await joinPrivate<MyState>(code, { urls, room: slug, auth: () => g
372
387
  Pick your own per-player state shape (any JSON). `room` is the **project slug**
373
388
  (printed by `genex init`) — same id = same room, different ids are fully isolated.
374
389
 
390
+ ### WHEN to call `connect()` — make the screen tell the truth (MANDATORY)
391
+
392
+ `connect()` immediately joins the shared world and makes the player present. Two lifecycles are
393
+ valid; pick exactly one:
394
+
395
+ - **Always-online world:** there is no Play/Online commitment screen. After identity is ready,
396
+ connect and spawn immediately. This is correct for a drop-in social space or an ongoing sumo ring
397
+ where loading the game already means joining it. A lightweight loading/reconnecting overlay is
398
+ honest; a Play button that appears to delay entry is not.
399
+ - **Menu before online:** if the game shows **Play**, **Play Online**, **Join Arena**, or offers
400
+ Local/Bots, that click is the commitment point. Boot the menu/offline world with no relay contact,
401
+ call `connect()` only inside the online handler, and spawn the network player only after it
402
+ resolves. Local/Bots never connect. Leaving online calls `room.leave()`, disables terminal rejoin,
403
+ removes the online avatar, and returns to the pre-online state.
404
+
405
+ Never auto-connect/spawn behind a title menu and then ask the player to press Play. The API is not
406
+ the bug in that case; the lifecycle is. Likewise, do not add a fake queue/finding screen to an
407
+ always-online `connect()` world — it has no match formation to report.
408
+
375
409
  **Joining requires the SDK's player identity — the relay rejects tokenless
376
410
  joins, but accepts guests** (accountless players named like `Guest-1234`).
377
411
  Load the `genex-threejs-embed-auth` skill first (it sets up `initEmbed(...)`),
@@ -587,6 +621,19 @@ is the newest value with no smoothing. **Draw** from `state`; **test** against `
587
621
  detection, "am I close enough to kick", pickups, and any discrete number (hp, ammo, animation id,
588
622
  a 0/1 flag) that must not arrive fractional. This holds for both players and objects.
589
623
 
624
+ **HARD RULE — `state` is for RENDER ONLY.** Every GAMEPLAY read — hit tests, deflection/catch/
625
+ return windows, physics seeding after adoption, distance and reach checks — uses `stateRaw`.
626
+ The smoothed view is ~100–150 ms in the past; at projectile or ball speeds the REAL object has
627
+ already passed where the ghost still is. Two field-verified failures caused by breaking this rule:
628
+ - A pong-style game read the incoming ball via `state` for its deflection window — at top speed
629
+ the real ball crossed the paddle plane before the smoothed one arrived; returns were literally
630
+ impossible online while feeling fine in solo testing.
631
+ - A dodgeball game aimed at opponents drawn from `state` — a strafing target's real position was
632
+ already elsewhere, so "direct hits" never registered damage.
633
+ Corollary: **cap top speeds against the network, not just the physics** — an object's arena/table
634
+ crossing time should stay above ~2× the smoothing delay (~0.25 s), or receivers are reacting to
635
+ history no matter how correct the code is.
636
+
590
637
  ## Shared objects (the ball, the NPC) — use `objects`, never `shared`
591
638
 
592
639
  A ball belongs to no player. Put it on `objects`: exactly one client owns it at a time (the SDK +
@@ -611,6 +658,67 @@ contact remains valid. Keep object state flat. For Rapier pushables, install the
611
658
  with `genex controller networked-physics`; see
612
659
  [references/host-physics.md](references/host-physics.md).
613
660
 
661
+ **OWNERSHIP INVARIANT — a host-simulated object MUST be claimed before it publishes.**
662
+ `objects.set()` / `objects.snap()` on an object you do not own are ignored; SDK ≥0.10.2 warns once
663
+ per object/operation instead of failing silently.
664
+ The field-verified symptom is unmistakable — *the object moves on the host's screen and sits
665
+ frozen at spawn for everyone else* (each client falls back to whatever local body it has; only
666
+ the host's is simulated). If the host simulates an object (a crate, an NPC, a puck), it must:
667
+
668
+ ```ts
669
+ let hostReady: Promise<boolean> | null = null;
670
+ function ensureHostObjects(room: Session<S>) {
671
+ if (!room.isHost) return Promise.resolve(false);
672
+ if (hostReady) return hostReady; // one adoption flight, never per tick
673
+ hostReady = (async () => {
674
+ for (const id of HOST_OBJECT_IDS) {
675
+ const before = room.objects.get(id)?.stateRaw; // last published truth, before claiming
676
+ const res = await room.objects.claimConfirmed(id, { authority: "host" });
677
+ if (!res.accepted) return false;
678
+ seedPhysicsFromRaw(id, before); // pose + velocity/cooldowns, never zero
679
+ }
680
+ return true;
681
+ })();
682
+ return hostReady;
683
+ }
684
+
685
+ room.on("host", () => { hostReady = null; void ensureHostObjects(room); });
686
+ room.onHostTick(30, async () => {
687
+ if (!(await ensureHostObjects(room))) return; // no step/publish before adoption
688
+ stepAndPublishHostPhysics();
689
+ });
690
+ ```
691
+
692
+ Do not assume `onHostTick` firing means you own anything — host *election* and object
693
+ *ownership* are separate systems. Claim explicitly, check `accepted`, re-claim on migration, and
694
+ do not step or publish until the whole host-owned set is ready.
695
+
696
+ **RENDER SPLIT — the host draws its own authority, only NON-hosts read the wire.** Once the
697
+ host owns the object and publishes, `objects.get(id).state` becomes defined on EVERY client
698
+ including the host — and if the host now renders from that networked `state` instead of its
699
+ own sim body, a subtle trap bites: a freshly-claimed object's smoothed `state` can briefly be
700
+ a DEGENERATE transform (a zero/NaN quaternion, or a position mid-interpolation from origin),
701
+ which renders the mesh to NaN and it vanishes ON EVERY SCREEN. Field-verified: enabling the
702
+ claim above without this split made a crate invisible for everyone. The fix:
703
+
704
+ ```ts
705
+ // Host renders its own authoritative sim; non-hosts render the published truth, GUARDED.
706
+ const cs = room.isHost ? null : room.objects.get(id)?.state;
707
+ const ok = cs && Number.isFinite(cs.x) && Number.isFinite(cs.y) && Number.isFinite(cs.z);
708
+ if (ok) {
709
+ mesh.position.set(cs.x, cs.y, cs.z);
710
+ if (Array.isArray(cs.q) && cs.q.length === 4) { // normalize — a bad sample must not vanish the mesh
711
+ const n = Math.hypot(cs.q[0], cs.q[1], cs.q[2], cs.q[3]);
712
+ if (n > 1e-3) mesh.quaternion.set(cs.q[0]/n, cs.q[1]/n, cs.q[2]/n, cs.q[3]/n);
713
+ }
714
+ } else {
715
+ mesh.position.copy(localBodyPos); // host, offline, or non-host awaiting first valid sample
716
+ }
717
+ ```
718
+ Prefer syncing the MINIMAL transform a slide/roll needs (a puck on a plane is `{x,z}` + a
719
+ constant y — no quaternion at all); every field you don't send is a field that can't arrive
720
+ degenerate. The netcode-park reference does exactly this.
721
+
614
722
  ## Host authority (scores, rounds, enemies)
615
723
 
616
724
  One client is the `host`. Let *only* the host write agreed state and simulate shared enemies, so
@@ -776,11 +884,16 @@ host-driven saving works as long as ANY account is in the room.
776
884
 
777
885
  ## Checklist
778
886
 
779
- - [ ] `npm i @genex-ai/multiplayer@^0.10.1` (connected presence, supplier auth, confirmed controls, snap epochs, reconnect-safe host ticks); config wired into the build.
887
+ - [ ] `npm i @genex-ai/multiplayer@^0.10.2` (connected presence, supplier auth, confirmed controls, snap epochs, reconnect-safe host ticks, unowned-write warnings); config wired into the build.
888
+ - [ ] The plan names one net model, the player-experience reason, start/quorum, late-join/backfill,
889
+ and below-quorum/end behavior. The agent inferred it unless the experience was genuinely ambiguous.
780
890
  - [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
781
891
  - [ ] `connect()` terminal `disconnect` starts a guarded backoff rejoin that reruns
782
892
  `waitForPlayer()` and reads fresh auth on every attempt; deliberate leave stops it;
783
893
  replacement code `4409` NEVER auto-rejoins.
894
+ - [ ] `connect()` lifecycle is honest: an always-online world has no fake Play screen and may
895
+ connect/spawn after identity; if a Play/Online/Local/Bots menu exists, connect and spawn happen
896
+ only after the online click, Local/Bots stay offline, and leaving calls `room.leave()`.
784
897
  - [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
785
898
  - [ ] Pushable/ownable objects (ball, box, prop) use claim-on-touch + a Rapier proxy (the soft handoff glides the handoff); only a genuine simultaneous tug-of-war (sumo) uses the host-authoritative pattern. See host-physics.md.
786
899
  - [ ] Irreversible actions wait for `claimConfirmed`; held contact retries after `retryAfterMs` while still valid.
@@ -830,6 +943,8 @@ host-driven saving works as long as ANY account is in the room.
830
943
  per-client, never `mm.matchmaking.teams`, never a default for the unassigned — and you
831
944
  WATCHED two browsers land on OPPOSITE teams.
832
945
  - [ ] Picked the matching recipe from [references/genre-recipes.md](references/genre-recipes.md).
946
+ - [ ] Passed the [netcode feel gate](references/genex-netcode-feel-checklist.md), including the two-
947
+ identity real-input proof for the chosen model.
833
948
 
834
949
  ## Troubleshooting auth
835
950
 
@@ -0,0 +1,61 @@
1
+ # Genex multiplayer netcode feel gate
2
+
3
+ Run this gate before calling any multiplayer loop playable. A build, screenshot, or one local client
4
+ does not prove networking. Keep the check proportional: one focused two-client pass, not a new test
5
+ suite for the game.
6
+
7
+ ## Before coding
8
+
9
+ - Write the net-model line: `connect` or `matchmake`, the player-experience reason, start/quorum,
10
+ late-join/backfill, and below-quorum/end behavior.
11
+ - Infer the model from the experience. Ask the user only if both an ongoing drop-in world and fresh
12
+ bounded sessions are plausible and the brief does not choose between them.
13
+ - State authority per thing: local player, remote player, shared object, score/round, projectile/hit,
14
+ and host-simulated entity.
15
+ - Budget the fastest interaction. Rendered remote state is delayed for smoothness; gameplay tests use
16
+ `stateRaw`, sweep between raw samples, and cap speeds so reaction windows remain humanly possible.
17
+
18
+ ## Lifecycle
19
+
20
+ - `connect` always-online world: no fake Play/finding screen; identity may be followed by immediate
21
+ join/spawn. Leaving is explicit and stops terminal rejoin.
22
+ - `connect` behind Play/Online/Local/Bots: no relay contact or network spawn before the online click;
23
+ Local/Bots stay offline; leaving online calls `room.leave()`.
24
+ - `matchmake`: create the handle only on Play/Find Match; show searching/waiting only after that
25
+ commitment; leaving calls `mm.cancel()`; bind every replacement `mm.session`.
26
+ - Quorum-required games leave `playing` when connected quorum falls below the declared minimum.
27
+ Ongoing shared worlds may continue solo only when that was the stated design.
28
+
29
+ ## Authority and interaction
30
+
31
+ - Self movement and immediate reversible feedback happen locally on the input frame.
32
+ - Remote players and non-owned objects render `state` directly, with no second interpolator.
33
+ - Every gameplay read uses `stateRaw`: hit, return/deflect/catch, reach, pickup, goal, physics adoption,
34
+ hp/ammo/flags, and target selection.
35
+ - Fast bodies/projectiles use a segment or swept-volume test from previous raw position to current raw
36
+ position; point samples are not enough.
37
+ - Irreversible shared results wait for confirmed authority. `objects.set`/`snap` happen only after an
38
+ accepted claim; host simulation waits for single-flight adoption and seeds from last raw truth.
39
+ - Every travel-time PvP projectile has attacker-side raw detection plus victim-side/self detection
40
+ where appropriate, both feeding one projectile-id-deduped hit application.
41
+ - Hitstop never pauses the networking pump or another player's simulation. Freeze/slow the local
42
+ presentation and locally owned gameplay clock only; keep sends, receives, reconnects, and quorum
43
+ checks running.
44
+
45
+ ## One focused proof with two distinct identities
46
+
47
+ Use regular + incognito guest identities, two accounts/devices, or ask the user to perform the exact
48
+ sequence. Two tabs sharing one identity are one enforced seat.
49
+
50
+ 1. Enter online using real clicks. Then press Space/Enter/the main action using real key input; confirm
51
+ focus did not re-trigger Play/Leave and both sessions remain live.
52
+ 2. Confirm reciprocal presence and movement: A sees B move and B sees A move.
53
+ 3. Exercise the signature interaction at full intended speed: kick/return, projectile hit, shared
54
+ object claim, enemy hit, or vehicle seat. Confirm immediate local feedback and exactly one result.
55
+ 4. Exercise one authority transition: object ownership, seat, or host migration. Confirm no freeze,
56
+ teleport-to-origin, duplicate point/damage, or host-only visual divergence.
57
+ 5. For `matchmake`, drop one player, observe quorum/waiting behavior, then join a new identity and
58
+ confirm re-seat/backfill. For `connect`, verify the declared leave/solo/host-migration behavior.
59
+
60
+ If this proof was not possible, say exactly which items remain unverified. Never substitute a
61
+ screenshot or local-test mode for multiplayer evidence.
@@ -34,9 +34,18 @@ The genre where a single contested object is the whole game.
34
34
  everyone else) and writes the score to `shared`; everyone reads `shared.get("score_a")` in the HUD.
35
35
  - **A player quitting mid-match doesn't kill the ball** — if the owner leaves, the ball is
36
36
  reassigned to the host automatically and play continues.
37
+ - **The receiver's interaction window reads `stateRaw`, never `state`** — return/deflect/catch
38
+ checks against the smoothed ball test a ghost ~120 ms in the past; at rally speeds the REAL
39
+ ball has already crossed your paddle/goal line before the ghost arrives (field-verified: it
40
+ made a pong-style game unreturnable online while feeling perfect in solo play). Render the
41
+ smoothed ball, but run the window on the raw one, sweeping raw-sample→raw-sample.
42
+ - **Cap top ball speed against the network:** keep the arena/table crossing time above ~2× the
43
+ smoothing delay (≳0.25 s). Faster than that, no human can respond to what they're shown —
44
+ no amount of correct code fixes reacting to history.
37
45
 
38
46
  **Acceptance feel:** the kicker sees the ball respond the same frame; everyone else sees it glide;
39
- a contested kick settles on one owner within a snapshot; the owner leaving doesn't freeze the ball.
47
+ a contested kick settles on one owner within a snapshot; the owner leaving doesn't freeze the ball;
48
+ a full-speed shot is still humanly returnable by the receiving player.
40
49
 
41
50
  ---
42
51
 
@@ -94,6 +103,25 @@ slow physical projectiles, all against the same damage/defeat rules.)
94
103
  scores exactly one point even if the host changes mid-fight; the scoreboard survives a reload; one
95
104
  player on a throttled/backgrounded tab doesn't drag others.
96
105
 
106
+ ### Every travel-time projectile vs moving targets — the "visual hit, no damage" trap
107
+
108
+ Any projectile that spends time moving through the world — object-owned, host-simulated, or
109
+ deterministically replayed from a throw event (fireball, rocket, dodgeball) — inherits BOTH classic
110
+ netcode failures, and the symptom is always the same: *the projectile visibly hits a strafing player
111
+ and nothing happens.* Do not limit these rules to one implementation style:
112
+
113
+ 1. **Detection must run on the ATTACKER too, against `stateRaw`.** Target-only self-detection
114
+ ("each player owns their own hp, so only I test hits on me") silently fails against movers:
115
+ the thrower aimed at the smoothed ghost (~120 ms old), the deterministic projectile flies to
116
+ where the target WAS, and the target's self-test against its own REAL position never fires.
117
+ Run the attacker-side test each frame vs every remote's `stateRaw`, then send the damage
118
+ event the victim always honors (dedupe by projectile id so self-detection can coexist).
119
+ 2. **Sweep, never point-sample.** A projectile at 20 m/s moves ~0.33 m per 60 Hz frame — more
120
+ than most hit radii. Track `prev` each frame and test segment(prev→pos) vs the target
121
+ sphere, on both the attacker's and the target's tests.
122
+ 3. Both channels apply damage through ONE deduped `applyHit(projectileId)` on the victim, so a
123
+ ball registered by both sides still counts once.
124
+
97
125
  ---
98
126
 
99
127
  ## Recipe 3 — Co-op vs enemies (horde, tower defense, dungeon)
@@ -178,17 +206,22 @@ the short-handed side.
178
206
 
179
207
  Almost every game opens on a menu: **Play Online**, **Local / Single-player**, **Bots**. The rule
180
208
  that keeps online matches clean: **the menu is pre-multiplayer — there is no room, no queue, no
181
- server contact until the player commits to online.** `matchmake()` IS the "Play Online" button. Call
182
- it anywhere earlier (page load, boot code, beside `waitForPlayer()`) and a player who picks Bots is
183
- still parked in an online room, counting toward `minPlayers` while three real players wait for a
184
- fourth who never comes.
209
+ server contact until the player commits to online.** The chosen online API — `matchmake()` for fresh
210
+ bounded sessions, `connect()` for one ongoing shared world IS the "Play Online" button. Call either
211
+ one earlier (page load, boot code, beside `waitForPlayer()`) and a player who picks Bots is already
212
+ present online. With matchmaking they contaminate quorum; with a shared world they spawn an avatar
213
+ for someone who never chose to enter it.
214
+
215
+ Exception: a truly always-online game may call `connect()` after identity and spawn immediately,
216
+ but then loading the game already means "join" — it has no Local/Bots choice and no fake Play screen.
217
+ An ongoing drop-in sumo ring can use that shape; a sumo title menu cannot auto-spawn behind Play.
185
218
 
186
219
  | Moment | What runs | Relay contact |
187
220
  | --- | --- | --- |
188
221
  | Page load → menu | An **offline world** generated locally, menu overlay on top | NONE. Not `connect()`, not `matchmake()`. `waitForPlayer()` may run (mints identity only, seats nobody). |
189
222
  | "Bots" / "Local" | The same offline world + local AI / single-player | NONE, ever. |
190
- | "Play Online" | `matchmake()` → queue → the server seats you | FIRST contact. Only now are you a counted participant. |
191
- | Leaving online (back to menu / quit / switch to Bots after being seated) | Tear down the online view, return to the offline menu | `mm.cancel()` frees the seat so you stop counting toward `minPlayers`. |
223
+ | "Play Online" | `matchmake()` → queue/seat, or `connect()` ongoing shared world | FIRST contact. Only now are you an online participant. |
224
+ | Leaving online (back to menu / quit / switch to Bots after joining) | Tear down the online view, return to the offline menu | `mm.cancel()` for matchmaking; intentional `room.leave()` for connect. |
192
225
 
193
226
  **Decisions:**
194
227
  - **Every menu action blurs its button before acting.** Otherwise the first gameplay Space/Enter
@@ -196,6 +229,9 @@ fourth who never comes.
196
229
  menu/lobby phases (see the game-ui skill).
197
230
  - **`matchmake()` is created lazily, on the click — not held from boot.** Keep the handle in a
198
231
  variable so you can `cancel()` it; create it inside the "Play Online" handler, not at module load.
232
+ - **`connect()` follows the same commitment rule when this menu exists.** Create the session and
233
+ network avatar inside the online handler; intentional leave disables the rejoin loop, calls
234
+ `room.leave()`, removes the online avatar, and returns to the offline world.
199
235
  - **Bots/Local touch nothing networked.** They run the exact offline world the menu already booted.
200
236
  A player can sit in Bots forever and the online queue never knows they exist — which is the point.
201
237
  - **The waiting screen is an online-only, post-commit overlay.** Show it only when
@@ -181,17 +181,27 @@ room.inputs.on((fromId, payload) => {
181
181
  if (p?.obj && Array.isArray(p.push)) pending.push(p as { obj: string; push: number[] });
182
182
  });
183
183
 
184
- room.onHostTick(30, async (dtMs) => {
185
- // 1) First tick after election: adopt the objects + seed the physics world from the
186
- // last published truth (stateRaw) NEVER from zero, or the world teleports.
187
- for (const id of CONTESTED_IDS) {
188
- const view = room.objects.get(id);
189
- if (!view?.isMine) {
184
+ let hostReady: Promise<boolean> | null = null;
185
+ function ensureHostReady() {
186
+ if (!room.isHost) return Promise.resolve(false);
187
+ if (hostReady) return hostReady; // single flight: async host ticks never overlap adoption
188
+ hostReady = (async () => {
189
+ for (const id of CONTESTED_IDS) {
190
+ const raw = room.objects.get(id)?.stateRaw; // capture BEFORE claim changes local ownership view
190
191
  const result = await room.objects.claimConfirmed(id, { authority: "host" });
191
- if (!result.accepted) continue;
192
- seedRapierBody(id, view?.stateRaw); // position/rotation/velocity from the wire
192
+ if (!result.accepted) return false;
193
+ seedRapierBody(id, raw); // position/rotation/velocity from the wire, NEVER zero
193
194
  }
194
- }
195
+ pending.length = 0; // discard intent queued for the previous host/timeline
196
+ return true;
197
+ })();
198
+ return hostReady;
199
+ }
200
+ room.on("host", () => { hostReady = null; void ensureHostReady(); });
201
+
202
+ room.onHostTick(30, async (dtMs) => {
203
+ // 1) No physics or publishing until the whole host-owned set is adopted and seeded.
204
+ if (!(await ensureHostReady())) return;
195
205
  // 2) Apply everyone's inputs to the ONE authoritative Rapier world.
196
206
  for (const { obj, push } of pending.splice(0)) applyImpulse(obj, push);
197
207
  // 3) Step and publish (flat state: numbers + one [x,y,z,w] quaternion).
@@ -206,10 +216,13 @@ room.onHostTick(30, async (dtMs) => {
206
216
  });
207
217
  const r2 = (v: number) => Math.round(v * 100) / 100; // quantize — floats are JSON bloat
208
218
 
209
- // ---- rendering: identical on every client, host included ----
219
+ // ---- rendering: host draws its live simulation; followers draw the guarded smooth stream ----
210
220
  for (const id of CONTESTED_IDS) {
221
+ if (room.isHost) { drawFromBody(meshOf(id), bodyOf(id)); continue; }
211
222
  const view = room.objects.get(id);
212
- if (view) meshOf(id).position.set(view.state.x, view.state.y, view.state.z); // auto-smoothed
223
+ if (view && Number.isFinite(view.state.x) && Number.isFinite(view.state.y) && Number.isFinite(view.state.z)) {
224
+ meshOf(id).position.set(view.state.x, view.state.y, view.state.z); // auto-smoothed
225
+ }
213
226
  }
214
227
  ```
215
228
 
@@ -218,6 +231,8 @@ Rules that make it correct:
218
231
  - `onHostTick` pauses during reconnect and stops on demotion/leave/terminal disconnect. Bound the input
219
232
  queue, attribute input from the callback's authenticated `fromId` (never payload `from`), and discard
220
233
  stale pre-failover inputs when a new host adopts raw state.
234
+ - Host election and object ownership are separate. Adoption is one async flight per host term; never
235
+ issue claims every tick, and never step/publish until every required claim was accepted.
221
236
 
222
237
  - **Sim internals that must survive migration** (velocities, cooldowns, aggro) either live in
223
238
  the published object state or get mirrored at low rate into a dedicated object
@@ -90,12 +90,16 @@ execution order.
90
90
  **Multiplayer is mandatory routing:** if the game has 2+ players sharing a world, loading
91
91
  `$genex-threejs-multiplayer` is **required** before any networking code — the SDK auto-smooths
92
92
  remote players **and shared objects**, and gives you server-enforced object ownership (a ball) and
93
- a room `host` (scores, enemies). Choose the net model before coding: `connect()` is one shared-world
94
- room; `matchmake()` creates capped queued rooms. Only `matchmake()` requires a server-owned
95
- `genex.matchmaking` block in `package.json`, reported by preview/publish. Both models accept a fresh
96
- auth supplier, but their terminal-recovery duties differ, so follow the chosen model's section rather than
97
- mixing the two. The skill also covers the rules that keep it smooth and its per-genre recipes
98
- (sports/ball, shooter, co-op).
93
+ a room `host` (scores, enemies). Choose the net model from the player experience before coding:
94
+ `connect()` is one ongoing drop-in world; `matchmake()` forms capped rooms for fresh matches or
95
+ missions with quorum/fair-start/backfill rules. Infer it when clear; ask one plain-language
96
+ ongoing-world-vs-fresh-session question only when both experiences genuinely fit. A Play/Online
97
+ button is always the commitment point for either API; only a truly always-online `connect()` world
98
+ may join/spawn immediately, and then it must not show a fake Play screen. Only `matchmake()` requires
99
+ a server-owned `genex.matchmaking` block in `package.json`, reported by preview/publish. Both models
100
+ accept a fresh auth supplier, but their terminal-recovery duties differ, so follow the chosen model's
101
+ section rather than mixing the two. Run that skill's netcode feel gate before handoff. The skill also
102
+ covers the rules that keep it smooth and its per-genre recipes (sports/ball, shooter, co-op).
99
103
 
100
104
  ## Real (AI-generated) assets — `npx genex` commands
101
105
 
@@ -243,4 +243,11 @@ claim-on-touch + a Rapier proxy — the soft handoff glides the ownership change
243
243
  **simultaneous** contest (two players pushing one crate against each other — sumo, tug-of-war)
244
244
  uses the host-authoritative pattern (`inputs` + `onHostTick`). Both are in that skill's
245
245
  host-physics reference.
246
+ Choose the net model from player experience: one ongoing drop-in world uses `connect()`; a fresh
247
+ bounded match/mission with quorum, fair start, teams, backfill, or parallel sessions uses
248
+ `matchmake()`. Infer this when the brief is clear. Ask one experience-level question only when both
249
+ are genuinely plausible — never ask the user to select an SDK API or preset. For either model, a
250
+ Play/Online button is the first relay contact; immediate `connect()` + spawn is valid only when
251
+ loading the game already means joining the always-online world and there is no fake Play screen.
252
+ Before handoff, run the multiplayer skill's netcode feel gate with two distinct identities.
246
253
  Use only the APIs that skill documents — do not invent transport methods.