@genex-ai/cli-demo 1.38.0 → 1.38.1-dev.1098
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
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# Movement presets and prediction
|
|
2
|
+
|
|
3
|
+
A **preset** is movement the server simulates and every client replays identically, so a player's
|
|
4
|
+
own body moves the instant they press a key and the server's correction, when it comes, is
|
|
5
|
+
invisible. Name one in the module's `config.preset` and `connectAuthority` predicts with it on its
|
|
6
|
+
own; there is nothing to wire on the client but the input (and, for two of them, the world).
|
|
7
|
+
|
|
8
|
+
Presets are append-only by name. There are three:
|
|
9
|
+
|
|
10
|
+
| The game | Preset | Ask for |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| Seen from above: a twin-stick arena, a top-down shooter, a board game with moving pieces | `topdown` | `tickHz: 30` |
|
|
13
|
+
| A character on foot through a level: a first-person or over-the-shoulder shooter, with jumping, ducking, stairs and ramps | `firstperson` | `tickHz: 60` |
|
|
14
|
+
| Cars: car football, a derby, an arena brawl, a track of flats and ramps | `vehicle` | `tickHz: 60` |
|
|
15
|
+
| Anything else: flying, swimming, climbing, a double jump, a boat, a hovercraft | your own step ([below](#movement-of-your-own)) | |
|
|
16
|
+
|
|
17
|
+
Pick a preset whenever one fits: each is ported to every engine and held to the same conformance
|
|
18
|
+
vectors, so prediction matches on every client. `firstperson` and `vehicle` need `@genex-ai/multiplayer`
|
|
19
|
+
0.17.4 or newer.
|
|
20
|
+
|
|
21
|
+
## `topdown`
|
|
22
|
+
|
|
23
|
+
Planar movement on the server's x/y plane, with an aim angle.
|
|
24
|
+
|
|
25
|
+
| Parameter | Range | Default |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `speed` | 0 to 1000 world units per second | 6 |
|
|
28
|
+
| `arena` | above 0, up to 1e6: bodies stay within [-arena, arena] on x and y | 20 |
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
config: { tickHz: 30, preset: { name: "topdown", params: { speed: 6, arena: 20 } } }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Each input moves the player's first owned entity: `moveX` and `moveY` are the stick (a diagonal
|
|
35
|
+
is scaled back to length 1), `yaw` is the aim in radians with 0 along +x, and the body is kept
|
|
36
|
+
inside the arena. The server applies it before `onInput` sees the input, so `onInput` reads the
|
|
37
|
+
moved body. An unknown or out-of-range parameter is refused when the module loads.
|
|
38
|
+
|
|
39
|
+
In three.js, draw the server's `x` as `x` and its `y` as `z`, and turn the body by `-yaw` around
|
|
40
|
+
three's y axis.
|
|
41
|
+
|
|
42
|
+
The reference implementation is exported from `@genex-ai/multiplayer/authority` as `stepTopdown`
|
|
43
|
+
(with `resolveTopdownParams`, `TOPDOWN_DEFAULTS` and `PRESET_MAX_DT_S`); the session already runs it
|
|
44
|
+
for you.
|
|
45
|
+
|
|
46
|
+
## `firstperson`
|
|
47
|
+
|
|
48
|
+
Source-engine-style movement: friction, ground and air acceleration (air-strafing
|
|
49
|
+
works), a jump, ducking and crouch-jumping, a free step up ledges, staying on the ground down slopes
|
|
50
|
+
and stairs, and a hull that slides along what it hits. The body is an upright box whose feet are at
|
|
51
|
+
`(x, y, z)`, moving through a fixed world of boxes and ramps ([the collision world](#the-collision-world-firstperson-and-vehicle)).
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
export default defineRoom({
|
|
55
|
+
contract: 1,
|
|
56
|
+
config: { tickHz: 60, maxPlayers: 10, preset: { name: "firstperson", params: {} }, lagComp: { buttons: FIRE } },
|
|
57
|
+
world: WORLD, // the same object the client hands its session
|
|
58
|
+
// init, onJoin, onLeave, onInput, step …
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Units.** The defaults are the Source engine's, in Source units of about an inch: a hull 32 wide
|
|
63
|
+
and 72 tall (54 ducked), a run of 250 units/s, gravity 800. The easy way to a game in metres is to
|
|
64
|
+
keep these on the server and scale the drawing (`0.0254` metres a unit). A game that sets its own
|
|
65
|
+
units sets every distance, speed and acceleration below, `skin` and `groundSnap` included.
|
|
66
|
+
|
|
67
|
+
| Parameter | Default | Range | What it does |
|
|
68
|
+
|---|---|---|---|
|
|
69
|
+
| `maxSpeed` | 250 | 0 to 10000 | Run speed, units/s, before the body's `cap` |
|
|
70
|
+
| `walkSpeed` | 0.52 | 0 to 1 | Speed multiplier while walk is held |
|
|
71
|
+
| `crouchSpeed` | 0.34 | 0 to 1 | Speed multiplier while ducking (wins over walk) |
|
|
72
|
+
| `accelerate` | 5.5 | 0 to 1000 | Ground acceleration |
|
|
73
|
+
| `airAccelerate` | 12 | 0 to 1000 | Air acceleration |
|
|
74
|
+
| `airMaxWishSpeed` | 30 | 0 to 10000 | Air wish speed cap: what makes air-strafing work |
|
|
75
|
+
| `friction` | 5.2 | 0 to 1000 | Ground friction |
|
|
76
|
+
| `stopSpeed` | 80 | 0 to 10000 | Below this speed friction acts as if at this speed |
|
|
77
|
+
| `gravity` | 800 | 0 to 100000 | Units/s² along −z |
|
|
78
|
+
| `jumpImpulse` | 301.993 | 0 to 100000 | The upward speed a jump sets |
|
|
79
|
+
| `maxVelocity` | 3500 | 0 to 100000 | Each velocity axis is held within ±this |
|
|
80
|
+
| `radius` | 16 | above 0, to 10000 | Half the side of the hull's square footprint |
|
|
81
|
+
| `standHeight` | 72 | above 0, to 10000 | Hull height standing |
|
|
82
|
+
| `crouchHeight` | 54 | above 0, to 10000 | Hull height fully ducked |
|
|
83
|
+
| `duckTime` | 0.4 | above 0, to 100 | Seconds to duck fully, and to stand back up |
|
|
84
|
+
| `stepHeight` | 18 | 0 to 10000 | Highest ledge walked up without a jump |
|
|
85
|
+
| `standable` | 0.7 | 0 to 1 | Least upward share of a ramp's normal that can be stood on (0.7 is about 45°) |
|
|
86
|
+
| `groundTrace` | 2.5 | 0 to 10000 | How far below the feet ground is found |
|
|
87
|
+
| `groundSnap` | 1 | 0 to 10000 | How far above the feet ground is found |
|
|
88
|
+
| `skin` | 0.03 | 0 to 100 | Clearance kept from a surface |
|
|
89
|
+
| `pitchLimit` | 1.5533430576324463 | 0 to π/2 | Largest look up or down, radians (89°) |
|
|
90
|
+
| `jumpBit`, `duckBit`, `walkBit` | 1, 2, 4 | integer below 2^24 | Which `buttons` bits jump, duck and walk; 0 turns one off |
|
|
91
|
+
| `substeps` | 2 | integer 1 to 8 | Sub-steps per input ([the CPU budget](#sub-steps-and-the-cpu-budget)) |
|
|
92
|
+
| `world` | the empty world's hash | | Written by the sandbox from the module's `world`: leave it out |
|
|
93
|
+
|
|
94
|
+
An unknown parameter, or one out of its range, is refused when the module loads.
|
|
95
|
+
|
|
96
|
+
**Input.** `moveX` and `moveY` are the direction the player wants to move **in the world's axes**,
|
|
97
|
+
not the camera's: turn the keys by the view's yaw before sending them (the step itself has no
|
|
98
|
+
trigonometry). `yaw` and `pitch` are the view's aim, stored on the body for drawing and for the
|
|
99
|
+
module's shots. The preset's bits are on `buttons`; give the game's own buttons (fire, reload,
|
|
100
|
+
switch weapon) other bits, 8 and up.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const forward = (keys.has("KeyW") ? 1 : 0) - (keys.has("KeyS") ? 1 : 0);
|
|
104
|
+
const right = (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0);
|
|
105
|
+
session.input.moveX = forward * Math.cos(viewYaw) + right * Math.sin(viewYaw);
|
|
106
|
+
session.input.moveY = forward * Math.sin(viewYaw) - right * Math.cos(viewYaw);
|
|
107
|
+
session.input.yaw = viewYaw; // +yaw turns left: the mouse moving right is viewYaw -= dx * sensitivity
|
|
108
|
+
session.input.pitch = viewPitch; // positive looks up; the step holds it within ±pitchLimit
|
|
109
|
+
session.input.buttons = (jump ? 1 : 0) | (duck ? 2 : 0) | (walk ? 4 : 0) | (firing ? FIRE : 0);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A jump fires once per press: holding the key does not hop again on landing.
|
|
113
|
+
|
|
114
|
+
**The body.** On top of `x y z vx vy vz yaw`, the preset writes `pitch` (the aim), `duck` (0 standing
|
|
115
|
+
to 1 fully ducked) and `mstate` (bits: 1 on the ground, 2 ducking, 4 jump held), and reads `cap`: a
|
|
116
|
+
speed multiplier from 0 to 4 (default 1) that the module writes, for a weapon's weight or a wound. It
|
|
117
|
+
limits how fast the body can go, never how hard it pushes, so a slowed player still overcomes
|
|
118
|
+
friction. A `cap` written in `onInput` applies from the next input. The eye height is the game's:
|
|
119
|
+
The Source engine's is `64 - 18 * duck` over the feet. A respawn writes the new spot with
|
|
120
|
+
`vx: 0, vy: 0, vz: 0, duck: 0, mstate: 0` and bumps `epoch`.
|
|
121
|
+
|
|
122
|
+
**What it does not do.** Players pass through each other: the preset collides only with the world.
|
|
123
|
+
The world is fixed for the room's life, so a door, a lift or a crate that moves is the module's own
|
|
124
|
+
rule, not a solid. And `ctx.rewind.hitscan` does not know the world either: test the shot against
|
|
125
|
+
the same boxes yourself (a ray against each box) and refuse a hit behind a wall. The shot leaves
|
|
126
|
+
from the eye along `(cos(pitch) * cos(yaw), cos(pitch) * sin(yaw), sin(pitch))`.
|
|
127
|
+
|
|
128
|
+
**Tick rate.** Ask for `tickHz: 60`. Air-strafing turns the wish direction once per input, so the
|
|
129
|
+
speed a strafe gains grows with the input rate: at 30 Hz a perfect strafer gains less than half of
|
|
130
|
+
what they gain at 60 (sub-steps do not change it). A jump pressed on an input whose last sub-step
|
|
131
|
+
lands is lost, as on a Source server's tick.
|
|
132
|
+
|
|
133
|
+
## `vehicle`
|
|
134
|
+
|
|
135
|
+
An arcade car on Rocket League's ground and air handling, kept level (it never pitches, rolls or
|
|
136
|
+
flips): throttle, braking and coasting; boost from a fuel tank; grip that turns the velocity with the
|
|
137
|
+
heading, and a drift button that lets the tail slide; a turn rate that grows with speed and mirrors
|
|
138
|
+
in reverse; a jump, and steering in the air. It drives the same hull through the same world as
|
|
139
|
+
`firstperson`.
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
export default defineRoom({
|
|
143
|
+
contract: 1,
|
|
144
|
+
config: { tickHz: 60, maxPlayers: 4, preset: { name: "vehicle", params: { radius: 50, height: 36 } } },
|
|
145
|
+
world: WORLD, // the same object the client hands its session
|
|
146
|
+
// init, onJoin, onLeave, onInput, step …
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Units.** About a centimetre, Rocket League's own scale: a car about 120 long, 1410 units/s on the
|
|
151
|
+
throttle and 2300 with boost, gravity 650. The hull does not turn with the heading: its square
|
|
152
|
+
footprint, 100 across by default, stands in for a car about 118 long and 84 wide. A game in other
|
|
153
|
+
units sets every distance, speed and acceleration, `skin` included.
|
|
154
|
+
|
|
155
|
+
| Parameter | Default | Range | What it does |
|
|
156
|
+
|---|---|---|---|
|
|
157
|
+
| `maxSpeed` | 1410 | 0 to 100000 | Top speed on the throttle, units/s, before `cap` |
|
|
158
|
+
| `accel` | 1000 | 0 to 100000 | Throttle acceleration, units/s² |
|
|
159
|
+
| `brake` | 3500 | 0 to 100000 | Deceleration while the throttle opposes the motion |
|
|
160
|
+
| `coast` | 525 | 0 to 100000 | Deceleration with no throttle, and back down to the throttle's top speed after a boost |
|
|
161
|
+
| `boostAccel` | 1000 | 0 to 100000 | Boost's push along the heading, on top of the throttle's |
|
|
162
|
+
| `boostMaxSpeed` | 2300 | 0 to 100000 | Top speed under boost, before `cap` |
|
|
163
|
+
| `boostBurn` | 0.3333333432674408 | 0 to 1000 | Fuel burned per second of boost: a full tank (1) lasts 3 s |
|
|
164
|
+
| `grip` | 6000 | 0 to 100000 | How fast sideways speed is lost on the ground, units/s² |
|
|
165
|
+
| `driftGrip` | 1500 | 0 to 100000 | The same, while drift is held |
|
|
166
|
+
| `turnRate` | 2.2 | 0 to 100 | Turn rate at full steer on the ground, rad/s, at or above `turnSpeed` |
|
|
167
|
+
| `turnSpeed` | 400 | above 0, to 100000 | The forward speed where the full turn rate is reached; below it the rate scales with speed |
|
|
168
|
+
| `spinAccel` | 30 | 0 to 10000 | How fast the turn rate follows the steering on the ground, rad/s² |
|
|
169
|
+
| `airTurnRate` | 5.5 | 0 to 100 | Turn rate at full steer in the air, rad/s |
|
|
170
|
+
| `airSpinAccel` | 9 | 0 to 10000 | How fast the turn rate follows the steering in the air, rad/s² |
|
|
171
|
+
| `gravity` | 650 | 0 to 100000 | Units/s² along −z |
|
|
172
|
+
| `jumpImpulse` | 450 | 0 to 100000 | The upward speed a jump sets |
|
|
173
|
+
| `maxVelocity` | 6000 | 0 to 100000 | Each velocity axis is held within ±this |
|
|
174
|
+
| `radius` | 50 | above 0, to 10000 | Half the side of the hull's square footprint |
|
|
175
|
+
| `height` | 36 | above 0, to 10000 | Hull height |
|
|
176
|
+
| `stepHeight` | 8 | 0 to 10000 | Highest kerb driven up without a jump, and the deepest drop a sub-step follows down |
|
|
177
|
+
| `standable` | 0.7 | 0 to 1 | Least upward share of a ramp's normal that can be driven on (0.7 is about 45°) |
|
|
178
|
+
| `groundTrace` | 4 | 0 to 10000 | How far below the hull ground is found |
|
|
179
|
+
| `groundSnap` | 2 | 0 to 10000 | How far above the hull's base ground is found |
|
|
180
|
+
| `skin` | 0.03 | 0 to 100 | Clearance kept from a surface |
|
|
181
|
+
| `jumpBit`, `boostBit`, `driftBit` | 1, 2, 4 | integer below 2^24 | Which `buttons` bits jump, boost and drift; 0 turns one off |
|
|
182
|
+
| `substeps` | 2 | integer 1 to 8 | Sub-steps per input ([the CPU budget](#sub-steps-and-the-cpu-budget)) |
|
|
183
|
+
| `world` | the empty world's hash | | Written by the sandbox from the module's `world`: leave it out |
|
|
184
|
+
|
|
185
|
+
**Input.** The heading is the car's own state, not input: `yaw`, `pitch` and `moveZ` are unused.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
session.input.moveY = (keys.has("KeyW") ? 1 : 0) - (keys.has("KeyS") ? 1 : 0); // throttle: below 0 brakes, then reverses
|
|
189
|
+
session.input.moveX = (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0); // steer: +1 turns RIGHT
|
|
190
|
+
session.input.buttons = (keys.has("Space") ? 1 : 0) | (keys.has("ShiftLeft") ? 2 : 0) | (keys.has("KeyQ") ? 4 : 0); // jump, boost, drift
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`moveX = +1` turns the car right, toward −yaw (with z up, +yaw is counterclockwise seen from
|
|
194
|
+
above): the steer-right key sends +1, like every other right input. A stick sends its x as is.
|
|
195
|
+
|
|
196
|
+
**How it drives.** Boost always drives forward at full throttle, burns a full tank in 3 seconds and
|
|
197
|
+
works in the air along the level heading. The preset only burns fuel: the module gives it (a boost
|
|
198
|
+
pad, a kickoff, a slow refill) by writing `fuel`. A standing car does not turn: the turn rate grows
|
|
199
|
+
with speed up to `turnSpeed`. Walls do not bounce: a car meeting one head-on stops in one sub-step,
|
|
200
|
+
and one scraping along a wall with its nose turned into it slows (past about 10° the throttle can
|
|
201
|
+
no longer hold its speed; parallel to the wall it recovers). A ramp the car can drive on (up to
|
|
202
|
+
about 45° with the default `standable`) is climbed at its flat-ground speed; a steeper one is a
|
|
203
|
+
wall. Downhill the car keeps to the road only while one sub-step's drop stays under `stepHeight`:
|
|
204
|
+
at 60 Hz with 2 sub-steps that is a slope of about 23° at top boost speed, so a steep downhill or a
|
|
205
|
+
crest launches it. Cars pass through each other: a bump or a demolition is the module's own rule,
|
|
206
|
+
and a velocity the module writes into a car shows on its driver's screen as a correction. A car the
|
|
207
|
+
module pushes into a wall, a ramp or the floor is pushed back out on its next step, through the
|
|
208
|
+
solid's nearest face (never its bottom, nor a top another solid sits on, such as a wall's top under
|
|
209
|
+
the ceiling); keep a push under half a wall's thickness plus the car's radius, or that nearest face
|
|
210
|
+
is the far one.
|
|
211
|
+
|
|
212
|
+
**The body.** On top of `x y z vx vy vz yaw` (the heading), the preset writes `spin` (the turn rate,
|
|
213
|
+
rad/s, positive toward +yaw: for wheels and skid marks), `fuel` (the tank, 0 to 1) and `mstate`
|
|
214
|
+
(bits: 1 on the ground, 4 jump held), and reads `cap`, a multiplier of both top speeds from 0 to 4
|
|
215
|
+
(default 1) that the module writes. It never writes `pitch` or `duck`. A respawn or a kickoff writes
|
|
216
|
+
the new spot and heading with `vx: 0, vy: 0, vz: 0, spin: 0, mstate: 0`, the fuel it starts with,
|
|
217
|
+
and bumps `epoch`.
|
|
218
|
+
|
|
219
|
+
**Tick rate.** Ask for `tickHz: 60`. The turn, the grip and the heading's turn of the velocity are
|
|
220
|
+
integrated once per sub-step, so another `tickHz` or `substeps` is a slightly different car (the
|
|
221
|
+
same one on every engine). A jump pressed on an input whose last sub-step lands is lost.
|
|
222
|
+
|
|
223
|
+
**Sine and cosine.** The preset turns the heading with `psin` and `pcos`, a sine and cosine every
|
|
224
|
+
engine computes to the same bits; the platform's `Math.sin` and `Math.cos` differ in the last bit
|
|
225
|
+
between engines and would split prediction from the server. The client can import them from
|
|
226
|
+
`@genex-ai/multiplayer/authority`; a room module cannot import a package, so it rounds `Math.sin` and
|
|
227
|
+
`Math.cos` with `Math.fround` ([module-api.md](module-api.md)) or, like template 3, uses none.
|
|
228
|
+
|
|
229
|
+
## The collision world (`firstperson` and `vehicle`)
|
|
230
|
+
|
|
231
|
+
Both presets move through one static world of boxes and ramps, z up:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
// src/world.ts: imported by server/room.js AND by the client, so both have the same world
|
|
235
|
+
export const WORLD = {
|
|
236
|
+
boxes: [
|
|
237
|
+
[-1024, -1024, -64, 1024, 1024, 0], // [minX, minY, minZ, maxX, maxY, maxZ]: the floor
|
|
238
|
+
[-1056, -1056, 0, -1024, 1056, 256], // a wall
|
|
239
|
+
],
|
|
240
|
+
ramps: [
|
|
241
|
+
[400, -160, 0, 592, 160, 96, 0, 0, 96], // [minX, minY, minZ, maxX, maxY, maxZ, axis, lo, hi]
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- **A ramp** is a box whose top rises along x (`axis` 0) or y (`axis` 1): `lo` at that axis's min
|
|
247
|
+
end, `hi` at its max end, `maxZ` equal to the higher of them and `minZ` at or below both.
|
|
248
|
+
- **Nothing is implicit.** With no floor box a body falls forever: open ground is one box.
|
|
249
|
+
- **Every box is axis-aligned** and every ramp rises along x or y, so a level is built on a grid; a
|
|
250
|
+
diagonal wall is a staircase of boxes.
|
|
251
|
+
- **Every number must be a float32**: integers are always safe, and so are halves and quarters;
|
|
252
|
+
`0.1` is refused. Each min is below its max. At most 4096 boxes and 512 ramps.
|
|
253
|
+
- **The module exports it**: `defineRoom({ …, world: WORLD })`. The sandbox validates it when the
|
|
254
|
+
module loads (a bad world is a bad config: the module does not load) and writes its hash into
|
|
255
|
+
the preset's `params.world`. Leave `params.world` out.
|
|
256
|
+
- **The client hands its session the same world**: `connectAuthority({ …, world: WORLD })`, or
|
|
257
|
+
`session.setWorld(WORLD)` before the first `frame()`. It returns the hash, and throws a
|
|
258
|
+
`WorldError` naming the first bad value when the world is one the room would refuse.
|
|
259
|
+
- **The hash is the gate.** The session predicts your own body only while its world's hash equals
|
|
260
|
+
the room's, so one number changed on one side turns prediction off: your body is then drawn from
|
|
261
|
+
the server, a round trip late, and the console says why once. `session.prediction` reports it:
|
|
262
|
+
`reason` is `world_mismatch`, with both hashes in `world` and `roomWorld`. Keep the world in one
|
|
263
|
+
file both sides import; never rebuild it on the client from a model or a level file.
|
|
264
|
+
- **Size is cheap.** The server and the session both cull with a bounding-volume tree, so a step
|
|
265
|
+
costs the solids near the body, not the size of the map, and a tower of stacked boxes costs no
|
|
266
|
+
more than the same boxes spread out. The results are the same with or without it.
|
|
267
|
+
- **Draw what collides.** Build the visible level from the same boxes and ramps (or art that sits
|
|
268
|
+
on them exactly): a wall drawn a little off is one players stop at in the air. The module's own
|
|
269
|
+
tests (a shot's line of sight, a ball's bounce) use the same `WORLD`.
|
|
270
|
+
|
|
271
|
+
## Axes and three.js
|
|
272
|
+
|
|
273
|
+
The server's ground is the x/y plane and **+z is up**. `yaw` turns from +x toward +y,
|
|
274
|
+
counterclockwise seen from above (yaw 0 faces +x), and a positive `pitch` looks up. For
|
|
275
|
+
`firstperson` and `vehicle`, draw with three.js's handedness kept:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
mesh.position.set(e.x, e.z, -e.y); // server (x, y, z) -> three (x, z, -y)
|
|
279
|
+
mesh.rotation.y = e.yaw; // a model that faces +x turns onto the heading
|
|
280
|
+
camera.rotation.set(pitch, yaw - Math.PI / 2, 0, "YXZ"); // a first-person camera at the eye, looking along the aim
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Do not use topdown's `(x, z, y)` drawing with these presets. It is a mirror image, which does not
|
|
284
|
+
matter while a game reads its input in the same drawn axes, as the topdown templates do. Here the
|
|
285
|
+
input's right is the server's, and in a mirror image the steer-right key turns the car left on
|
|
286
|
+
screen. Convert only where you draw and where you read input; the world stays in server axes, value
|
|
287
|
+
for value the module's, or its hash differs.
|
|
288
|
+
|
|
289
|
+
## Running a ball
|
|
290
|
+
|
|
291
|
+
A ball, in car football or any game where bodies knock something about, is **one server projectile
|
|
292
|
+
the module re-launches** whenever its flight changes ([projectiles.md](projectiles.md)): a fixed id
|
|
293
|
+
such as `ball`, launched from `step` with `src` `''`. Every client draws it from its flight at the
|
|
294
|
+
server's present, and nothing moves it in between. Template 3 in [room-template.md](room-template.md)
|
|
295
|
+
is a whole car arena built this way. Every tick, `step` reads it with `ctx.projectiles.at("ball")`
|
|
296
|
+
and re-launches it from where it is when one of these changes it:
|
|
297
|
+
|
|
298
|
+
- **Off the world.** A sphere test against every solid of the same `WORLD` (by each box's closest
|
|
299
|
+
point, and a ramp's closest point too, never its planes, which report a touch near an edge before
|
|
300
|
+
there is one). Moving into a surface with normal `n`, the new velocity is
|
|
301
|
+
`v - (1 + e) * dot(v, n) * n`, with a restitution `e` of about 0.6. Lose tangential speed only on
|
|
302
|
+
a real impact (`-dot(v, n)` over a threshold): a ball sliding down a slope touches it every few
|
|
303
|
+
ticks under gravity, and a loss on each touch glues it to the slope.
|
|
304
|
+
- **Off a car, at the time of impact.** Sweep the ball's flight from the tick before against the
|
|
305
|
+
car's move since the module last saw it (keep each car's position at the end of `step`), relative
|
|
306
|
+
to each other, to their first contact: sample the tick, then halve the step that first touches.
|
|
307
|
+
Tested only where both are at the end of the tick, a hit's direction and speed depend on how deep
|
|
308
|
+
the car got, and at 30 Hz a fast car passes under the ball. At the contact, push the ball out
|
|
309
|
+
along the normal of the sphere against the car's hull (the preset's box), give it the car's
|
|
310
|
+
velocity relative to it along that normal times `1 + e`, plus a share of the car's speed, and
|
|
311
|
+
place it by where the car is now. A minimum hit speed, if you want one, scales with the car's own
|
|
312
|
+
speed into the ball: a fixed one adds energy at every contact with a parked car, so the ball
|
|
313
|
+
bounces forever on its roof or between it and a wall.
|
|
314
|
+
- **Rolling.** A ball resting on the floor is launched with `g: 0` and `vz: 0`, so its flight stays
|
|
315
|
+
on the floor. Every few ticks (6 at 60 Hz) re-launch it with its speed `s` reduced by a share and a
|
|
316
|
+
constant, `max(0, s * (1 - k * N / tickHz) - d * N / tickHz)` for `N` ticks, and at rest once it
|
|
317
|
+
is slow: a share alone decays forever, so a slow ball creeps for seconds.
|
|
318
|
+
- **Goals.** The whole ball past a goal line inside the goal mouth is a `goal` event and a kickoff:
|
|
319
|
+
the ball at rest on the centre spot, every car back on its slot with an `epoch` bump.
|
|
320
|
+
- **Speed.** Cap the ball's speed below its radius per tick (`radius * tickHz`), so one tick's move
|
|
321
|
+
never carries it through a wall thinner than its diameter.
|
|
322
|
+
|
|
323
|
+
Each re-launch is one entity update, far inside a tick's caps. **The limit to say out loud:** every
|
|
324
|
+
client draws the ball where the server has it now, its own car ahead of the server by its input
|
|
325
|
+
lead, and other cars 100 ms behind. So your own touch shows your car reaching the ball a moment
|
|
326
|
+
before the ball reacts, and another car's touch shows the ball leaving before that car visibly gets
|
|
327
|
+
there: both are off by about the one-way latency. Draw the ball from `session.entities()` and leave
|
|
328
|
+
it there; do not try to hide this by moving the ball on the client.
|
|
329
|
+
|
|
330
|
+
## Sub-steps and the CPU budget
|
|
331
|
+
|
|
332
|
+
`substeps` (default 2) splits each input's step, so at 60 Hz the default runs 120 sub-steps a
|
|
333
|
+
second per body. Each sub-step costs about the same, so a room's preset time is about bodies ×
|
|
334
|
+
sub-steps. Genex's servers have room for a full car room at the most sub-steps. The local server runs
|
|
335
|
+
the module on QuickJS, several times slower: under Genex's limits a full car room (eight cars at
|
|
336
|
+
60 Hz) fits there at 2 sub-steps and is ended for CPU at 4 or more. **Keep `substeps` at 2 for a
|
|
337
|
+
full car room**; raise it only for fewer players, and only when the REPORT line of
|
|
338
|
+
`npx genex-authority-dev start --module server/room.js --root . --bots 7` keeps `tick.p99Ms` well
|
|
339
|
+
under `tick.budgetMs`. A `firstperson` map at the scale of a competitive shooter map (about 1500 boxes
|
|
340
|
+
and 60 ramps) at 2 sub-steps holds ten players on Genex's servers and about five on QuickJS.
|
|
341
|
+
|
|
342
|
+
The bots and the validation fuzz press button bit 1 most often. On both presets that is jump by
|
|
343
|
+
default, so in tests the bots mostly hop; a game whose main shot should be exercised binds it to
|
|
344
|
+
bit 1 and moves jump (`jumpBit: 8`), or accepts that the fuzz fires its other bits less often.
|
|
345
|
+
|
|
346
|
+
## Movement of your own
|
|
347
|
+
|
|
348
|
+
A game whose movement is none of these (flying, swimming, a double jump, a dash) leaves `preset` out
|
|
349
|
+
and moves the body itself in `onInput`. Write the step ONCE, in a file both sides import, and hand
|
|
350
|
+
the same function to the session so it predicts exactly what the server will do:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
// src/sim/step.ts: imported by server/room.ts AND by the client
|
|
354
|
+
import type { InputData } from "@genex-ai/multiplayer/authority/module";
|
|
355
|
+
|
|
356
|
+
export const SPEED = 8;
|
|
357
|
+
export function step(body: { x: number; y: number; yaw: number }, input: InputData, dt: number): void {
|
|
358
|
+
body.x = Math.fround(body.x + input.moveX * SPEED * dt);
|
|
359
|
+
body.y = Math.fround(body.y + input.moveY * SPEED * dt);
|
|
360
|
+
body.yaw = Math.fround(input.yaw);
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
// server/room.ts: onInput
|
|
366
|
+
onInput(ctx, playerId, input) {
|
|
367
|
+
const me = ctx.entities.ownedBy(playerId);
|
|
368
|
+
if (me) step(me, input, ctx.dt);
|
|
369
|
+
},
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
// the client
|
|
374
|
+
const session = await connectAuthority({
|
|
375
|
+
urls: getAuthorityUrls(),
|
|
376
|
+
room: GENEX.slug,
|
|
377
|
+
auth: () => getColyseusAuth(),
|
|
378
|
+
predict: { step, fields: ["x", "y", "yaw"] },
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`fields` lists what the step writes (any of `x y z vx vy vz yaw pitch duck mstate cap spin fuel`;
|
|
383
|
+
all of them when left out). `predict: false` turns prediction off: the player's own body is then
|
|
384
|
+
drawn from the server's state, a round trip late, which is right only for something that does not
|
|
385
|
+
move under the player's hands (a turret, a card table).
|
|
386
|
+
|
|
387
|
+
Rules for a step of your own:
|
|
388
|
+
|
|
389
|
+
- Pure: the body, the input and `dt` in, the body out. No clock, no random, no state of its own.
|
|
390
|
+
- `Math.fround` every value it stores, as the wire carries float32.
|
|
391
|
+
- `Math.sin`, `Math.cos`, `Math.atan2` and `Math.exp` may differ in the last bit between engines:
|
|
392
|
+
round what they give with `Math.fround` before storing it.
|
|
393
|
+
- The server stays the judge: the step predicts movement; collisions with other players, damage
|
|
394
|
+
and scoring stay in the module and reach the client as state.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Projectiles
|
|
2
|
+
|
|
3
|
+
A projectile is an entity on a fixed ballistic flight: a start point, a velocity and an optional
|
|
4
|
+
gravity. The room module writes the launch **once**; every client works out where the projectile is
|
|
5
|
+
at any instant from that launch. Other players' projectiles are therefore drawn where the server has
|
|
6
|
+
them now, not 100 ms behind like bodies, and your own is drawn from the frame you fire it. A flying
|
|
7
|
+
projectile costs nothing per tick on the wire: only its launch, a re-launch and its removal go out.
|
|
8
|
+
|
|
9
|
+
Projectiles need `@genex-ai/multiplayer` 0.17.4 or newer. If `session.predictProjectile` does not
|
|
10
|
+
exist after `npm i @genex-ai/multiplayer@latest`, the release is not out yet: say so and stop; do
|
|
11
|
+
not move projectile entities by hand every tick instead.
|
|
12
|
+
|
|
13
|
+
A complete room and client to start from: the bolt arena, the second template in
|
|
14
|
+
[room-template.md](room-template.md).
|
|
15
|
+
|
|
16
|
+
## Projectile or hitscan
|
|
17
|
+
|
|
18
|
+
| Use | When | How it is judged |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Hitscan, `ctx.rewind.hitscan` | The shot is instant: rifles, lasers, a sniper. It must land where the crosshair was. | Lag-compensated (with `config.lagComp`): against where the shooter saw the targets. |
|
|
21
|
+
| A projectile, `ctx.projectiles` | Players can see the shot travel and dodge it: bolts, rockets, grenades, arrows, dodgeballs, fireballs. | Against where the targets are on the server now. No lag compensation. |
|
|
22
|
+
|
|
23
|
+
A projectile is fair without lag compensation because the target sees it coming at the server's
|
|
24
|
+
present and can step aside. That also means a shooter must lead a moving target: the bodies they see
|
|
25
|
+
are about 100 ms in the past. Keep projectiles slow enough to see (a few to a few tens of units per
|
|
26
|
+
second); a shot that should feel instant is hitscan.
|
|
27
|
+
|
|
28
|
+
## The room module
|
|
29
|
+
|
|
30
|
+
### `ctx.projectiles.launch(init)`
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
const id = ctx.projectiles.launch({ kind: "bolt", x, y, z: 0, vx, vy, vz: 0, g: 0 });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Writes a projectile whose flight starts at the tick being stepped and returns its id. `kind`, `x`,
|
|
37
|
+
`y`, `z`, `vx`, `vy` and `vz` are required (finite, at most 1e9 in magnitude); `g` is the downward
|
|
38
|
+
pull along −z in units/s², from 0 to 1000 (default 0, a straight line); `hp`, `flags` and `data` are
|
|
39
|
+
optional and work as on any entity. The server stores every number as float32. `owner` is always
|
|
40
|
+
`''`: a projectile is never a player's predicted body.
|
|
41
|
+
|
|
42
|
+
- **In `onInput`** the projectile belongs to that input: its `src` is the shooter's seat and its id
|
|
43
|
+
is `<seat>.<seq>.<n>`, the firing input's sequence number and the launch's index within that input
|
|
44
|
+
from 0. This is the id the shooter's client predicted it under, so never pass `id` here — it is
|
|
45
|
+
refused (a `TypeError`).
|
|
46
|
+
- **In `step`, `onJoin` or `onLeave`** pass an `id` yourself; `src` is `''` (a server projectile: a
|
|
47
|
+
turret, a trap, a bounce).
|
|
48
|
+
- **Never in `init`**: a launch there throws. `init` steps no tick.
|
|
49
|
+
- **The same id again re-launches it**: the new launch replaces the old one whole, from the tick being
|
|
50
|
+
stepped (pass `hp`, `flags` and `data` again to keep them). That is how a projectile bounces or
|
|
51
|
+
turns. A re-launch from `step` makes it a server projectile (`src` becomes `''`), so keep who
|
|
52
|
+
fired it in `ctx.state` if you score with it.
|
|
53
|
+
- **Change a flight only by re-launching.** Writing `x`…`vz` with `ctx.entities.update` moves the
|
|
54
|
+
start point but keeps the old launch tick, so the projectile jumps. `t0`, `g` and `src` are
|
|
55
|
+
read-only (`ctx.entities.update` ignores them). `hp`, `flags` and `data` update normally.
|
|
56
|
+
|
|
57
|
+
### `ctx.projectiles.at(id, tick?)`
|
|
58
|
+
|
|
59
|
+
Where the projectile is at `tick` (default: the tick being stepped): `{ x, y, z, vx, vy, vz }`, or
|
|
60
|
+
`undefined` when `id` is not a projectile. Use it for walls, the floor and bounds:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const at = ctx.projectiles.at(id);
|
|
64
|
+
if (Math.abs(at.x) > ARENA || Math.abs(at.y) > ARENA || at.z < 0) ctx.entities.remove(id);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `ctx.projectiles.sweep(id, { kinds, radius })`
|
|
68
|
+
|
|
69
|
+
The entities of those `kinds` whose sphere of `radius` the projectile's path crossed between the
|
|
70
|
+
previous tick and this one, nearest first, as `{ id, dist }` (`dist`: how far along that path, from
|
|
71
|
+
its point at the previous tick). A fast projectile never tunnels through a body between two ticks.
|
|
72
|
+
|
|
73
|
+
- The projectile is a point: `radius` is the target's radius **plus** the projectile's.
|
|
74
|
+
- Swept in `step`, bodies are tested where they stand this tick, after this tick's inputs moved them.
|
|
75
|
+
- **The shooter's own body comes back too** when the projectile starts inside it (launched from the
|
|
76
|
+
body's centre, as the templates do). Skip hits whose `owner` is the shooter.
|
|
77
|
+
- When `step` sweeps a projectile this tick's input launched, its path is just its start point: a
|
|
78
|
+
target standing inside the muzzle is hit before any client sees the shot (a point-blank hit).
|
|
79
|
+
- Cost is the number of entities of the kinds you ask for. A couple of hundred projectiles each
|
|
80
|
+
swept against the players fit a tick; hundreds swept against each other do not. Name only the
|
|
81
|
+
kinds that can be hit.
|
|
82
|
+
|
|
83
|
+
### Removing them is the module's job
|
|
84
|
+
|
|
85
|
+
The server never ages a projectile out. Remove it on a hit, past the arena, below the floor and after
|
|
86
|
+
a maximum age, or the room fills up: a tick that would leave more than 1024 entities is a fault.
|
|
87
|
+
Keep the live projectiles in `ctx.state` (an array, in launch order) and walk that list in `step`,
|
|
88
|
+
so the order is fixed and every replay is identical.
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
step(ctx) {
|
|
92
|
+
ctx.state.shots = ctx.state.shots.filter((shot) => fly(ctx, shot)); // false once it is gone
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Limits
|
|
97
|
+
|
|
98
|
+
- Projectiles are entities: they count against the 1024 per room, and their launches and removals
|
|
99
|
+
against the 256 updates and 256 removals per tick.
|
|
100
|
+
- Launches still waiting to go out may hold at most 224 entities and 28 KiB of output — about a
|
|
101
|
+
hundred new projectiles in one tick. A launch past that throws a `RangeError` and writes nothing.
|
|
102
|
+
Uncaught, it is a fault, and the tick's changes go out with the next tick. A shotgun or a burst
|
|
103
|
+
catches it:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
for (let i = 0; i < PELLETS; i++) {
|
|
107
|
+
try { ctx.projectiles.launch(pellet(i)); } catch (e) { if (e instanceof RangeError) break; throw e; }
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- Cap the fire rate per seat with a cooldown in ticks, as the template does. It is what keeps the
|
|
112
|
+
number of live projectiles, and of hit events per tick (at most 64), bounded.
|
|
113
|
+
|
|
114
|
+
## The client
|
|
115
|
+
|
|
116
|
+
### Fire: predict while staging the input
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
if (wantsToFire && !armed) {
|
|
120
|
+
session.input.buttons = FIRE;
|
|
121
|
+
session.predictProjectile({ kind: "bolt", x: me.x, y: me.y, z: 0, vx, vy, vz: 0, g: 0 });
|
|
122
|
+
armed = true; // staged: predict nothing more until it is sent
|
|
123
|
+
}
|
|
124
|
+
const sent = session.frame(now);
|
|
125
|
+
if (armed && sent > 0) {
|
|
126
|
+
session.input.buttons = 0; // only the input the prediction was made for fires
|
|
127
|
+
armed = false;
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`predictProjectile` must run **before the `frame()` that sends the firing input**, once per
|
|
132
|
+
projectile the module will launch for it, in the same order. It returns the id the module's launch
|
|
133
|
+
will get; the projectile is in `session.entities()` that same frame. When the server's projectile of
|
|
134
|
+
that id arrives, the same entry keeps flying from where the prediction was, so a mesh keyed by id
|
|
135
|
+
never jumps back.
|
|
136
|
+
|
|
137
|
+
- **Predict the module's numbers**: the same start point, speed, gravity and aim. Round the aim with
|
|
138
|
+
`Math.fround` before you send it and before you use it, as the server stores it. The module
|
|
139
|
+
launches from the body after this input moved it, the client from its drawn body; a shooter firing
|
|
140
|
+
on the run sees a small nudge when the server's projectile takes over.
|
|
141
|
+
- **Mirror the cooldown, with slack.** A prediction the module refuses is drawn and then vanishes.
|
|
142
|
+
Count inputs with `session.telemetry.inputsSent` and wait a few inputs longer than the module's
|
|
143
|
+
cooldown in ticks.
|
|
144
|
+
- Predict only your own shots. Never create, move or remove projectile entities on the client.
|
|
145
|
+
|
|
146
|
+
### Draw them from `session.entities()`
|
|
147
|
+
|
|
148
|
+
An entity with `t0 > 0` is a projectile. Its `x`, `y`, `z` (and `vx`, `vy`, `vz`) are already where
|
|
149
|
+
it is this frame: set the mesh there every frame and do not smooth, lerp or extrapolate it.
|
|
150
|
+
|
|
151
|
+
- `e.src === session.id` is your own shot; `''` is a server projectile, or one the server re-launched
|
|
152
|
+
(a bounce). A projectile is never `session.me()`.
|
|
153
|
+
- When an id leaves `session.entities()`, remove its mesh: it hit something, left the arena or was
|
|
154
|
+
refused.
|
|
155
|
+
- **A refused or point-blank shot**: the prediction disappears once the server has handled the input
|
|
156
|
+
that fired it (at the latest after max(2·RTT, 600 ms)). A refusal (cooldown, dead shooter) and a
|
|
157
|
+
point-blank hit look the same from the state; the module's own events say which, so draw hits,
|
|
158
|
+
damage and kills only from those events and from `hp`.
|
|
159
|
+
- `session.telemetry` counts `projectilesPredicted`, `projectilesConfirmed` and
|
|
160
|
+
`projectilesRejected`; `lastProjectileLeadMs` is how much later the server launched your latest
|
|
161
|
+
confirmed shot than you predicted it (roughly the one-way latency plus up to a tick).
|
|
162
|
+
|
|
163
|
+
### Axes in three.js
|
|
164
|
+
|
|
165
|
+
The server's ground is x/y and +z is up; `yaw` 0 points along +x. In three.js y is up:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
mesh.position.set(e.x, e.z + HEIGHT, e.y); // server (x, y, z) → three (x, z, y)
|
|
169
|
+
mesh.lookAt(e.x + e.vx, e.z + HEIGHT + e.vz, e.y + e.vy); // point a rocket along its flight
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A body turns by `-yaw` around three's y axis. A projectile launched at `z: 0` and drawn at a fixed
|
|
173
|
+
`HEIGHT` keeps the hit test on the ground plane; a lob launches with `vz > 0` and `g > 0` and lands
|
|
174
|
+
when `ctx.projectiles.at(id).z < 0`.
|
|
175
|
+
|
|
176
|
+
## Every client must support projectiles
|
|
177
|
+
|
|
178
|
+
A client that does not implement projectiles draws each one frozen where it was launched: the server
|
|
179
|
+
writes the launch once and never moves it. Every client in a room whose module launches projectiles
|
|
180
|
+
must support them:
|
|
181
|
+
|
|
182
|
+
- Ship `@genex-ai/multiplayer` 0.17.4 or newer in the same build as the first module that launches.
|
|
183
|
+
- A native build (Unity, Godot, Unreal, Rust) in the same room supports them only once its Genex
|
|
184
|
+
multiplayer SDK passes the projectile conformance checks; until then, keep that build out of rooms
|
|
185
|
+
with projectiles.
|
|
186
|
+
|
|
187
|
+
## Before calling it done
|
|
188
|
+
|
|
189
|
+
1. `validate` passes, and its fuzz players fire (bind the main shot to button bit 1, the bit the fuzz
|
|
190
|
+
and the local bots press most).
|
|
191
|
+
2. At `--latency 75`, your shot leaves the instant you click and flies on without a jump; another
|
|
192
|
+
player's shots fly smoothly toward you, not 100 ms late.
|
|
193
|
+
3. `session.telemetry.projectilesRejected` stays near 0 while you fire steadily (a rising count means
|
|
194
|
+
the client predicts shots the module refuses).
|
|
195
|
+
4. Every projectile is removed: a few minutes of play leave the entity count flat.
|