@genex-ai/cli-demo 1.38.0 → 1.38.1-dev.1097
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/{blender-mcp-QVEGHE5L.js → blender-mcp-WSURPZET.js} +2 -2
- package/dist/{blender-serve-66OHC4ML.js → blender-serve-EH2MOW3U.js} +1 -1
- package/dist/{chunk-4BBTPRJW.js → chunk-EABIVAGT.js} +2 -2
- package/dist/{chunk-QI7FIYBY.js → chunk-K4EHXOZK.js} +1 -1
- package/dist/index.js +1700 -811
- package/package.json +1 -1
- package/templates/skills/genex-authoritative-multiplayer/SKILL.md +208 -0
- package/templates/skills/genex-authoritative-multiplayer/references/module-api.md +172 -0
- package/templates/skills/genex-authoritative-multiplayer/references/physics.md +150 -0
- package/templates/skills/genex-authoritative-multiplayer/references/presets.md +394 -0
- package/templates/skills/genex-authoritative-multiplayer/references/projectiles.md +195 -0
- package/templates/skills/genex-authoritative-multiplayer/references/room-template.md +1189 -0
- package/templates/skills/genex-authoritative-multiplayer/references/settings.md +122 -0
- package/templates/skills/genex-authoritative-multiplayer/references/testing.md +118 -0
- package/templates/skills/genex-game-director/SKILL.md +2 -1
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-llm-in-games/SKILL.md +209 -72
- package/templates/skills/genex-llm-in-games/references/pricing.md +126 -85
- package/templates/skills/genex-monetization/SKILL.md +2 -11
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +4 -1
- package/templates/skills/genex-threejs-embed-auth/references/shells.md +104 -0
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +4 -0
- package/templates/skills/genex-tool-llm/SKILL.md +11 -9
- package/templates/skills/genex-tool-publish/SKILL.md +7 -0
- package/templates/skills/genex-updates/SKILL.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "1.38.
|
|
3
|
+
"version": "1.38.1-dev.1097",
|
|
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": {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: genex-authoritative-multiplayer
|
|
3
|
+
description: Server-authoritative multiplayer for Genex games — the game's own room module runs on Genex's servers, players only send input, and the server decides every move and hit (lag-compensated). Use for competitive play where cheating matters: ranked PvP, arenas, shooters (first-person, hitscan or dodgeable projectiles), car football, "so nobody can cheat". Casual co-op and hangouts use genex-threejs-multiplayer instead. Load before writing the room module or any networking code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Genex Server-Authoritative Multiplayer
|
|
7
|
+
|
|
8
|
+
In a server-authoritative game the **server runs the game's rules**. The game ships a **room
|
|
9
|
+
module** — one script, `server/room.ts` or `server/room.js` — that Genex runs in a sandbox at a
|
|
10
|
+
fixed tick. Players send typed input (`moveX`, `moveY`, `yaw`, `buttons`, …); the server moves
|
|
11
|
+
every body, judges every shot against where the shooter saw its target, and writes the state every
|
|
12
|
+
client renders. A modified client can lie only about its own stick and buttons.
|
|
13
|
+
|
|
14
|
+
The client half is `connectAuthority` from `@genex-ai/multiplayer/authority`: it predicts the
|
|
15
|
+
player's own body with the same movement preset the server runs (so moving feels instant), draws
|
|
16
|
+
everything else 100 ms in the past, and reconciles when the server's truth arrives.
|
|
17
|
+
|
|
18
|
+
## Casual or server-authoritative
|
|
19
|
+
|
|
20
|
+
| | Casual — `$genex-threejs-multiplayer` | Server-authoritative — this skill |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| Who runs the rules | Each player's browser; Genex's relay passes messages | Genex's servers run the game's room module |
|
|
23
|
+
| Cheating | A modified client can say where it is and what it hit | The server decides every move and every hit |
|
|
24
|
+
| Fits | Co-op, hangouts, party, turn-based, casual races | Competitive shooters, arenas, ranked play — anything with a winner worth faking |
|
|
25
|
+
| On Genex's servers | Free | Needs Genex Pro for the game's owner |
|
|
26
|
+
| Building and testing | Free | Free, on this machine, with no account |
|
|
27
|
+
|
|
28
|
+
"Competitive", "ranked", "PvP", "esports", "so nobody can cheat" or "server-authoritative" mean
|
|
29
|
+
this skill; everything else is casual. When the person has not said which, ask in one line and say
|
|
30
|
+
what each costs. Never mix the two in one game: an authority game has no relay `Session`, no host
|
|
31
|
+
and no shared objects — the server owns all state.
|
|
32
|
+
|
|
33
|
+
## What you write
|
|
34
|
+
|
|
35
|
+
1. **The room module**, `server/room.ts` (or `.js`): `export default` a room with `contract: 1`,
|
|
36
|
+
`config`, `init`, `onJoin`, `onLeave`, `onInput` and `step`. It is server code: keep it and
|
|
37
|
+
everything only it uses in `server/`. Shared pure code (a movement step the client also runs)
|
|
38
|
+
may live in `src/` and be imported by both. The full API and the sandbox's rules are in
|
|
39
|
+
[references/module-api.md](references/module-api.md); four complete games to start from (an
|
|
40
|
+
arena with instant shots, one with bolts players can dodge, a car arena with a ball, and the car
|
|
41
|
+
arena on server physics with crates) are in
|
|
42
|
+
[references/room-template.md](references/room-template.md).
|
|
43
|
+
2. **The manifest block**, in package.json:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{ "genex": { "authority": { "module": "server/room.ts", "tickHz": 30, "maxPlayers": 8, "preset": "topdown" } } }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A game without a package.json (one made from Studio's template) puts the same object in
|
|
50
|
+
`genex.json` as `{ "authority": { … } }`. `module` is a plain relative path to a `.js` or `.ts`
|
|
51
|
+
file inside the game folder. The rest is optional: `maxPlayers` (1 to 64) is the most a room
|
|
52
|
+
seats (the module may ask for fewer), `tickHz` (20, 30, 60 or 120) is the rate for a module whose
|
|
53
|
+
`config` names none (120 only for a module that asks for physics), and `preset` names its movement preset. Left out, each comes from the
|
|
54
|
+
module's own `config`. A malformed block is not ignored — `genex preview` refuses it and says why.
|
|
55
|
+
3. **The client**: `connectAuthority` with the identity from `$genex-threejs-embed-auth`, input
|
|
56
|
+
written to `session.input`, `session.frame(now)` once per render frame, and the scene drawn
|
|
57
|
+
from `session.entities()`.
|
|
58
|
+
4. **The packages**: `npm i @genex-ai/multiplayer@latest @genex-ai/embed-sdk@latest` and
|
|
59
|
+
`npm i -D @genex-ai/authority-dev`. The game needs a `@genex-ai/multiplayer` that exports
|
|
60
|
+
`@genex-ai/multiplayer/authority` and an embed SDK at 0.35.0 or newer (it has
|
|
61
|
+
`getAuthorityUrls`). If the import does not resolve after installing `@latest`, the release with
|
|
62
|
+
server-authoritative rooms is not out yet: tell the person plainly and stop, rather than writing a
|
|
63
|
+
server of your own. Projectiles need `@genex-ai/multiplayer` 0.17.4 or newer.
|
|
64
|
+
|
|
65
|
+
## Rules that decide whether it plays well
|
|
66
|
+
|
|
67
|
+
1. **The server decides; the client asks.** Health, score, pickups, kills, respawns and round
|
|
68
|
+
state change only in the room module, and the client draws them from `session.entities()` and
|
|
69
|
+
the module's events. Show a muzzle flash or play a sound at once if you like, but never draw a
|
|
70
|
+
hit, a kill or a score the server has not sent.
|
|
71
|
+
2. **Never move your own body yourself.** Write the stick and aim into `session.input`; the session
|
|
72
|
+
predicts your body with the room's preset and corrects it. A game whose movement is not the
|
|
73
|
+
preset passes its own step as `predict: { step }` — the exact step the module runs
|
|
74
|
+
([references/presets.md](references/presets.md)).
|
|
75
|
+
3. **The module must be deterministic.** The same seed and inputs must give the same room: no
|
|
76
|
+
clock, no timers, and randomness only from `ctx.random()` (`Math.random` is the same seeded
|
|
77
|
+
generator inside the sandbox). `validate` and `replay` check this, and a publish refuses a
|
|
78
|
+
module that fails.
|
|
79
|
+
4. **Bump `epoch` to teleport.** A respawn or a teleport that does not bump the entity's `epoch`
|
|
80
|
+
slides across the map on every other screen.
|
|
81
|
+
5. **Keep per-tick work bounded.** Each tick has a CPU and memory budget; a runaway loop ends the
|
|
82
|
+
room for everyone in it. Loops run over entities or players, never "until something happens".
|
|
83
|
+
6. **The server's plane is x/y, and +z is up.** `yaw` turns from +x toward +y. For `firstperson`
|
|
84
|
+
and `vehicle`, draw at three.js `(x, z, -y)` with `rotation.y = yaw`: their input's "right" is
|
|
85
|
+
the server's, and a mirrored drawing steers the car the wrong way. The topdown templates draw the
|
|
86
|
+
server's `y` as three's `z`, which works there because they read input in the same drawn axes.
|
|
87
|
+
|
|
88
|
+
## Movement: pick a preset
|
|
89
|
+
|
|
90
|
+
| The game | `config.preset` | Notes |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| Seen from above (twin-stick, top-down shooter) | `topdown` | 30 Hz is enough |
|
|
93
|
+
| On foot through a level (a first-person or third-person shooter) | `firstperson` | Source-engine-style movement through a world of boxes and ramps; `tickHz: 60` |
|
|
94
|
+
| Cars (car football, a derby, a track of ramps) | `vehicle` | Rocket League's handling, kept level, through the same kind of world; `tickHz: 60` |
|
|
95
|
+
| Anything else | none: your own step | Shared by the module and the client, `predict: { step }` |
|
|
96
|
+
|
|
97
|
+
`firstperson` and `vehicle` move through a fixed collision world the module exports
|
|
98
|
+
(`defineRoom({ world })`) and the client hands its session (`connectAuthority({ …, world })`),
|
|
99
|
+
from one file both import: the session predicts only while the two hash the same. Their inputs,
|
|
100
|
+
parameters, units and CPU budget are in [references/presets.md](references/presets.md); read it
|
|
101
|
+
before writing either side. A ball the cars knock about is one projectile the module re-launches
|
|
102
|
+
(template 3, and "Running a ball" in presets.md).
|
|
103
|
+
|
|
104
|
+
## Feel settings: contacts, inputs, lifts, tick rate, regions, fast channel
|
|
105
|
+
|
|
106
|
+
Seats that touch (standing on heads, shoving, passing through), starved inputs, moving platforms, 120 Hz,
|
|
107
|
+
regions and the UDP fast channel are **settings in `config`**, picked by genre (`shooter`, `platformer`,
|
|
108
|
+
`party`, `physics-sandbox`, `vehicle-arena`), never code that names the game. Read
|
|
109
|
+
[references/settings.md](references/settings.md) for a worked config per genre before writing any of them.
|
|
110
|
+
|
|
111
|
+
## Physics: crates, balls, doors
|
|
112
|
+
|
|
113
|
+
When things must collide with each other (a stack that topples, a ball between cars and walls, a
|
|
114
|
+
door a player shoves), the module asks for server physics (`config.physics`) and spawns bodies with
|
|
115
|
+
`ctx.physics`; clients only draw them. It is new: check physics.md for when the servers run it. Read
|
|
116
|
+
[references/physics.md](references/physics.md) first; template 4 is the car arena with a physics ball
|
|
117
|
+
and crate stacks.
|
|
118
|
+
|
|
119
|
+
## Shots: hitscan or projectile
|
|
120
|
+
|
|
121
|
+
- **Instant** (a rifle, a laser): `ctx.rewind.hitscan` in `onInput`, lag-compensated with
|
|
122
|
+
`config.lagComp`. Template 1.
|
|
123
|
+
- **Something players see travel and can dodge** (a bolt, a rocket, a grenade, an arrow, a
|
|
124
|
+
dodgeball): a projectile. The module launches it once with `ctx.projectiles.launch` and judges it
|
|
125
|
+
with `ctx.projectiles.sweep`; the shooter's client calls `session.predictProjectile` while staging
|
|
126
|
+
the input that fires, and every client draws it from `session.entities()`. Template 2. Read
|
|
127
|
+
[references/projectiles.md](references/projectiles.md) before writing either side.
|
|
128
|
+
|
|
129
|
+
Never make a travelling shot a plain entity the module moves in `step`: it costs an update every
|
|
130
|
+
tick and every client draws it 100 ms late. Every client in a room whose module launches
|
|
131
|
+
projectiles must support them; one that does not draws each projectile frozen where it was
|
|
132
|
+
launched.
|
|
133
|
+
|
|
134
|
+
## The client in one screen
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { connectAuthority } from "@genex-ai/multiplayer/authority";
|
|
138
|
+
import { getAuthorityUrls, getColyseusAuth, waitForPlayer } from "@genex-ai/embed-sdk";
|
|
139
|
+
import { GENEX } from "./genex.config";
|
|
140
|
+
|
|
141
|
+
await waitForPlayer();
|
|
142
|
+
const session = await connectAuthority({
|
|
143
|
+
urls: getAuthorityUrls(),
|
|
144
|
+
room: GENEX.slug,
|
|
145
|
+
auth: () => getColyseusAuth(),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
function loop(now: number) {
|
|
149
|
+
session.input.moveX = stick.x; // [-1, 1] on the server's x axis
|
|
150
|
+
session.input.moveY = stick.y; // [-1, 1] on the server's y axis
|
|
151
|
+
session.input.yaw = aimAngle; // radians, 0 along +x
|
|
152
|
+
session.input.buttons = firing ? FIRE : 0;
|
|
153
|
+
session.frame(now); // sends the due inputs, predicts you, interpolates everyone else
|
|
154
|
+
for (const [id, e] of session.entities()) drawBody(id, e.x, e.y, e.yaw, e.epoch);
|
|
155
|
+
requestAnimationFrame(loop);
|
|
156
|
+
}
|
|
157
|
+
requestAnimationFrame(loop);
|
|
158
|
+
session.on("shot", (data) => showTracer(data)); // an event the room module emitted
|
|
159
|
+
session.on("disconnect", (code) => showLeft(code));
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A refused join rejects with a `code`: 402 (the owner's plan does not include Genex servers — final),
|
|
163
|
+
424 (no room module is published for this build yet), 426 (the SDK is too old), 429 (the owner's
|
|
164
|
+
server time or room limit is used up — try later) or 503 (no server free — the session already retried). Say each
|
|
165
|
+
in the player's words; never loop on 402 or 424. The whole client, with three.js, is in
|
|
166
|
+
[references/room-template.md](references/room-template.md).
|
|
167
|
+
|
|
168
|
+
## Shipping it
|
|
169
|
+
|
|
170
|
+
`npx genex preview` and `npx genex publish` ship the room module with the build, and refuse to
|
|
171
|
+
ship one that is not ready:
|
|
172
|
+
|
|
173
|
+
- **Before anything is built**, the module is bundled and validated with the game's own
|
|
174
|
+
`@genex-ai/authority-dev` (the same check as `npx genex-authority-dev validate`). Any failure stops
|
|
175
|
+
the deploy and prints its typed reason; the full report is kept at `.genex/authority/report.json`.
|
|
176
|
+
- **Before any game file uploads**, the module and its report go to a private store that the game's
|
|
177
|
+
address never serves, and `server/` is left out of the public build.
|
|
178
|
+
- **On Genex's servers the owner needs Genex Pro.** A free account is refused with
|
|
179
|
+
`authority_requires_pro` and nothing is uploaded; it can still build and test everything locally.
|
|
180
|
+
Say so before the person publishes.
|
|
181
|
+
- A preview link plays in rooms apart from the live game, as casual multiplayer's does, and
|
|
182
|
+
`npx genex promote` / `npx genex rollback` move the module together with the build — a rollback can
|
|
183
|
+
restore the previous module alone when only `server/` changed.
|
|
184
|
+
|
|
185
|
+
## Test it on this machine — CLI lane
|
|
186
|
+
|
|
187
|
+
Everything above holds in every lane. This section is how the server runs while you build: when the
|
|
188
|
+
app you run in loads this skill under its own preface, with its own local server tool, that preface
|
|
189
|
+
replaces this section wherever they differ.
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
npx genex-authority-dev validate --module server/room.ts --root .
|
|
193
|
+
npx genex-authority-dev start --module server/room.ts --root . --bots 3 --latency 75 --report
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`start` prints `LISTEN ws://127.0.0.1:<port>`. Open the dev server with that address —
|
|
197
|
+
`http://localhost:5173/?genex_local_test=1&genex_authority_url=ws://127.0.0.1:<port>` — and
|
|
198
|
+
`getAuthorityUrls()` returns it, so the same client code joins the local room as a local player
|
|
199
|
+
next to the bots. `--latency 75` is 75 ms each way, the ping real players feel; test there, not at
|
|
200
|
+
zero. `--root .` lets the module import shared files outside `server/`. What each command checks,
|
|
201
|
+
the REPORT line and every failure reason are in [references/testing.md](references/testing.md).
|
|
202
|
+
|
|
203
|
+
Before calling it done: `validate` passes, `replay` says the hashes are equal, two people (a normal
|
|
204
|
+
and a private window both count) have moved and fired at 75 ms, and nobody slides after a respawn.
|
|
205
|
+
With projectiles, also the checks at the end of
|
|
206
|
+
[references/projectiles.md](references/projectiles.md). With `firstperson` or `vehicle`,
|
|
207
|
+
`session.prediction.predicting` is true while you play (never `world_mismatch`), and a full room
|
|
208
|
+
stays under budget in the REPORT line. Say plainly what was not exercised.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Room module API
|
|
2
|
+
|
|
3
|
+
The room module is one ES module whose default export is a room. Its types are published at
|
|
4
|
+
`@genex-ai/multiplayer/authority/module`: `GenexRoomModule`, `RoomContext`, `InitContext`,
|
|
5
|
+
`EntityApi`, `EntityData`, `EntityInit`, `RewindApi`, `ProjectileApi`, `ProjectileInit`,
|
|
6
|
+
`ProjectileFlight`, `ProjectileHit`, `InputData`, `PlayerInfo`, `ModuleConfig`,
|
|
7
|
+
`ResolvedModuleConfig`, `PresetSpec`, `LeaveReason`, `Vec3`, `Json`, `MODULE_CONTRACT` and the
|
|
8
|
+
identity helper `defineRoom`.
|
|
9
|
+
|
|
10
|
+
A TypeScript module imports the types with `import type` (they are erased when it is bundled). A
|
|
11
|
+
module imports **nothing at runtime from a package**: the bundle runs in a sandbox and every byte it
|
|
12
|
+
needs must be inside the game folder. A plain-JavaScript module therefore writes its own one-line
|
|
13
|
+
`defineRoom`, as [the template](room-template.md) does. Relative imports of your own files are
|
|
14
|
+
fine — a movement step in `src/` that the client also runs is the usual one.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import type { GenexRoomModule, InputData, PlayerInfo, RoomContext } from "@genex-ai/multiplayer/authority/module";
|
|
18
|
+
|
|
19
|
+
interface State {
|
|
20
|
+
score: Record<string, number>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const room: GenexRoomModule<State> = {
|
|
24
|
+
contract: 1,
|
|
25
|
+
config: { tickHz: 30, maxPlayers: 8, preset: { name: "topdown" }, lagComp: { buttons: 1 } },
|
|
26
|
+
init: () => ({ score: {} }),
|
|
27
|
+
onJoin(ctx, player) { ctx.entities.spawn({ kind: "player", owner: player.id, hp: 3 }); },
|
|
28
|
+
onLeave(ctx, player) { const me = ctx.entities.ownedBy(player.id); if (me) ctx.entities.remove(me.id); },
|
|
29
|
+
onInput(ctx, playerId, input) { /* one input, after the preset moved the player's body */ },
|
|
30
|
+
step(ctx) { /* once per tick, after the tick's inputs */ },
|
|
31
|
+
};
|
|
32
|
+
export default room;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## The room
|
|
36
|
+
|
|
37
|
+
| Field | What it is |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `contract` | Always `1`. A bundle whose default export has another contract does not load. |
|
|
40
|
+
| `config` | Optional, and the one place to set these. `tickHz` (20, 30, 60 or 120), 120 only with `physics`; `maxPlayers` (a room never seats more than the game's manifest allows), `preset` (`{ name, params? }` with `name` one of `topdown`, `firstperson` or `vehicle`, see [presets.md](presets.md)) and `lagComp: { buttons }` — the bitmask of input buttons that ask for lag compensation. What the room runs with is `ctx.config` in `init`. `physics` asks for server physics (crates, balls, doors): see [physics.md](physics.md), which also says when Genex's servers run it. |
|
|
41
|
+
| `world` | The `firstperson` and `vehicle` presets' collision world, `{ boxes, ramps }`, fixed for the room's life. Validated when the module loads; its hash becomes the preset's `params.world`, and the client hands its session the same object (`session.setWorld`). Left out, the world is empty: no floor. See [presets.md](presets.md). |
|
|
42
|
+
| `init(ctx)` | Runs once. Returns the module's own state, `ctx.state` from then on. Clients never see it. |
|
|
43
|
+
| `onJoin(ctx, player)` | A player took a seat. Spawn their body here. |
|
|
44
|
+
| `onLeave(ctx, player, reason)` | A seat ended. Remove what they owned. `reason` is a `LeaveReason`: `left`, `dropped` (the reconnect grace ran out), `superseded` (a newer session of the same player took the seat), `revoked` or `closing`. |
|
|
45
|
+
| `onInput(ctx, playerId, input)` | One call per input, in each player's order, before `step`. |
|
|
46
|
+
| `step(ctx)` | Once per tick, after the tick's inputs. |
|
|
47
|
+
|
|
48
|
+
`player` is a `PlayerInfo`: `id` is the **seat** (an entity's `owner`, and what `emit(…, to)` takes);
|
|
49
|
+
`uid` is the player's stable id for this game across visits — key saves and scores by it; `name`;
|
|
50
|
+
`team` (`''` for none). A hook that throws costs only itself — the tick's other hooks still run —
|
|
51
|
+
but it is a fault (below).
|
|
52
|
+
|
|
53
|
+
`input` is an `InputData`, already cleaned by the server: `moveX`, `moveY`, `moveZ` in [-1, 1],
|
|
54
|
+
`yaw` and `pitch` in [-π, π), `buttons` an integer bitmask below 2^24. With a `preset`, the server
|
|
55
|
+
has already moved the player's first owned entity by this input when `onInput` runs; `onInput` is
|
|
56
|
+
where buttons become actions.
|
|
57
|
+
|
|
58
|
+
## The context
|
|
59
|
+
|
|
60
|
+
| Member | What it is |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `ctx.state` | What `init` returned. |
|
|
63
|
+
| `ctx.tick` | The fixed-step tick, from 0. |
|
|
64
|
+
| `ctx.dt` | Seconds per tick (1 / tickHz). |
|
|
65
|
+
| `ctx.random()` | The room's seeded generator: the only randomness there is. `Math.random` is the same generator. |
|
|
66
|
+
| `ctx.entities` | The entities: the only state clients see. |
|
|
67
|
+
| `ctx.rewind` | Lag-compensated hit tests. |
|
|
68
|
+
| `ctx.projectiles` | Shots on a ballistic flight that every client draws from one launch (below, and [projectiles.md](projectiles.md)). |
|
|
69
|
+
| `ctx.emit(type, data?, to?)` | A game event to every client, or only to the seat `to`. Clients receive it with `session.on(type, handler)`. |
|
|
70
|
+
| `ctx.log(...values)` | A server log line, rate limited. |
|
|
71
|
+
|
|
72
|
+
`init` gets `ctx.config`, `ctx.seed`, `ctx.random()`, `ctx.entities`, `ctx.projectiles` and
|
|
73
|
+
`ctx.log` (no tick, no rewind, no emit yet). A projectile launch in `init` throws, and the module
|
|
74
|
+
does not load: launch from a hook that runs in a tick.
|
|
75
|
+
|
|
76
|
+
## Entities
|
|
77
|
+
|
|
78
|
+
Each entity is an `EntityData`: `id`, `kind`, `owner` (a seat, or `''`), `x` `y` `z`, `vx` `vy`
|
|
79
|
+
`vz`, `yaw`, `hp`, `flags` (an integer bitmask below 2^24), `epoch` and `data` (any JSON), plus
|
|
80
|
+
three projectile fields that only `ctx.projectiles.launch` writes and `update` ignores: `t0` (the
|
|
81
|
+
launch tick; 0 = not a projectile), `g` (its gravity) and `src` (the seat that fired it, `''` for
|
|
82
|
+
none). Numbers are float32 on the wire. Only fields that changed this tick go out.
|
|
83
|
+
|
|
84
|
+
The movement presets add their own fields, which the preset writes from each input and the module
|
|
85
|
+
may write too (a respawn, a refuel):
|
|
86
|
+
|
|
87
|
+
| Field | Preset | What it is |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `pitch` | `firstperson` | The aim's pitch, radians, positive up. |
|
|
90
|
+
| `duck` | `firstperson` | 0 standing to 1 fully ducked: derive the eye height from it. |
|
|
91
|
+
| `mstate` | `firstperson`, `vehicle` | Movement bits: 1 on the ground, 2 ducking, 4 jump held. Write 0 on a respawn. |
|
|
92
|
+
| `cap` | `firstperson`, `vehicle` | A speed multiplier from 0 to 4, default 1, that only the module writes (a weapon's weight, a wound, a slow zone); a car's applies to both top speeds. |
|
|
93
|
+
| `spin` | `vehicle` | The car's turn rate, rad/s, positive toward +yaw. Write 0 on a respawn. |
|
|
94
|
+
| `fuel` | `vehicle` | The boost tank, 0 to 1, default 0. The preset only burns it: write it to refuel. |
|
|
95
|
+
|
|
96
|
+
| Call | Does |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `ctx.entities.spawn({ kind, ...fields })` | Adds one and returns its id (the `id` you pass when it is free, else a fresh one). |
|
|
99
|
+
| `ctx.entities.get(id)` | The entity, or `undefined`. Assigning to its fields updates it. |
|
|
100
|
+
| `ctx.entities.update(id, patch)` | Changes several fields at once. |
|
|
101
|
+
| `ctx.entities.remove(id)` | Removes it. |
|
|
102
|
+
| `ctx.entities.ownedBy(seat)` | The first entity that seat owns: a player's body. |
|
|
103
|
+
| `ctx.entities.all()` | Every entity. |
|
|
104
|
+
|
|
105
|
+
**Bump `epoch` when an entity jumps** (a respawn, a teleport): clients snap to it instead of sliding
|
|
106
|
+
across the map. Clients predict the first entity a player owns that is not a projectile, so give
|
|
107
|
+
each player one body, spawn everything else (pickups, props) unowned, and launch shots with
|
|
108
|
+
`ctx.projectiles.launch`, never as plain entities moved in `step`.
|
|
109
|
+
|
|
110
|
+
## Lag compensation
|
|
111
|
+
|
|
112
|
+
`ctx.rewind.hitscan(origin, dir, { range, radius, kinds })` returns the nearest `{ id, dist }` along
|
|
113
|
+
the ray, or `null`; `ctx.rewind.overlapAt(center, radius)` returns every id within the radius. While
|
|
114
|
+
`onInput` handles an input whose `buttons` intersect `config.lagComp.buttons`, both test the
|
|
115
|
+
positions **that player saw** when they pressed it (at most 500 ms back); otherwise they test the
|
|
116
|
+
current positions. During that input the shooter's own entities are never its target. A shot judged
|
|
117
|
+
this way lands when it looked right on the shooter's screen.
|
|
118
|
+
|
|
119
|
+
## Projectiles
|
|
120
|
+
|
|
121
|
+
A projectile's launch is written once; every client computes its flight, so it costs nothing per
|
|
122
|
+
tick on the wire and is drawn at the server's present. The whole guide, the client side included, is
|
|
123
|
+
[projectiles.md](projectiles.md).
|
|
124
|
+
|
|
125
|
+
| Call | Does |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `ctx.projectiles.launch({ kind, x, y, z, vx, vy, vz, g?, hp?, flags?, data?, id? })` | Launches at the tick being stepped, `owner` `''`, and returns the id. In `onInput` the id is `<seat>.<seq>.<n>` (the one the shooter's client predicted) and `src` the seat; passing `id` there throws. Elsewhere `id` is required and `src` is `''`. An existing id is re-launched, replaced whole: a bounce. `g` is downward along −z, 0 to 1000 units/s². Throws a `RangeError`, writing nothing, past the launch budget. |
|
|
128
|
+
| `ctx.projectiles.at(id, tick?)` | `{ x, y, z, vx, vy, vz }` at that tick (default: this one), or `undefined` for an id that is not a projectile. |
|
|
129
|
+
| `ctx.projectiles.sweep(id, { kinds, radius })` | `{ id, dist }[]`, nearest first: the entities of those kinds whose sphere of `radius` the path crossed since the previous tick. The shooter's own body can be among them (a launch point inside it): skip it. Costs the number of entities of those kinds. |
|
|
130
|
+
|
|
131
|
+
Projectiles take no part in lag compensation: `ctx.rewind` skips them, and a sweep tests bodies
|
|
132
|
+
where they are now. The server never removes one: the module does, on a hit, at the walls or after a
|
|
133
|
+
maximum age. Change a flight by re-launching; `update` on its `x`…`vz` keeps the old launch tick, so
|
|
134
|
+
it jumps. The launches waiting to go out may hold at most 224 entities and 28 KiB of output, about a
|
|
135
|
+
hundred new projectiles in one tick.
|
|
136
|
+
|
|
137
|
+
## What the sandbox has, and what it refuses
|
|
138
|
+
|
|
139
|
+
The module runs with no clock, no timers, no network, no files and no shared memory. Absent:
|
|
140
|
+
`Date`, `SharedArrayBuffer`, `Atomics`, `WebAssembly`, `console`, `WeakRef`, `FinalizationRegistry`,
|
|
141
|
+
`Intl`, `setTimeout`, `setInterval`, `setImmediate`, `clearTimeout`, `clearInterval`,
|
|
142
|
+
`queueMicrotask`, `fetch`, `require`, `process`, `performance`. Reaching the global object
|
|
143
|
+
(`globalThis`) instead of `ctx` fails validation too. Everything the module needs arrives through
|
|
144
|
+
`ctx`.
|
|
145
|
+
|
|
146
|
+
Its output is checked every tick, and each of these is a **fault**:
|
|
147
|
+
|
|
148
|
+
- a hook that throws;
|
|
149
|
+
- a number that is not finite, or past 1e9 in magnitude;
|
|
150
|
+
- at most 256 entity updates, and at most 256 removals, in one tick — more is a fault;
|
|
151
|
+
- at most 64 events in one tick;
|
|
152
|
+
- at most 1024 entities in the room at once;
|
|
153
|
+
- one event at most 16 KiB as JSON;
|
|
154
|
+
- entity `data` at most 256 characters as JSON;
|
|
155
|
+
- an id that is empty, longer than 64 characters, or `__proto__`, `constructor` or `prototype`.
|
|
156
|
+
|
|
157
|
+
The third fault ends the room (close code 4501, and every player sees `disconnect`). So does a tick
|
|
158
|
+
that runs past its deadline or a module whose CPU or memory use stays over budget. Keep per-tick
|
|
159
|
+
work small, bounded and the same every run: `validate` holds it to a local CPU budget so a module
|
|
160
|
+
that passes here fits Genex's servers.
|
|
161
|
+
|
|
162
|
+
## Determinism
|
|
163
|
+
|
|
164
|
+
The same seed and the same inputs must give the same room. That is what makes a validation report
|
|
165
|
+
mean something and what `replay` checks. Rules that keep it true:
|
|
166
|
+
|
|
167
|
+
- randomness only from `ctx.random()`;
|
|
168
|
+
- iterate in a fixed order (`Object.keys` of objects you built in a fixed order, arrays, the entity
|
|
169
|
+
list) and never depend on timing;
|
|
170
|
+
- round what you store with `Math.fround` when it comes from `Math.sin`, `Math.cos`, `Math.atan2` or
|
|
171
|
+
`Math.exp`: their last bit may differ between Genex's servers and the local one, and float32
|
|
172
|
+
hides that difference, as the wire does.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Server physics: crates, balls, doors
|
|
2
|
+
|
|
3
|
+
A room module can ask Genex's servers to simulate rigid bodies — crates that topple, a ball that
|
|
4
|
+
bounces off cars, doors on hinges — with the Genex Physics core. The server alone steps them. Every
|
|
5
|
+
client only **draws** them: at the server's present, smoothed when a correction arrives. A client
|
|
6
|
+
never moves a body itself.
|
|
7
|
+
|
|
8
|
+
**New, and not on Genex's servers until the release that carries the physics core is deployed.**
|
|
9
|
+
`genex-authority-dev validate` runs a physics module on that same core; a validator without it fails
|
|
10
|
+
`physics_unavailable` (update `@genex-ai/authority-dev`). Until the servers run it, a game that must
|
|
11
|
+
ship today keeps to presets and projectiles ([presets.md](presets.md),
|
|
12
|
+
[projectiles.md](projectiles.md)).
|
|
13
|
+
|
|
14
|
+
Reach for physics when things must collide with each other: a stack, a pile, a ball between cars
|
|
15
|
+
and walls, a door a player shoves. A player's own movement stays a preset, so it is predicted and
|
|
16
|
+
feels instant; a lone flying shot stays a projectile.
|
|
17
|
+
|
|
18
|
+
## Asking for it
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
config: {
|
|
22
|
+
tickHz: 60,
|
|
23
|
+
preset: { name: "vehicle", params: { radius: 50, height: 36 } },
|
|
24
|
+
physics: { subscribe: ["ball"], player: { events: true } },
|
|
25
|
+
},
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`physics: true` takes every default. The keys:
|
|
29
|
+
|
|
30
|
+
| Key | Default | What it is |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `lengthUnit` | 100 with `vehicle`, 39.37 with `firstperson`, else 1 | Your units per metre. |
|
|
33
|
+
| `gravity` | The preset's `gravity`, else 9.81 × `lengthUnit` | Units/s² along −z. |
|
|
34
|
+
| `iterations` | 4 | Solver iterations, 1 to 16. |
|
|
35
|
+
| `limits` | The defaults below | Lower any limit; higher ones are clamped. |
|
|
36
|
+
| `subscribe` | `[]` | Up to 8 physics kinds whose body states `ctx.physics.body(key)` returns. |
|
|
37
|
+
| `player` | `{ events: false }` | `events: true` reports the players' hulls touching bodies. A `topdown` room also gives `radius` and `height` here, or its players have no hulls. |
|
|
38
|
+
|
|
39
|
+
Any other key is a bad config. A room's limits: 512 bodies, 64 joints, 16 bodies with continuous
|
|
40
|
+
collision (`ccd`), 64 queries a tick, 64 events a tick, at most 256 commands a tick.
|
|
41
|
+
|
|
42
|
+
## A body is an entity
|
|
43
|
+
|
|
44
|
+
Every body belongs to one entity with the same id. Spawn the entity, then its body:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
init(ctx) {
|
|
48
|
+
ctx.entities.spawn({ id: "ball", kind: "ball", z: 92 });
|
|
49
|
+
ctx.physics.spawn({
|
|
50
|
+
key: "ball", kind: "ball", type: "dynamic", ccd: true,
|
|
51
|
+
pose: { p: { x: 0, y: 0, z: 92 }, q: { x: 0, y: 0, z: 0, w: 1 } },
|
|
52
|
+
colliders: [{ key: "ball", shape: { kind: "sphere", radius: 92 }, restitution: 0.65 }],
|
|
53
|
+
});
|
|
54
|
+
return {};
|
|
55
|
+
},
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **Ids**: 1 to 64 of `A-Z a-z 0-9 _ . : -`, never starting `static:`. Ids the room makes up
|
|
59
|
+
(`e1`, `e2`, …) qualify. A body's `kind` (`"ball"`, `"crate"`) is its own, 1 to 32 of the same.
|
|
60
|
+
- **The server writes a body entity's `x y z`, orientation and velocities.** Your module must never
|
|
61
|
+
write those fields on a body entity: the write throws. Every other field is yours: `kind`,
|
|
62
|
+
`owner`, `hp`, `data`, `epoch`.
|
|
63
|
+
- **A teleport** (a kickoff, a respawn): `ctx.physics.setPose(key, pose, { resetVelocity: true, wake: true })`
|
|
64
|
+
and bump the entity's `epoch` in the same tick, so every client snaps instead of sliding there.
|
|
65
|
+
- **Removing** the entity removes its body. `ctx.physics.remove(key)` removes only the body.
|
|
66
|
+
- A player's own entity never gets a body, and a projectile never does.
|
|
67
|
+
|
|
68
|
+
Vectors are `{ x, y, z }` and rotations `{ x, y, z, w }`. Shapes: `box` (`half`), `sphere`
|
|
69
|
+
(`radius`), `capsule` (`halfHeight`, `radius`, `axis`), `hull` (`points`), `trimesh` and
|
|
70
|
+
`heightfield` (fixed bodies only).
|
|
71
|
+
|
|
72
|
+
**Put a body's origin at its centre of mass.** Clients extrapolate a body's origin by its centre's
|
|
73
|
+
velocity, so a spinning body whose colliders are offset from its origin is drawn off until the next
|
|
74
|
+
update. Swing a door by posing its middle around the hinge, or join it to the world with a hinge
|
|
75
|
+
joint, rather than giving it an offset collider.
|
|
76
|
+
|
|
77
|
+
## Commands, queries, events
|
|
78
|
+
|
|
79
|
+
Commands take effect in this tick's step, and every hook may call them (`init` too):
|
|
80
|
+
|
|
81
|
+
| Call | Does |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `ctx.physics.spawn(desc)` | Adds a body to an existing entity. |
|
|
84
|
+
| `ctx.physics.remove(key)` | Removes the body; the entity stays. |
|
|
85
|
+
| `ctx.physics.impulse(key, j, at?)` | A push, in mass × units/s. |
|
|
86
|
+
| `ctx.physics.setVelocity(key, linvel?, angvel?)` | Sets a dynamic body's velocities. |
|
|
87
|
+
| `ctx.physics.setPose(key, pose, options?)` | Moves it (bump the epoch with it). |
|
|
88
|
+
| `ctx.physics.setNextKinematicPose(key, pose)` | Where a kinematic body (a lift, a door you drive) is at the end of this tick. |
|
|
89
|
+
| `ctx.physics.addJoint(desc)` / `ctx.physics.removeJoint(key)` | Hinges, sliders, ropes; `b: null` is the world. |
|
|
90
|
+
| `ctx.physics.setMotor(jointKey, motor)` | Drives a joint. |
|
|
91
|
+
| `ctx.physics.areaPush(center, radius, strength, falloff, groups?)` | An explosion. |
|
|
92
|
+
| `ctx.physics.wake(key)` / `ctx.physics.sleep(key)` | |
|
|
93
|
+
|
|
94
|
+
Each returns its command number, or `'invalid-key'` for a bad id. A command the core refuses
|
|
95
|
+
changes nothing and is listed next tick in `ctx.physics.refused` (`{ n, code }`). It is never a
|
|
96
|
+
fault.
|
|
97
|
+
|
|
98
|
+
Queries (`ctx.physics.ray`, `ctx.physics.shapeCast`, `ctx.physics.overlap`) return an index; the
|
|
99
|
+
answer is `ctx.physics.answers[index]` on the **next** tick. Queries throw in `init`.
|
|
100
|
+
|
|
101
|
+
What the last step did: `ctx.physics.events` (`contactBegin`, `contactEnd`, `triggerEnter`,
|
|
102
|
+
`triggerExit`, for colliders with `events: true`), `ctx.physics.escaped` (ids whose bodies left the
|
|
103
|
+
world; the server removed those entities), and `ctx.physics.body(key)` (`{ p, q, v, w, sleeping }`
|
|
104
|
+
for a subscribed kind).
|
|
105
|
+
|
|
106
|
+
## Players push bodies
|
|
107
|
+
|
|
108
|
+
With `firstperson` or `vehicle` (or `topdown` with `player.radius` and `player.height`), each
|
|
109
|
+
player's own entity carries a hull the server moves with it. Hulls push dynamic bodies; bodies never
|
|
110
|
+
block a player, who still moves only through the preset's world. With `player: { events: true }`,
|
|
111
|
+
`contactBegin` events name the player's entity id, so a module sees who touched the ball:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
for (const ev of ctx.physics.events) {
|
|
115
|
+
if (ev.kind !== "contactBegin" || (ev.a !== "ball" && ev.b !== "ball")) continue;
|
|
116
|
+
const car = ctx.entities.get(ev.a === "ball" ? ev.b : ev.a);
|
|
117
|
+
if (car && car.kind === "car") ctx.state.lastTouch = car.owner;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Drawing bodies on the client
|
|
122
|
+
|
|
123
|
+
Nothing to set up: `connectAuthority` joins physics rooms and draws bodies. In
|
|
124
|
+
`session.entities()`, a body has `body: true`, its orientation in `qx qy qz qw` (z up) and `sleep`.
|
|
125
|
+
Its `x y z` are already where the server has it now: extrapolated past the last update, never
|
|
126
|
+
100 ms behind like other players. A correction eases out over about 100 ms, and a jump over one
|
|
127
|
+
metre (or an `epoch` bump) snaps. With three.js, z up becomes y up for both:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
mesh.position.set(e.x, e.z, -e.y);
|
|
131
|
+
if (e.body) mesh.quaternion.set(e.qx, e.qz, -e.qy, e.qw);
|
|
132
|
+
else mesh.rotation.set(0, e.yaw, 0);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A body far from the player may update less often: each player gets the 100 nearest awake bodies
|
|
136
|
+
every update and the rest in turns. A body that has not updated for a while is held where it was.
|
|
137
|
+
|
|
138
|
+
## Costs and limits
|
|
139
|
+
|
|
140
|
+
The server measures physics time apart from your module's. A tick over 8 ms per 1/60 s of game
|
|
141
|
+
time, or an average over 1 ms per 1/60 s, is a strike; the third ends the room. At 120 Hz a tick
|
|
142
|
+
gets a 60 Hz tick's 8 ms and 1 ms average, and the room counts twice toward the server's capacity. Asleep bodies cost
|
|
143
|
+
almost nothing, so let piles settle: `canSleep` is on by default.
|
|
144
|
+
|
|
145
|
+
## Testing
|
|
146
|
+
|
|
147
|
+
`npx genex-authority-dev validate --module server/room.js` fuzzes a physics module like any other,
|
|
148
|
+
on the physics core. Play it at a real ping (`--latency 75`) and judge it there, not at zero: a
|
|
149
|
+
fast ball drawn from the server is a little behind the moment a car hits it, by about the one-way
|
|
150
|
+
latency, and one that bounces off a wall is drawn a little into it before it comes back.
|