@genex-ai/cli-demo 0.15.0 → 0.17.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/dist/index.js +20 -0
- package/package.json +1 -1
- package/templates/controllers/shared/NETWORKING.md +11 -0
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +123 -4
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +111 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +5 -1
package/dist/index.js
CHANGED
|
@@ -1110,6 +1110,24 @@ async function detectMultiplayer(cwd = process.cwd()) {
|
|
|
1110
1110
|
return false;
|
|
1111
1111
|
}
|
|
1112
1112
|
}
|
|
1113
|
+
async function detectMatchmaking(log, cwd = process.cwd()) {
|
|
1114
|
+
let pkg;
|
|
1115
|
+
try {
|
|
1116
|
+
pkg = JSON.parse(await fs8.readFile(path9.join(cwd, "package.json"), "utf8"));
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
const mm = pkg.genex?.matchmaking;
|
|
1122
|
+
if (mm && typeof mm.preset === "string" && mm.preset) {
|
|
1123
|
+
return {
|
|
1124
|
+
preset: mm.preset,
|
|
1125
|
+
...mm.winCondition ? { winCondition: mm.winCondition } : {},
|
|
1126
|
+
...mm.config ? { config: mm.config } : {}
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1113
1131
|
async function runPublish(opts) {
|
|
1114
1132
|
const log = createLogger({ quiet: opts.quiet });
|
|
1115
1133
|
log.plain(c.bold("genex publish"));
|
|
@@ -1151,6 +1169,8 @@ async function runPublish(opts) {
|
|
|
1151
1169
|
const embedSdkVersion = await detectEmbedSdkVersion();
|
|
1152
1170
|
if (embedSdkVersion) body.embedSdkVersion = embedSdkVersion;
|
|
1153
1171
|
body.multiplayer = await detectMultiplayer();
|
|
1172
|
+
const matchmaking = await detectMatchmaking(log);
|
|
1173
|
+
if (matchmaking) body.matchmaking = matchmaking;
|
|
1154
1174
|
res = await fetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
|
|
1155
1175
|
method: "POST",
|
|
1156
1176
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
package/package.json
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Networking these controllers
|
|
2
|
+
|
|
3
|
+
These controllers simulate **your own player only** (self-authoritative, zero input
|
|
4
|
+
latency). To show OTHER players' rigs in a multiplayer game, publish a small flat state on
|
|
5
|
+
a fixed tick and play it back on a visual-only remote rig — never instantiate a controller
|
|
6
|
+
or a Rapier body for a remote player.
|
|
7
|
+
|
|
8
|
+
The complete recipe (what to publish per controller, remote playback, contested-contact
|
|
9
|
+
physics via the host) lives in the multiplayer skill:
|
|
10
|
+
`genex-threejs-multiplayer` → `references/host-physics.md`. Load that skill before writing
|
|
11
|
+
any networking code.
|
|
@@ -37,7 +37,71 @@ example, the shared-object/ball code, rotation, and host usage. Read
|
|
|
37
37
|
npm i @genex-ai/multiplayer
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
This skill targets `@genex-ai/multiplayer` **≥ 0.8.0** (`objects`/`host` since 0.4; `matchmake()` since 0.5; presets + `score()`/`finish()` since 0.6; `createPrivate()`/`joinPrivate()` since 0.7; auto-reconnect + `inputs`/`onHostTick` since 0.8).
|
|
41
|
+
|
|
42
|
+
## Trust model (say it plainly in your game's copy)
|
|
43
|
+
|
|
44
|
+
This is **casual, favor-the-player multiplayer** (Haxball, not Rocket League): you are
|
|
45
|
+
authoritative over yourself, match outcomes are self-reported, and there is no server-side
|
|
46
|
+
simulation or anti-cheat. The server DOES enforce identity (verified tokens), object
|
|
47
|
+
ownership, match seating/adjudication, and rate/size caps — but a modified client can still
|
|
48
|
+
lie about its own position or score. Great for friends and casual lobbies; don't promise
|
|
49
|
+
ranked-grade fairness.
|
|
50
|
+
|
|
51
|
+
## Matchmaking (competitive presets — server-owned)
|
|
52
|
+
|
|
53
|
+
When players should be **matched into separate capped rooms** rather than share one big room (a 1v1
|
|
54
|
+
duel, an FFA arena, N-v-N teams, an invite lobby), use `matchmake()` instead of `connect()`. It
|
|
55
|
+
returns a handle whose **`session` is `null` while searching** — render your OWN "finding a match…"
|
|
56
|
+
HUD from `mm.matchmaking` (its `status`, `queue.position`, `players`/`opponents`, `teams`, `scores`,
|
|
57
|
+
`winCondition`), and switch to the game once `session` goes live:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// Pass auth as a FUNCTION — one matchmake() handle re-joins the queue/match many times (re-search,
|
|
61
|
+
// requeue) over a session, and embed tokens rotate (~10 min). A function is read fresh each (re)join;
|
|
62
|
+
// a static object goes stale and gets rejected mid-session.
|
|
63
|
+
const mm = await matchmake<MyState>({ url, room: slug, auth: () => getColyseusAuth() });
|
|
64
|
+
mm.on('matched', () => {/* session is live — start the game */});
|
|
65
|
+
// each frame: if (mm.session) renderGame(mm.session); else renderSearchingHud(mm.matchmaking);
|
|
66
|
+
mm.on('matchEnded', ({ winnerId, scores, draw }) => {/* result screen */});
|
|
67
|
+
mm.on('error', (e) => {/* a (re)join failed, e.g. auth — usually transient; the handle keeps searching */});
|
|
68
|
+
|
|
69
|
+
// Report ONLY your own outcome — the server adjudicates. Which call fits depends on the win condition:
|
|
70
|
+
mm.eliminated(); // I'm out (lastStanding)
|
|
71
|
+
mm.score(1); // I scored (firstToScore / highScoreInTime)
|
|
72
|
+
mm.finish(); // I finished the race (firstToFinish)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Everything is **server-owned** — set once in `package.json` under `genex.matchmaking`, reported at
|
|
76
|
+
publish; the client declares nothing. You never run matchmaking logic: the server owns the queue,
|
|
77
|
+
roles, winner-stays, forfeit, timeout, and the win condition.
|
|
78
|
+
|
|
79
|
+
```jsonc
|
|
80
|
+
"genex": {
|
|
81
|
+
"matchmaking": {
|
|
82
|
+
"preset": "arena", // duel | arena | teams | private
|
|
83
|
+
"winCondition": "firstToScore", // lastStanding | firstToScore | highScoreInTime | firstToFinish
|
|
84
|
+
"config": { "scoreTarget": 20, "maxPlayers": 8 } // numeric knobs, optional
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Presets: **duel** (1v1 winner-stays), **arena** (N-player FFA, join-anytime), **teams** (balanced
|
|
90
|
+
N-v-N), **private** (invite-code lobby). Win conditions: **lastStanding** (last one alive),
|
|
91
|
+
**firstToScore** (first to the score target), **highScoreInTime** (top score at the time cap),
|
|
92
|
+
**firstToFinish** (first to finish). A round that hits the time cap undecided is a draw.
|
|
93
|
+
|
|
94
|
+
**Private lobbies** (for `preset: 'private'`) don't use `matchmake()` — a host makes an invite code
|
|
95
|
+
and friends join it; the lobby is persistent (rounds replay, nobody is evicted):
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
|
|
99
|
+
const lobby = await createPrivate<MyState>({ url, room: slug, auth: () => getColyseusAuth() }); // live NOW
|
|
100
|
+
showCode(lobby.code); // share this
|
|
101
|
+
// a friend, elsewhere:
|
|
102
|
+
const lobby = await joinPrivate<MyState>(code, { url, room: slug, auth: () => getColyseusAuth() });
|
|
103
|
+
// same handle API as matchmake(): lobby.session, lobby.matchmaking, eliminated()/score()/finish(), cancel()
|
|
104
|
+
```
|
|
41
105
|
|
|
42
106
|
## Connect
|
|
43
107
|
|
|
@@ -65,6 +129,36 @@ const room = await connect<State>({
|
|
|
65
129
|
});
|
|
66
130
|
```
|
|
67
131
|
|
|
132
|
+
**Capacity:** a room holds up to **64 players** (up to 48 of them guests). Above that, the
|
|
133
|
+
relay opens a **second room for the same game** — two parallel worlds, no error. If your
|
|
134
|
+
game needs seated, one-world competition, use the matchmaking presets instead of one big
|
|
135
|
+
`connect()` room.
|
|
136
|
+
|
|
137
|
+
## Disconnects & reconnection (built in — render it, don't rebuild it)
|
|
138
|
+
|
|
139
|
+
The SDK auto-reconnects after a network blip or brief signal loss: the relay holds your seat
|
|
140
|
+
for a grace window (~30 s), and on recovery **nothing changed** — same session id, objects
|
|
141
|
+
still yours, host unchanged, and in a match **a blip is not a forfeit**. Your only job is UI:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
room.on("reconnecting", ({ attempt }) => showOverlay(`Reconnecting… (${attempt})`));
|
|
145
|
+
room.on("reconnected", () => hideOverlay());
|
|
146
|
+
room.on("disconnect", (code) => {
|
|
147
|
+
// Terminal: server restart, revoked session, or the link never came back.
|
|
148
|
+
// To play again, read a FRESH token and connect() anew — never reuse the old auth object.
|
|
149
|
+
showMenu("Connection lost");
|
|
150
|
+
});
|
|
151
|
+
room.on("server:restart", () => flushSaves()); // the relay warns before a deploy — save now
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Keep your render loop running during `reconnecting` — remote players freeze briefly and then
|
|
155
|
+
glide on; don't tear the scene down. A deliberate `room.leave()` never auto-reconnects.
|
|
156
|
+
|
|
157
|
+
**One seat per player (enforced server-side):** joining the same game again — a second tab,
|
|
158
|
+
another device, or a page reload — instantly evicts the previous session (it gets
|
|
159
|
+
`disconnect`, code 4409). You never need to handle "the same player twice" and a reload
|
|
160
|
+
never leaves a ghost avatar behind.
|
|
161
|
+
|
|
68
162
|
## Which channel for which data
|
|
69
163
|
|
|
70
164
|
**This table is the most important thing in this skill.** Every piece of networked state is one
|
|
@@ -105,13 +199,21 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
105
199
|
`'host'` `(id)`, and any custom `send` name.
|
|
106
200
|
- `room.send(type, payload)` — fire-and-forget to all **other** clients. **It never echoes to
|
|
107
201
|
you**, so apply your own action's local effect directly (draw your own tracer at fire time),
|
|
108
|
-
not inside `on(...)`. `
|
|
202
|
+
not inside `on(...)`. Relay-internal names (`state`, `shared`, `claim`, `obj`, `release`,
|
|
203
|
+
`destroy`, `match:*`, `__*`) are refused — pick your own event names. `room.leave()`.
|
|
204
|
+
- `room.inputs.send(payload)` / `room.inputs.on((fromId, payload) => …)` — the host-routed
|
|
205
|
+
input channel for host-authoritative physics: anyone sends, ONLY the current host receives.
|
|
206
|
+
See [references/host-physics.md](references/host-physics.md).
|
|
207
|
+
- `room.onHostTick(hz, cb)` — run a fixed simulation tick only while you are the host
|
|
208
|
+
(auto-starts/stops across host migration). Returns a disposer.
|
|
109
209
|
|
|
110
210
|
## The loop you must build (input → local → tick → render)
|
|
111
211
|
|
|
112
212
|
1. **Input mutates a local object only** (`me.x += …`). Never network on keypress.
|
|
113
213
|
2. **A fixed tick publishes it:** `setInterval(() => room.me.set(me), 66)` (~15 Hz). If you own an
|
|
114
|
-
object, `objects.set` it in the same tick.
|
|
214
|
+
object, `objects.set` it in the same tick. **Round numbers before publishing** —
|
|
215
|
+
`Math.round(v * 100) / 100` (2 decimals ≈ cm precision) — raw floats serialize as 17-digit
|
|
216
|
+
JSON and are the #1 bandwidth waste; nobody can see a 0.001-unit difference.
|
|
115
217
|
3. **Render at your own framerate:** yourself from your *local* object; every other player from
|
|
116
218
|
`players.get(id).state` directly (already smoothed); every object from `objects.get(id).state`.
|
|
117
219
|
4. **Create-or-reuse one mesh per id**; remove a player's mesh on `'leave'`.
|
|
@@ -163,6 +265,17 @@ For host-simulated NPCs, the host claims and drives each enemy as an `object`; w
|
|
|
163
265
|
leaves, its enemies are reassigned to the new host, which reads their `stateRaw` and keeps
|
|
164
266
|
simulating. See the co-op recipe in [references/genre-recipes.md](references/genre-recipes.md).
|
|
165
267
|
|
|
268
|
+
### Contested physics (two players pushing ONE thing) — host-authoritative
|
|
269
|
+
|
|
270
|
+
Claim-on-touch is perfect for one-touch objects (kick a ball). It **breaks down under
|
|
271
|
+
sustained contact** — two players pushing the same crate steal ownership back and forth and
|
|
272
|
+
the crate judders. For contested objects, ONE simulation must own the contest: the **host**
|
|
273
|
+
runs the physics for those objects; everyone else sends **inputs**
|
|
274
|
+
(`room.inputs.send({ push })`), which the relay routes to the host only; the host applies
|
|
275
|
+
them on `room.onHostTick(...)` and publishes results via `objects` (smooth for everyone).
|
|
276
|
+
Full recipe — including surviving host migration and wiring the Rapier controllers —
|
|
277
|
+
in [references/host-physics.md](references/host-physics.md).
|
|
278
|
+
|
|
166
279
|
## Smoothness is felt, not seen — hand the feel to a human
|
|
167
280
|
|
|
168
281
|
Lag and stutter are *motion over time*. A screenshot is one frozen instant, so **you cannot tell
|
|
@@ -246,7 +359,10 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
246
359
|
|
|
247
360
|
## Checklist
|
|
248
361
|
|
|
249
|
-
- [ ] `npm i @genex-ai/multiplayer` (≥ 0.
|
|
362
|
+
- [ ] `npm i @genex-ai/multiplayer` (≥ 0.8.0 — auto-reconnect, `inputs`, `onHostTick`); config wired into the build.
|
|
363
|
+
- [ ] `reconnecting`/`reconnected`/`disconnect` render an overlay (don't tear the scene down).
|
|
364
|
+
- [ ] Numbers rounded (~2 decimals) before `me.set`/`objects.set`.
|
|
365
|
+
- [ ] Contested (sustained-contact) objects use the host-physics pattern, not claim-on-touch.
|
|
250
366
|
- [ ] `connect()` runs AFTER `await waitForPlayer()` (never `waitForAuth()` — guests would
|
|
251
367
|
hang) and passes `auth: getColyseusAuth()!` (the relay rejects tokenless joins —
|
|
252
368
|
see `genex-threejs-embed-auth`).
|
|
@@ -268,3 +384,6 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
268
384
|
read `getColyseusAuth()` fresh at every connect). 403 "wrong game": the `room` value
|
|
269
385
|
doesn't match this game's own slug. 403 "guest capacity": the room is at its guest
|
|
270
386
|
limit — signing in gets the player a seat; surface the message as-is.
|
|
387
|
+
- **`disconnect` fired and the player wants back in** — the old session is dead; run your
|
|
388
|
+
connect flow again from the top with a FRESH `getColyseusAuth()` (a cached auth object is
|
|
389
|
+
the usual cause of a rejoin failing 401).
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Host-authoritative physics — contested objects + networked controllers
|
|
2
|
+
|
|
3
|
+
Two networking tiers exist for moving things, and picking the right one per object is the
|
|
4
|
+
whole trick:
|
|
5
|
+
|
|
6
|
+
| Object kind | Tier | Why |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| One-touch (kick a ball, throw a crate once) | **claim-on-touch** (`objects.claim` on contact, owner simulates) | lowest latency — your kick lands instantly |
|
|
9
|
+
| Sustained contact / contested (two players pushing one crate, tug-of-war, sumo, shared vehicles) | **host-authoritative** (this doc) | one simulation owns the contest — no ownership ping-pong, physics stays consistent |
|
|
10
|
+
|
|
11
|
+
Claim-on-touch under sustained contact means every contact steals ownership, resets the
|
|
12
|
+
simulation, and the object judders between two owners' views. Host-authoritative kills that
|
|
13
|
+
by construction: **the host runs the ONE Rapier world for contested objects; everyone else
|
|
14
|
+
sends inputs.**
|
|
15
|
+
|
|
16
|
+
## The pattern (complete)
|
|
17
|
+
|
|
18
|
+
Every client runs this same code — `onHostTick` only fires on the current host, so there is
|
|
19
|
+
no "am I host?" bookkeeping and host migration is automatic:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// ---- inputs: NON-hosts (and the host itself) report intent, ~10-15Hz or on action ----
|
|
23
|
+
// Routed by the relay to the CURRENT HOST ONLY — never broadcast, cheap.
|
|
24
|
+
if (pushingCrate) room.inputs.send({ obj: "crate", push: dir.toArray() });
|
|
25
|
+
|
|
26
|
+
// ---- simulation: runs ONLY on the host, survives migration ----
|
|
27
|
+
const pending: { obj: string; push: number[] }[] = [];
|
|
28
|
+
room.inputs.on((fromId, payload) => {
|
|
29
|
+
const p = payload as { obj?: string; push?: number[] };
|
|
30
|
+
if (p?.obj && Array.isArray(p.push)) pending.push(p as { obj: string; push: number[] });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
room.onHostTick(30, (dtMs) => {
|
|
34
|
+
// 1) First tick after election: adopt the objects + seed the physics world from the
|
|
35
|
+
// last published truth (stateRaw) — NEVER from zero, or the world teleports.
|
|
36
|
+
for (const id of CONTESTED_IDS) {
|
|
37
|
+
const view = room.objects.get(id);
|
|
38
|
+
if (!view || !view.isMine) {
|
|
39
|
+
room.objects.claim(id);
|
|
40
|
+
seedRapierBody(id, view?.stateRaw); // position/rotation/velocity from the wire
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// 2) Apply everyone's inputs to the ONE authoritative Rapier world.
|
|
44
|
+
for (const { obj, push } of pending.splice(0)) applyImpulse(obj, push);
|
|
45
|
+
// 3) Step and publish (flat state: numbers + one [x,y,z,w] quaternion).
|
|
46
|
+
rapierWorld.step();
|
|
47
|
+
for (const id of CONTESTED_IDS) {
|
|
48
|
+
const b = bodyOf(id);
|
|
49
|
+
room.objects.set(id, {
|
|
50
|
+
x: r2(b.translation().x), y: r2(b.translation().y), z: r2(b.translation().z),
|
|
51
|
+
q: quatArray(b.rotation()),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
const r2 = (v: number) => Math.round(v * 100) / 100; // quantize — floats are JSON bloat
|
|
56
|
+
|
|
57
|
+
// ---- rendering: identical on every client, host included ----
|
|
58
|
+
for (const id of CONTESTED_IDS) {
|
|
59
|
+
const view = room.objects.get(id);
|
|
60
|
+
if (view) meshOf(id).position.set(view.state.x, view.state.y, view.state.z); // auto-smoothed
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Rules that make it correct:
|
|
65
|
+
|
|
66
|
+
- **Sim internals that must survive migration** (velocities, cooldowns, aggro) either live in
|
|
67
|
+
the published object state or get mirrored at low rate into a dedicated object
|
|
68
|
+
(`objects.set("sim", …)`) the next host reads on adoption. Positions/rotations come free
|
|
69
|
+
via `stateRaw`.
|
|
70
|
+
- **Latency honesty:** a non-host's push lands after ~RTT to the relay. At casual scale that
|
|
71
|
+
reads as weight, not lag. The host's own pushes are instant — that asymmetry is the tier's
|
|
72
|
+
price; don't fight it with client-side guessing.
|
|
73
|
+
- **Rate budget:** the host publishes N contested objects at 20–30 Hz; keep N modest (≤ ~10)
|
|
74
|
+
and state flat + quantized. Inputs are single-receiver and cheap.
|
|
75
|
+
- **Do not** run a second Rapier body for a contested object on non-hosts "for prediction" —
|
|
76
|
+
that's the double-simulation version of double-smoothing. Draw `state`.
|
|
77
|
+
|
|
78
|
+
## Networked controllers (the vendored character / vehicle / drone)
|
|
79
|
+
|
|
80
|
+
The `genex controller` controllers are **local-only physics** — each player simulates their
|
|
81
|
+
OWN rig (self-authoritative, zero latency). Networking them is publish-and-playback, never
|
|
82
|
+
remote simulation:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// You: after your controller's update, on the fixed tick (~15Hz)
|
|
86
|
+
room.me.set({
|
|
87
|
+
x: r2(rig.position.x), y: r2(rig.position.y), z: r2(rig.position.z),
|
|
88
|
+
q: rig.quaternion.toArray().map(r2), // quaternion — never a scalar yaw
|
|
89
|
+
anim: rig.animState, // discrete → remotes read via stateRaw
|
|
90
|
+
// vehicle extras: steer: r2(steerAngle), wheel: r2(wheelSpinPhase)
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Remote players: drive a VISUAL-ONLY rig from smoothed state — no Rapier body, no
|
|
94
|
+
// controller instance for remotes. Wheels/limbs animate from the published params.
|
|
95
|
+
const p = room.players.get(id)!;
|
|
96
|
+
remoteMesh.position.set(p.state.x, p.state.y, p.state.z);
|
|
97
|
+
remoteMesh.quaternion.fromArray(p.state.q);
|
|
98
|
+
remoteAnimator.play(p.stateRaw.anim); // discrete values from stateRaw
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
What to publish per controller:
|
|
102
|
+
|
|
103
|
+
- **character**: `x/y/z`, `q`, `anim` (state-machine id), optionally `speed` for blend trees.
|
|
104
|
+
- **vehicle**: body `x/y/z` + `q`, `steer` angle, a `wheel` spin phase (remotes spin wheels
|
|
105
|
+
procedurally — never sync per-wheel transforms).
|
|
106
|
+
- **drone**: `x/y/z`, `q`, rotor throttle if the visual needs it.
|
|
107
|
+
|
|
108
|
+
Player-vs-player physical contact (bumping cars) stays approximate at this tier — each
|
|
109
|
+
client is authoritative over itself, so contacts are cosmetic. If a game's core loop IS
|
|
110
|
+
contested vehicle contact, that's the host-authoritative tier above, with the vehicles as
|
|
111
|
+
host-simulated objects and player inputs over `inputs.send`.
|
|
@@ -69,4 +69,8 @@ NPC, claimed on contact) and a room `host` (single writer of scores, single simu
|
|
|
69
69
|
enemies). That skill covers the rules that keep it smooth (draw `state` directly, render
|
|
70
70
|
yourself and objects you own from a local object, quaternion rotation, `stateRaw` for
|
|
71
71
|
hit-tests), the per-genre recipes (sports/ball, shooter, co-op), config wiring, and the
|
|
72
|
-
persistent-world API.
|
|
72
|
+
persistent-world API. Reconnection is built into the SDK (render `reconnecting`/
|
|
73
|
+
`reconnected`, never rebuild it), and **contested physics** (two players pushing one
|
|
74
|
+
object — sumo, tug-of-war, shared crates) routes to its host-authoritative pattern
|
|
75
|
+
(`inputs` + `onHostTick`, see that skill's host-physics reference) — never claim-on-touch.
|
|
76
|
+
Use only the APIs that skill documents — do not invent transport methods.
|