@genex-ai/multiplayer 0.17.1 → 0.17.8
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/PROTOCOL.md +761 -9
- package/README.md +285 -1
- package/WORLD-GLTF.md +109 -0
- package/WORLD-SCENE.md +135 -0
- package/dist/authority/fast-udp.d.ts +15 -0
- package/dist/authority/fast-udp.js +14 -0
- package/dist/authority/fast-webtransport.d.ts +67 -0
- package/dist/authority/fast-webtransport.js +21 -0
- package/dist/authority/index.d.ts +2671 -0
- package/dist/authority/index.js +958 -0
- package/dist/authority/module.d.ts +2 -0
- package/dist/authority/module.js +12 -0
- package/dist/bridge-authority.d.ts +101 -0
- package/dist/bridge-authority.global.js +15 -0
- package/dist/bridge-authority.js +291 -0
- package/dist/bridge-core-Dhc73GC8.d.ts +27 -0
- package/dist/bridge.d.ts +26 -20
- package/dist/bridge.global.js +7 -7
- package/dist/bridge.js +17 -48
- package/dist/chunk-2NGXXQDS.js +1916 -0
- package/dist/chunk-4R7C2TLH.js +68 -0
- package/dist/{chunk-C52XFIB6.js → chunk-CSQEEANB.js} +217 -4
- package/dist/chunk-FPWP22Y7.js +143 -0
- package/dist/chunk-NFMGIKFZ.js +2627 -0
- package/dist/chunk-OD6NRSX2.js +29 -0
- package/dist/{chunk-3ZUPU3GT.js → chunk-QY3QQNDJ.js} +13 -122
- package/dist/chunk-QZOCUQJT.js +6900 -0
- package/dist/chunk-TSYEWPVO.js +44 -0
- package/dist/chunk-TVTVIMIH.js +675 -0
- package/dist/chunk-YWQ3ALYY.js +69 -0
- package/dist/fast-client--A88q0kk.d.ts +546 -0
- package/dist/index.d.ts +3 -5
- package/dist/index.js +3 -2
- package/dist/join-CGoInvUM.d.ts +6 -0
- package/dist/level-build-CbRqrYJq.d.ts +140 -0
- package/dist/module-CSpXrzFc.d.ts +1187 -0
- package/dist/physics-predict-C7ZB1gDG.d.ts +1223 -0
- package/dist/protocol.d.ts +222 -2
- package/dist/protocol.js +47 -1
- package/dist/shot-world-D22tqOUT.d.ts +485 -0
- package/dist/world-fit/index.d.ts +549 -0
- package/dist/world-fit/index.js +4763 -0
- package/dist/world-fit/three.d.ts +77 -0
- package/dist/world-fit/three.js +140 -0
- package/package.json +46 -4
package/README.md
CHANGED
|
@@ -213,7 +213,9 @@ self-heal on the next publish, and lost `send()` events / inputs are counted in
|
|
|
213
213
|
Render `reconnecting`/`reconnected` as an overlay and keep your loop running. `disconnect` is terminal
|
|
214
214
|
(server restart, revoked session `4401`, or the link never recovered `4003`): read a fresh token and
|
|
215
215
|
`connect()` again. A deliberate `leave()` never reconnects. The relay also broadcasts `server:restart` right before
|
|
216
|
-
a deploy — flush saves when you see it.
|
|
216
|
+
a deploy — flush saves when you see it. An authority session (`connectAuthority`) does more: a deploy or a dead server
|
|
217
|
+
moves its seat to a fresh room by itself, on the same session object, saying `draining`, `rejoining` and `rejoined`
|
|
218
|
+
(PROTOCOL.md §17.13), and its `disconnect` means it is over for good.
|
|
217
219
|
|
|
218
220
|
**One seat per player:** the relay enforces a single session per identity per room — a
|
|
219
221
|
second tab / device / reloaded page supersedes the previous session (old one gets
|
|
@@ -605,6 +607,288 @@ for (const e of JSON.parse(drainEventsJson(h))) { /* { type: 'playerJoined', id
|
|
|
605
607
|
Pages with no bundler load `dist/bridge.global.js` (the SDK and embed-sdk bundled in), which sets
|
|
606
608
|
`window.GenexMultiplayerBridge`. A JS game should use the SDK directly, not the bridge.
|
|
607
609
|
|
|
610
|
+
*(0.17.3, contract version 2)* A server-authoritative game (below) drives its room through the
|
|
611
|
+
bridge's authority calls instead — `openAuthority`, `setInput`, `predictProjectile` (your shot, drawn
|
|
612
|
+
the frame you fire it; `''` without a seat or while it reconnects), `setWorld` (a `firstperson` or
|
|
613
|
+
`vehicle` room's collision world, 0.17.4), `frame` (your predicted entity and every other one,
|
|
614
|
+
interpolated, as one JSON string per render frame), `authorityJson`,
|
|
615
|
+
`drainAuthorityEventsJson`, `closeAuthority`, `disposeAuthority` — from
|
|
616
|
+
`@genex-ai/multiplayer/bridge/authority`, or on a page from `dist/bridge-authority.global.js`, which is
|
|
617
|
+
`bridge.global.js` plus these calls (a relay game keeps loading the smaller script).
|
|
618
|
+
|
|
619
|
+
```js
|
|
620
|
+
const h = GenexMultiplayerBridge.openAuthority(JSON.stringify({ gameId: 'my-game', urls: [] }));
|
|
621
|
+
// every render frame:
|
|
622
|
+
GenexMultiplayerBridge.setInput(h, JSON.stringify({ moveX: 1, moveY: 0, yaw: aim, buttons: 0 }));
|
|
623
|
+
const { me, entities } = JSON.parse(GenexMultiplayerBridge.frame(h));
|
|
624
|
+
for (const e of JSON.parse(GenexMultiplayerBridge.drainAuthorityEventsJson(h))) { /* joined, message, … */ }
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
## Server-authoritative rooms — `@genex-ai/multiplayer/authority` *(0.17.2; firstperson, vehicle and projectiles 0.17.4)*
|
|
628
|
+
|
|
629
|
+
Protocol 4 ([`PROTOCOL.md` §17](./PROTOCOL.md#17-authority-rooms-protocol-4)): the server runs the
|
|
630
|
+
game's own room module at a fixed tick, clients send typed input and render what the server writes.
|
|
631
|
+
Use it when cheating matters (competitive play); everything above is the relay and is unchanged by it.
|
|
632
|
+
Genex's authority servers serve these rooms in production (Asia, EU and US); `npx genex-authority-dev start` serves the same module locally.
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
import { connectAuthority } from '@genex-ai/multiplayer/authority';
|
|
636
|
+
import * as genexEmbed from '@genex-ai/embed-sdk';
|
|
637
|
+
|
|
638
|
+
await genexEmbed.waitForPlayer();
|
|
639
|
+
const s = await connectAuthority({
|
|
640
|
+
urls: genexEmbed.getAuthorityUrls(), // the authority cluster, NOT getColyseusUrls()
|
|
641
|
+
regions: genexEmbed.getAuthorityRegions(), // its regions: the nearest is joined (PROTOCOL.md §17.1)
|
|
642
|
+
room: 'my-game',
|
|
643
|
+
auth: () => genexEmbed.getColyseusAuth(),
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
function loop(now: number) {
|
|
647
|
+
s.input.moveX = right - left; // [-1, 1]
|
|
648
|
+
s.input.moveY = up - down;
|
|
649
|
+
s.input.yaw = aimAngle; // radians
|
|
650
|
+
s.input.buttons = firing ? 1 : 0; // your bitmask, < 2^24
|
|
651
|
+
s.frame(now); // FIRST: sends due inputs, predicts, interpolates
|
|
652
|
+
const me = s.me(); // your entity, predicted (no input delay)
|
|
653
|
+
for (const e of s.entities().values()) draw(e.id, e.x, e.y, e.yaw); // others ~100 ms behind
|
|
654
|
+
requestAnimationFrame(loop);
|
|
655
|
+
}
|
|
656
|
+
requestAnimationFrame(loop);
|
|
657
|
+
s.on('hit', (data) => flash(data)); // events your room module emits
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
- **Input, not state.** `s.input` is sent once per fixed step (the room's tick rate) while you call
|
|
661
|
+
`frame()`; nothing is sent while you do not. There is no heartbeat.
|
|
662
|
+
- **Prediction.** When the room runs a movement preset (`topdown`, `firstperson` or `vehicle`), your own entity
|
|
663
|
+
moves the instant you press, with the same step the server runs, and is corrected smoothly when the
|
|
664
|
+
server disagrees.
|
|
665
|
+
Pass `predict: { step, fields }` for your own step, or `predict: false` to render the server's state.
|
|
666
|
+
When your entity respawns (its `epoch` changes) it snaps to the server's new pose and keeps the
|
|
667
|
+
inputs still in flight, which the server applies to the new life, so moving through a death costs no
|
|
668
|
+
correction. `s.telemetry` counts `inputsSent`, `inputsDropped`, `reconciles`, `visibleCorrections`
|
|
669
|
+
(a move of your place past 5 cm: 0.05 × the room's `lengthUnit`, else the preset's implied unit), `peakCorrection`
|
|
670
|
+
(the largest such move, room units; velocity and angles are never in it), `peakStateCorrection` (the largest
|
|
671
|
+
correction of any other mirrored field, each in its own unit: only zero or not says anything) and `reseeds` (respawns of your entity and reconnects, never counted
|
|
672
|
+
as corrections). `s.prediction` says whether your entity is predicted right now, and why not
|
|
673
|
+
(`off`, `no_entity`, `no_preset`, `world_mismatch`).
|
|
674
|
+
- **`firstperson`** ([§17.7](./PROTOCOL.md#177-movement-presets)) is Source-engine movement through a
|
|
675
|
+
static world of boxes and ramps, z up: friction, ground and air acceleration (air-strafing), jump,
|
|
676
|
+
duck and crouch-jump, a free step up ledges, staying on the ground down slopes and stairs, sliding
|
|
677
|
+
along walls; a ramp too steep to stand on is a wall. Ask for `tickHz: 60`: air-strafing turns the
|
|
678
|
+
wish direction once per input, so at 30 Hz a perfect strafe gains less than half of what it does at
|
|
679
|
+
60 (sub-steps do not change that). Send `moveX`/`moveY` in WORLD axes (turn your
|
|
680
|
+
camera-relative keys by your view yaw yourself; the step has no trigonometry), `yaw`/`pitch` as the
|
|
681
|
+
aim, and jump / duck / walk on `buttons` (1, 2, 4 by default). Give the session the module's world —
|
|
682
|
+
`connectAuthority({ …, world })` or `s.setWorld(world)` before the first `frame()` — the same
|
|
683
|
+
`{ boxes: [[minX, minY, minZ, maxX, maxY, maxZ], …], ramps: [[…, axis, lo, hi], …] }` the module
|
|
684
|
+
exports: it is validated (`WorldError` when §17.7 refuses it) and hashed, and your entity is
|
|
685
|
+
predicted only while that hash is the room's `params.world`. Otherwise it renders from the server's
|
|
686
|
+
state, and `s.prediction.reason` is `world_mismatch`. Entities then also carry `pitch` (within the
|
|
687
|
+
preset's ±`pitchLimit`), `duck` (0 standing .. 1 ducked; derive the eye height from it), `mstate`
|
|
688
|
+
(`MoveState` bits: 1 onGround, 2 ducking, 4 jumpHeld) and `cap` (the speed multiplier the module
|
|
689
|
+
writes: a ceiling on the speed, never a weaker push, so a wound cannot freeze a player), predicted
|
|
690
|
+
for yours and interpolated for others. `firstpersonWorld(spec)` validates and
|
|
691
|
+
hashes a world, and `stepFirstperson` is the step itself.
|
|
692
|
+
- **`vehicle`** ([§17.7](./PROTOCOL.md#177-movement-presets)) is an arcade car on Rocket League's
|
|
693
|
+
ground and air handling, kept level (no pitch or roll): throttle, braking and coasting, boost from a
|
|
694
|
+
fuel tank, grip that turns the velocity with the heading (`driftGrip` while drift is held, so the
|
|
695
|
+
tail slides), a turn rate that grows with speed up to `turnSpeed` and mirrors in reverse, a jump and
|
|
696
|
+
steering in the air. It drives the same hull through the same world as `firstperson` (walls clip
|
|
697
|
+
the velocity along them, nothing bounces), so it takes the same `world` / `setWorld` and predicts
|
|
698
|
+
only while the hashes match. Axes: x/y is the ground, z is up, and `yaw` is the heading,
|
|
699
|
+
counterclockwise seen from above (+yaw turns left; yaw 0 faces +x). The heading is the BODY's
|
|
700
|
+
state, not input: send `moveY` as the throttle (below 0 reverses) and `moveX` as the steer, where
|
|
701
|
+
**+1 turns right, toward −yaw** (your steer-right key is `moveX = +1`, as for any other right input),
|
|
702
|
+
and jump / boost / drift on `buttons` (1, 2, 4 by default); `yaw`, `pitch` and `moveZ` are unused.
|
|
703
|
+
Ask for `tickHz: 60`. Entities then also carry `spin` (the yaw rate, rad/s, positive toward +yaw:
|
|
704
|
+
for wheels and skid marks) and `fuel` (the tank, 0..1), predicted for yours and the server's latest
|
|
705
|
+
for others; your own heading draws along the short arc across ±π. The module writes `fuel` to
|
|
706
|
+
refuel (a boost pad, a respawn: `fuel: 1, spin: 0, mstate: 0` with an `epoch` bump) and `cap` to
|
|
707
|
+
scale both top speeds; the step only burns fuel. `stepVehicle` is the step itself. It turns the
|
|
708
|
+
heading with `psin` / `pcos`, §17.7's portable sine and cosine (exported here), which every engine
|
|
709
|
+
computes to the same bits; the platform's `Math.sin` / `Math.cos` differ in the last bit between
|
|
710
|
+
engines and would desynchronize prediction.
|
|
711
|
+
- **Entities** carry `kind`, `owner`, `x y z yaw` (rendered), `vx vy vz`, `hp`, `flags`, `epoch`,
|
|
712
|
+
`data` (JSON text), the projectile fields `t0`, `g`, `src`, the firstperson fields `pitch`,
|
|
713
|
+
`duck`, `mstate`, `cap` and the vehicle fields `spin`, `fuel`. A changed `epoch` is a teleport: it snaps, never glides. Another player's snap
|
|
714
|
+
lands when its render time (100 ms behind) reaches the bump, the time lag compensation judges a shot
|
|
715
|
+
at; until then it is drawn where it was, and `epoch` reports the life drawn.
|
|
716
|
+
- **Projectiles** ([§17.9](./PROTOCOL.md#179-projectiles)) are entities with a launch tick `t0 > 0`,
|
|
717
|
+
a gravity `g` and their shooter's seat `src` (`''` for a server one). Each is drawn from its
|
|
718
|
+
closed-form flight (`projectileAt`), never interpolated: another player's at the server's present,
|
|
719
|
+
not 100 ms behind. Fire your own with `s.predictProjectile({ kind, x, y, z, vx, vy, vz, g })` while
|
|
720
|
+
staging the input that fires: it is drawn that same frame under the id the room module's launch
|
|
721
|
+
gets (`<seat>.<seq>.<n>`), keeps that id when the server's projectile confirms it, and flies on
|
|
722
|
+
from where the prediction was; one the server re-launches as its own (a bounce) is drawn at the
|
|
723
|
+
server's present from then on. One no state ever shows (refused, or removed by a hit before a patch
|
|
724
|
+
carried it) is dropped as soon as the server acknowledges the input that fired it, one whose input
|
|
725
|
+
a drop lost at the reconnect's first state (the next shot reuses its id), and at the latest after
|
|
726
|
+
max(2·RTT, 600 ms). While the socket is down (`reconnecting` until `reconnected`) it predicts
|
|
727
|
+
nothing and answers `''`: no input goes out then. Every client in a room that launches projectiles must implement §17.9: an
|
|
728
|
+
older one draws each projectile frozen at its launch point.
|
|
729
|
+
A projectile is never `me()`. `s.telemetry` counts `projectilesPredicted`, `projectilesConfirmed`,
|
|
730
|
+
`projectilesRejected` and the latest `lastProjectileLeadMs`. The module side is
|
|
731
|
+
`ctx.projectiles.launch / at / sweep`.
|
|
732
|
+
- **Physics rooms** ([§17.10](./PROTOCOL.md#1710-physics)): the server simulates rigid bodies and
|
|
733
|
+
this session draws them; nothing on the client steps physics. Every join sends the `physics@1`
|
|
734
|
+
capability, which a physics room requires (426 without it). A body entity (one with a descriptor in
|
|
735
|
+
the state's `bodies`) comes out of `entities()` with `body: true`, its drawn orientation in
|
|
736
|
+
`qx qy qz qw` (z up) and the server's `sleep` flag; its `x y z` are the server's present, not
|
|
737
|
+
100 ms behind: the latest state (the body at the patch's tick + 1) extrapolated on the room clock
|
|
738
|
+
by its velocities (at most 250 ms, back a tick at most before its tick, not at all while asleep), and a newer state eased in from where
|
|
739
|
+
the older one was drawn (the offset decaying as e^(−age / 100 ms)). An `epoch` bump, a correction
|
|
740
|
+
over one metre or a body's first state snaps. A body out of this client's view is still listed,
|
|
741
|
+
held at its last state, until its descriptor goes. `vx vy vz` on a body are its linear velocity in
|
|
742
|
+
room units/s. `s.telemetry` counts `bodyStates`, `bodySnaps` and `peakBodyCorrection`. The drawing
|
|
743
|
+
is `physics-draw.ts` (`bodyStateOf`, `extrapolateBody`, `receiveBodyState`, `drawBody`), held to
|
|
744
|
+
the netcode bench's `physics-draw.json` vectors. A body entity is never `me()`. The module side is
|
|
745
|
+
`config.physics` and `ctx.physics` (commands, queries answered next tick, events, `body(key)` for
|
|
746
|
+
subscribed kinds); the wire and the reference tables are `wire.ts` and `physics.ts`. Genex's authority
|
|
747
|
+
servers run these rooms on the pinned core. `s.setPhysics(hash)` (or `connectAuthority({ …, physics })`)
|
|
748
|
+
names a physics world your client runs a copy of; `s.physics.reason` is `physics_mismatch` while the
|
|
749
|
+
room's hash differs.
|
|
750
|
+
- **Physics prediction** ([§17.11](./PROTOCOL.md#1711-physics-prediction)): give the session the core,
|
|
751
|
+
`connectAuthority({ …, world, physicsCore: async () => { const p = await import('@genex-ai/physics');
|
|
752
|
+
await p.init(); return p; } })`, and in a predicting room (the default for physics rooms) it steps the
|
|
753
|
+
bodies near you in a world of its own and draws them at your present (`predicted: true` on their
|
|
754
|
+
view); the core loads only once the room sends keyframes, and `wasmSimdSupported()` says when to pass
|
|
755
|
+
the non-SIMD module instead. `predictPhysics: false` turns it off; `s.physicsPrediction` reports
|
|
756
|
+
`status` (`on`, `off`, `loading`, `unavailable`, `mismatch`, `self_test`, `diverged`, `cpu`), how
|
|
757
|
+
keyframes were adopted, the slowest and latest replay and how many held a 60 Hz frame back
|
|
758
|
+
(`peakResimMs`, `lastResimMs`, `slowResims`), and how often the replay budget fell back to `cpu` and came back
|
|
759
|
+
(`fallbacks`, `recoveries`: `cpu` probes a replay every 500 ms and turns prediction on again after
|
|
760
|
+
four calm ones). The budget is the device's (4 ms desktop, 5 mobile); `physicsBudgetMs` overrides it
|
|
761
|
+
when it is a finite number over 0 (benches and CI on a slow host; a game leaves it unset). Remote players move by their velocity or, in a room asking
|
|
762
|
+
`remotePredict: 'inputDecay'`, by their last input repeated with decay (`'hold'`: held). Module commands are not
|
|
763
|
+
predicted, except the rules the module writes as pure functions and hands the client too:
|
|
764
|
+
`contacts(a, b)`, a seat contact rule (a car-to-car bump) run in your own entity's prediction
|
|
765
|
+
against the other seats, and `bodyRule({ tick, seats, body })`, a body rule (a scripted hit on a
|
|
766
|
+
ball) run before every step of the client world, and `kinematicRule(key, tick)`, where the module
|
|
767
|
+
poses its kinematic bodies (a door, a moving floor) at each tick, so the client world moves them as
|
|
768
|
+
the room does and draws them as predicted bodies (and by the rule on the server's timeline whenever
|
|
769
|
+
it does not predict them: no core, `cpu`); the module must run the same functions. Held to
|
|
770
|
+
the netcode bench's `physics-predict.json` (tolerance 0 on the client stateHash) and
|
|
771
|
+
`seat-contacts.json`. With `contacts`, `seatDrawing` chooses how other seats are drawn:
|
|
772
|
+
`predicted` (default, the Rocket League model) on your car's clock, every other seat stepped by the
|
|
773
|
+
room preset under its last input (`remotePredict: 'hold'` holds it) with the module's whole contact
|
|
774
|
+
pass (`contactPass`, else `contacts` on every pair) run on every seat and yours, each correction
|
|
775
|
+
eased in as a decaying offset that keeps the drawn velocity and never pulls a seat back against its
|
|
776
|
+
motion (`drawTick()`, `drawLead()` say where they were drawn); `separate` on
|
|
777
|
+
the render timeline but moved out of your drawn entity by the rule and taken round it, never across
|
|
778
|
+
it (draw-only); `timeline` on the render timeline only (a seat that missed yours on the server can
|
|
779
|
+
be drawn through it). `predicted` and `separate` keep every seat out of yours and out of each other
|
|
780
|
+
([§17.11 *Drawing other seats*](./PROTOCOL.md#1711-physics-prediction)). A room whose module
|
|
781
|
+
declares `config.contacts` (the universal contact policy: a genre preset such as `shooter` or
|
|
782
|
+
`vehicle-arena`, a contact shape and per-pair rules by tag, §17.11 *Contact policy*) needs no
|
|
783
|
+
`contacts` or `contactPass`: given neither, the session runs the declared policy the state carries
|
|
784
|
+
as both, in your prediction, the seat prediction, the physics replay and the drawing guard. The
|
|
785
|
+
authority host runs the same pass on the server after each tick's preset steps and before the
|
|
786
|
+
module's `step`, so the module writes no contact code at all (and must not run a pass of its own).
|
|
787
|
+
- **Letting a held body go.** A module that holds a body with `cap` 0 and lifts it unannounced lifts it for inputs on their
|
|
788
|
+
way, so a held stick lurches by about speed × round trip. Announce the tick (a countdown does anyway) and give the session a
|
|
789
|
+
`seatRule` that sets `cap` on it (§17.11 *Seat rules*): every seat starts on one tick, and no correction, measured on UDP.
|
|
790
|
+
- **Input repeat** is a room setting ([§17.11 *Input pacing*](./PROTOCOL.md#1711-physics-prediction),
|
|
791
|
+
*The setting*): `config.inputs: { preset?, repeat?, settleMs? }` (`settleMs`: §17.11 *A quiet seat*), `repeat` (`'on'` or `'off'`) as given, else
|
|
792
|
+
the genre preset's (`inputs.preset`, else `config.contacts.preset`), else on; `vehicle-arena` is off,
|
|
793
|
+
`shooter`, `platformer`, `party` and `physics-sandbox` on (`INPUT_PRESETS`). On, a starved seat's
|
|
794
|
+
held input is repeated and the session compares your prediction at
|
|
795
|
+
`reconcileThrough(held, ack, inRep, sent)` (`ack + inRep` once sent; past the inputs sent, a hitch,
|
|
796
|
+
it waits at the input it compared at last) and throttles after a starve; off, the room holds a starved seat
|
|
797
|
+
and the session reads the plain ack and never throttles. The session reads the room's setting from
|
|
798
|
+
the state's preset text (`presetInputRepeat`); a bad `config.inputs` is refused by its path.
|
|
799
|
+
- **Reconnection** is automatic and in place, as for `connect()`; `reconnecting`, `reconnected` and
|
|
800
|
+
`disconnect` fire as there. An input that came due while the socket was down is dropped, never
|
|
801
|
+
replayed. `leave()` during an outage still leaves: it stops reconnecting, and a retry already under
|
|
802
|
+
way leaves the moment it lands.
|
|
803
|
+
- **The fast channel** ([§17.12](./PROTOCOL.md#1712-the-fast-channel-fast1)), on by default: over UDP for Node
|
|
804
|
+
clients (bots, the netcode bench, tests) and over WebTransport in a browser, with nothing to pass.
|
|
805
|
+
`connectAuthority({ … })` asks for `fast@1`, `fast: false` turns it off,
|
|
806
|
+
and `openUdpFastSocket` from `@genex-ai/multiplayer/authority/fast-udp` is there to wrap. One line per join
|
|
807
|
+
names the transport and any fallback (`session.fast.line`; stderr, or `fastLog`). In a room that
|
|
808
|
+
offers it your inputs go by UDP datagram, each one in several (a lost datagram costs nothing), and the
|
|
809
|
+
room's motion comes back as per-seat ack-delta snapshots written into the same state a patch writes,
|
|
810
|
+
so prediction, reconcile and interpolation run unchanged; a WebSocket held up by a lost TCP segment
|
|
811
|
+
no longer holds them. Join, events and the rest of the state stay on the WebSocket. The seat moves only
|
|
812
|
+
once the path is proven, with the WebSocket carrying everything until then, so a network that blocks UDP
|
|
813
|
+
loses no input. With no snapshot 1 s after the first datagram or 2 s after the last, a binding it cannot
|
|
814
|
+
use, or the server's `__fast:off`, the seat goes on by WebSocket, every input sent stepped once and every
|
|
815
|
+
motion field at the WebSocket's latest value, and comes back by itself when the path does (retrying from
|
|
816
|
+
1 s up to every minute, with jitter). A seat with nothing to send keeps its channel with an empty datagram
|
|
817
|
+
every 250 ms, and a NAT rebinding or a Wi-Fi to mobile move keeps it on UDP. `s.fast` says `live`,
|
|
818
|
+
`snapshots`, `fallbacks`, `returns` and `fallback`: why the seat is on the WebSocket now (`no-webtransport`,
|
|
819
|
+
`no-relay`, `not-offered`, `unreachable`, `handshake`, `stall`, `server`). A room without the channel plays
|
|
820
|
+
exactly as before.
|
|
821
|
+
- **Lag compensation.** Every input tells the server what you saw: other entities `renderDelayMs`
|
|
822
|
+
(default 100) in the past, whether or not your own entity is predicted.
|
|
823
|
+
- **Every server message** also reaches `onEvent(type, data)` when you pass it, in order and with no
|
|
824
|
+
name withheld (`on(type)` skips the session's own event names) — for a facade forwarding them all.
|
|
825
|
+
- **Refusals** reject `connectAuthority` with the server's error; branch on `err.code` (`JoinError`):
|
|
826
|
+
402 the game's owner is not entitled to authority rooms (final; not retried), 424 no room module is
|
|
827
|
+
published, 429 the owner's room quota refused a new room, 426 this package is too old for the
|
|
828
|
+
server (update and republish). 503 (no capacity) is retried after 1, 2 and 4 s (`capacityRetryMs`).
|
|
829
|
+
|
|
830
|
+
A game's server code is one module typed by `@genex-ai/multiplayer/authority/module`, which has no
|
|
831
|
+
dependencies so it can be bundled alone:
|
|
832
|
+
|
|
833
|
+
```ts
|
|
834
|
+
import { defineRoom } from '@genex-ai/multiplayer/authority/module';
|
|
835
|
+
|
|
836
|
+
export default defineRoom({
|
|
837
|
+
contract: 1,
|
|
838
|
+
config: { tickHz: 30, preset: { name: 'topdown' } },
|
|
839
|
+
init: () => ({}),
|
|
840
|
+
onJoin: (ctx, player) => { ctx.entities.spawn({ kind: 'player', owner: player.id }); },
|
|
841
|
+
onLeave: (ctx, player) => { const e = ctx.entities.ownedBy(player.id); if (e) ctx.entities.remove(e.id); },
|
|
842
|
+
onInput: () => {},
|
|
843
|
+
step: () => {},
|
|
844
|
+
});
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
A `firstperson` room exports its collision world beside its config; the server validates it at init
|
|
848
|
+
and writes its hash into the resolved preset's `params.world`, and the game client passes the same
|
|
849
|
+
world to `setWorld`:
|
|
850
|
+
|
|
851
|
+
```ts
|
|
852
|
+
import { defineRoom } from '@genex-ai/multiplayer/authority/module';
|
|
853
|
+
import world from './world.json'; // { boxes: [[…6], …], ramps: [[…9], …] }, shared with the client
|
|
854
|
+
|
|
855
|
+
export default defineRoom({
|
|
856
|
+
contract: 1,
|
|
857
|
+
config: { tickHz: 60, preset: { name: 'firstperson' } },
|
|
858
|
+
world,
|
|
859
|
+
init: () => ({}),
|
|
860
|
+
onJoin: (ctx, player) => { ctx.entities.spawn({ kind: 'player', owner: player.id, z: 0 }); },
|
|
861
|
+
onLeave: (ctx, player) => { const e = ctx.entities.ownedBy(player.id); if (e) ctx.entities.remove(e.id); },
|
|
862
|
+
onInput: () => {},
|
|
863
|
+
step: () => {},
|
|
864
|
+
});
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
A `vehicle` room exports its world the same way, and refuels on a respawn:
|
|
868
|
+
|
|
869
|
+
```ts
|
|
870
|
+
import { defineRoom } from '@genex-ai/multiplayer/authority/module';
|
|
871
|
+
import world from './arena.json'; // the floor, walls and ramps, shared with the client
|
|
872
|
+
|
|
873
|
+
export default defineRoom({
|
|
874
|
+
contract: 1,
|
|
875
|
+
config: { tickHz: 60, preset: { name: 'vehicle' } },
|
|
876
|
+
world,
|
|
877
|
+
init: () => ({}),
|
|
878
|
+
onJoin: (ctx, player) => { ctx.entities.spawn({ kind: 'car', owner: player.id, z: 0, fuel: 1 }); },
|
|
879
|
+
onLeave: (ctx, player) => { const e = ctx.entities.ownedBy(player.id); if (e) ctx.entities.remove(e.id); },
|
|
880
|
+
onInput: () => {},
|
|
881
|
+
step: () => {},
|
|
882
|
+
});
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
### A level as a world: `@genex-ai/multiplayer/world-fit`
|
|
886
|
+
|
|
887
|
+
A world for `firstperson` or `vehicle` from a real level, at build time: `readGltfLevel(path)` reads a glb or glTF and
|
|
888
|
+
`sceneFromThree(scene)` (`@genex-ai/multiplayer/world-fit/three`, no Node in it) reads a three.js scene, both into the same scene
|
|
889
|
+
file ([WORLD-SCENE.md](WORLD-SCENE.md)) for `genex world fit`. Node names or `extras.genex` say what collides, on which channel and
|
|
890
|
+
where a spawn is; a file is hostile input and a refusal names what to change: [WORLD-GLTF.md](WORLD-GLTF.md).
|
|
891
|
+
|
|
608
892
|
## Rules of thumb
|
|
609
893
|
|
|
610
894
|
- **`me` / `shared` are synced; everything else is local.** Movement input → mutate a local object → `me.set` it on the tick.
|
package/WORLD-GLTF.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# A level from a glb, a glTF or a three.js scene
|
|
2
|
+
|
|
3
|
+
Two doors to the same [scene file](WORLD-SCENE.md), so a level from any engine with a glTF exporter reaches `genex world fit`:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
genex world gltf level.glb level.scene.json # no flags: a level says what it needs in its own extras
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { sceneFromThree } from "@genex-ai/multiplayer/world-fit/three";
|
|
11
|
+
const { json, notes } = sceneFromThree(gltf, { scale: 39.37 }); // a THREE.Scene, an Object3D or what GLTFLoader gave you
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
One level gives one scene by either door: `packages/netcode-bench/test/authority/world-gltf-three.test.ts` holds a level
|
|
15
|
+
through `GLTFLoader` and `sceneFromThree` to the reader's scene, value for value. Same file, same bytes and hash on Mac, Linux
|
|
16
|
+
and Windows (`world-gltf.yml`). Library: `readGltfLevel(path)` and `runWorldGltf(args)` in `@genex-ai/multiplayer/world-fit`. `genex world gltf` exits 0, 1 on a call
|
|
17
|
+
that is not one and 3 on a refused level, as `genex world fit` does.
|
|
18
|
+
|
|
19
|
+
## Space and units
|
|
20
|
+
A glTF is metres, y up, right-handed. A three.js scene is y up too. Everything comes out in the room's axes, z up, handedness
|
|
21
|
+
kept: drawing space `(X, Y, Z)` is the room's `(X, −Z, Y)`, the same turn as drawing a room's body at `(x, z, −y)`. Coordinates land
|
|
22
|
+
on a grid of 1/256 of a unit, then float32, so no path to a number shows.
|
|
23
|
+
|
|
24
|
+
- **The reader** scales metres by `unitsPerMeter`, 39.37 (Source units) unless the glTF scene's `extras.genex.unitsPerMeter` says
|
|
25
|
+
otherwise. A game in metres sets it to 1.
|
|
26
|
+
- **The helper** scales by its `scale` option, default 1: a game that draws the server's numbers as they are. A model loaded in
|
|
27
|
+
metres and not scaled in the scene is `{ scale: 39.37 }`; a model with `model.scale.setScalar(39.37)` is already there.
|
|
28
|
+
- **Hull**: `extras.genex.hull` on the scene (`radius`, `height`, `crouchHeight`, `step`, `jump`, `leap`, room units). Left out, the
|
|
29
|
+
fit sizes the Source defaults from `unitsPerMeter`. Make it your room's own parameters if they differ.
|
|
30
|
+
|
|
31
|
+
## Marks
|
|
32
|
+
A mesh collides, on both channels, unless it is marked. Any exporter can say all of these in a node's name; Blender, three.js and
|
|
33
|
+
anything that writes custom properties can say them in `extras.genex` (`userData.genex` in three.js), and those win. A parent passes
|
|
34
|
+
its mark to what hangs under it; a child's own mark wins.
|
|
35
|
+
|
|
36
|
+
| Name starts with | `extras.genex` | Meaning |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| | | Collides: stops a hull and a bullet |
|
|
39
|
+
| `nocol_` | `{ "collide": "none" }` | Only drawn: art beside a collision proxy, a sky dome, a banner, a character |
|
|
40
|
+
| `clip_` | `{ "collide": "move" }` | Clip wall: stops a hull, a bullet passes |
|
|
41
|
+
| `shot_` | `{ "collide": "shot" }` | Stops a bullet, a hull passes |
|
|
42
|
+
| `box_` | `{ "shape": "box" }` | Its bounds as one box |
|
|
43
|
+
| `hull_` | `{ "shape": "hull" }` | The convex hull of its points, up to 4,096 |
|
|
44
|
+
| | `{ "shape": "mesh" }` | Triangles, no box looked for |
|
|
45
|
+
| `movable_` | `{ "movable": true }` | Moves or disappears at run time: named in the fit's report, never in the world |
|
|
46
|
+
| `prop_` | `{ "prop": true }` | May be cut when a budget runs out |
|
|
47
|
+
| `spawn_` | `{ "spawn": true }` | Where a player starts: the node's origin, on or just over the floor. Its own mesh does not collide. `spawn_Red_1` is the spawn `Red_1` |
|
|
48
|
+
| | `{ "tags": ["red"] }` | Carried to the actor or spawn for the game |
|
|
49
|
+
|
|
50
|
+
Prefixes stack in any case: `CLIP_box_Fence.001`. A misspelled mark is refused rather than ignored.
|
|
51
|
+
Hidden objects still collide: that is what a collision proxy usually is. Mark what is only for show.
|
|
52
|
+
|
|
53
|
+
What else it works out, so an exporter never has to:
|
|
54
|
+
- **A closed cube is an exact box**, on the world's axes or turned, even inside a merged mesh, and under negative scale. A cube
|
|
55
|
+
sheared by a non-uniform parent is the exact hull of its corners. A cube flattened by a scale of zero is the plane left.
|
|
56
|
+
- **A node an animation moves is movable**, with what hangs under it. In three.js, pass the loaded `gltf`, not `gltf.scene`, so it can see the animations.
|
|
57
|
+
- **An instanced mesh is every instance** (`EXT_mesh_gpu_instancing`, `THREE.InstancedMesh`). **An LOD is its nearest level.**
|
|
58
|
+
- **Quantized positions** (`KHR_mesh_quantization`) read as the numbers they mean. **Strips and fans** are triangles; points and lines are no surface.
|
|
59
|
+
|
|
60
|
+
## Refusals
|
|
61
|
+
A file is hostile input. Every refusal is a `LevelRefusal` with a `code` and a message naming the node or field and what to do; the
|
|
62
|
+
scene is not written and an existing one stays. The reader's table is `packages/multiplayer/test/world-gltf-hostile.test.ts`.
|
|
63
|
+
|
|
64
|
+
| Code | What it says |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `not_gltf`, `bad_container`, `bad_version` | Not a glb or glTF 2; chunks that do not add up |
|
|
67
|
+
| `bad_reference`, `bad_accessor`, `bad_index`, `bad_value`, `not_finite` | An index naming nothing; an accessor that does not fit or is sparse; an index past its vertices; a bad transform; NaN or infinity |
|
|
68
|
+
| `node_cycle` | A cycle, or a node with two parents |
|
|
69
|
+
| `too_large` | Past a limit below |
|
|
70
|
+
| `compressed` | Draco (`KHR_draco_mesh_compression`) or meshopt (`EXT_meshopt_compression`, `KHR_meshopt_compression`) on something that collides. Not decoded here: re-export without it, or load it in three.js and use `sceneFromThree`, whose loader decodes it. Compressed art marked `nocol_` is no problem |
|
|
71
|
+
| `unknown_extension` | A required extension this cannot honour, by name |
|
|
72
|
+
| `uri_refused` | A buffer URI that leaves the input's folder or is not a plain file: a parent segment, an absolute path, a scheme, a host, a backslash, an encoded trick, a link leading outside (by realpath), a FIFO. Nothing outside the folder is read and nothing touches the network. Image URIs are never read at all |
|
|
73
|
+
| `skinned`, `morphed` | A skinned or morph-posed mesh that collides: a world is static. Mark it `nocol_` beside a static proxy, or `movable_` |
|
|
74
|
+
| `unsupported` | A `THREE.BatchedMesh` that collides |
|
|
75
|
+
| `degenerate` | A mesh with no triangle of any area left. A few among real ones are dropped and counted in `notes` |
|
|
76
|
+
| `non_manifold` | More than 64 triangles on one edge, which no surface has. Ordinary non-manifold edges are kept and counted in `notes`: the scene format reads a surface, open or closed |
|
|
77
|
+
| `no_spawn`, `no_surface`, `bad_mark` | What it says |
|
|
78
|
+
|
|
79
|
+
Limits, each checked before anything is decoded for it: 1 GiB a file or buffer and for all buffers together, 8,000,000 triangles with
|
|
80
|
+
instances counted, 24,000,000 vertices an accessor, 1,000,000 nodes, depth 1,024. Past them, give what no one walks on a `nocol_` mark
|
|
81
|
+
or a collision proxy.
|
|
82
|
+
|
|
83
|
+
## The whole path
|
|
84
|
+
```sh
|
|
85
|
+
genex world gltf level.glb level.scene.json # or write sceneFromThree's json to that file
|
|
86
|
+
genex world fit level.scene.json world.json # the §17.7 world, and what it cost and changed
|
|
87
|
+
```
|
|
88
|
+
Then the world is one file both sides import: `defineRoom({ …, world })` in the module, `connectAuthority({ …, world })` on the
|
|
89
|
+
client. Draw the glb as it is, in the same units: the world is made from it, not drawn. A spawn from the scene is where `onJoin` puts a seat.
|
|
90
|
+
|
|
91
|
+
`packages/netcode-bench/demo/level-walk/` is this path on a real page: `courtyard.glb` (`conformance/authority/world-scene/`,
|
|
92
|
+
written by its `courtyard.reference.mjs`), fitted, in a local `genex-authority-dev` room, two three.js seats and a Rust seat walking it.
|
|
93
|
+
`packages/netcode-bench/test/authority/world-gltf-route.test.ts` walks the same routes with no browser.
|
|
94
|
+
|
|
95
|
+
### three.js in a game
|
|
96
|
+
Run the helper wherever the level already exists. A Node build script needs no browser: `new GLTFLoader().parse(arrayBuffer, "", …)`
|
|
97
|
+
for a glb with embedded buffers, or a `Scene` built in code. In a page, hand a creator the file:
|
|
98
|
+
```ts
|
|
99
|
+
const url = URL.createObjectURL(new Blob([sceneFromThree(gltf, { scale: 39.37 }).json], { type: "application/json" }));
|
|
100
|
+
```
|
|
101
|
+
Do this once, at build time, and import what the fit wrote: never fit or re-export in a player's page, or one float's difference
|
|
102
|
+
turns prediction off (`world_mismatch`).
|
|
103
|
+
|
|
104
|
+
### Rust, or any engine with a glTF exporter
|
|
105
|
+
1. Export the level as a glb in metres, with the marks above. Blender: *Include > Custom Properties* writes `extras`; names are always kept.
|
|
106
|
+
2. `genex world gltf level.glb level.scene.json`, then `genex world fit level.scene.json world.json`.
|
|
107
|
+
3. Rust: `FirstpersonWorld::from_json_str(include_str!("../assets/world.json"))` and `session.set_world(world)`; the module exports the
|
|
108
|
+
same file. Draw with `engine = (g.x, g.z, −g.y)`. `sdks/rust/genex-multiplayer/examples/level_walk.rs` does this against a room:
|
|
109
|
+
hash equal to the room's, predicted, no correction. A game in metres sets `unitsPerMeter` to 1 and every distance in its params.
|
package/WORLD-SCENE.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# The world scene format
|
|
2
|
+
|
|
3
|
+
What an engine's exporter writes and `genex world fit` reads: a level as collision surfaces, in world space, with no
|
|
4
|
+
engine in it. Reader and checks: `src/world-fit/scene.ts` (`parseWorldScene`); a small example every exporter can
|
|
5
|
+
compare with: `../netcode-bench/conformance/authority/world-scene/sample.json`. A glb, a glTF or a three.js scene becomes this
|
|
6
|
+
file with no exporter to write: [WORLD-GLTF.md](WORLD-GLTF.md).
|
|
7
|
+
|
|
8
|
+
Exporters: Unity (`sdks/unity/README.md`, *From a level to a world*).
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"format": "genex-world-scene",
|
|
13
|
+
"version": 1,
|
|
14
|
+
"unitsPerMeter": 39.37,
|
|
15
|
+
"source": { "engine": "unity", "level": "Yard" },
|
|
16
|
+
"hull": { "radius": 16, "height": 72, "crouchHeight": 54, "step": 18, "jump": 57, "leap": 189 },
|
|
17
|
+
"actors": [
|
|
18
|
+
{ "name": "Ground", "surfaces": [
|
|
19
|
+
{ "kind": "box", "center": [0, 0, -16], "half": [2000, 2000, 16] } ] },
|
|
20
|
+
{ "name": "Fence_3", "tags": ["fence"], "surfaces": [
|
|
21
|
+
{ "kind": "box", "center": [100, 40, 44], "axes": [[0.8, 0.6, 0], [-0.6, 0.8, 0], [0, 0, 1]], "half": [32, 2, 44],
|
|
22
|
+
"channels": ["move"] } ] },
|
|
23
|
+
{ "name": "Rock_1", "tags": ["prop"], "surfaces": [
|
|
24
|
+
{ "kind": "mesh", "vertices": [0, 0, 0, 90, 0, 0, 0, 90, 0, 0, 0, 60], "indices": [0, 1, 2, 0, 1, 3, 1, 2, 3, 2, 0, 3] } ] }
|
|
25
|
+
],
|
|
26
|
+
"spawns": [ { "at": [0, 0, 8], "name": "Red_1", "tags": ["red"] } ]
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Space
|
|
31
|
+
- World space, the room's units, the same axes the game hands the step: **z up**, x and y as §17.7 moves them. An engine
|
|
32
|
+
with y up converts here, the way it converts everything else it sends the room.
|
|
33
|
+
- `unitsPerMeter` says what a unit is. It sizes the report's areas and, when `hull` is left out, scales the Source
|
|
34
|
+
defaults. It rescales nothing else.
|
|
35
|
+
- `hull` is the room's own hull: `radius`, `height` (stand), `crouchHeight`, `step`, `jump` (how high a standing jump
|
|
36
|
+
lifts the feet, `jumpImpulse² / (2·gravity)`) and `leap` (how far a run jump carries, `maxSpeed · 2·jumpImpulse / gravity`).
|
|
37
|
+
Every tolerance the fit uses comes from these; a scene has no tuning values to set.
|
|
38
|
+
|
|
39
|
+
## Actors and surfaces
|
|
40
|
+
An actor is what a creator knows by name: a refusal says `Wreck_12`. Write the engine's own name, a path when names repeat.
|
|
41
|
+
Each surface is one of:
|
|
42
|
+
|
|
43
|
+
| `kind` | Fields | Meaning |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `box` | `center`, `half`, `axes?` | An oriented box. `axes` are three orthonormal rows; left out means the world's. A box on the axes stays exact in the fit |
|
|
46
|
+
| `hull` | `points` | The convex hull of up to 4,096 points |
|
|
47
|
+
| `sphere` | `center`, `radius` | |
|
|
48
|
+
| `capsule` | `a`, `b`, `radius` | Any direction |
|
|
49
|
+
| `mesh` | `vertices`, `indices` | Triangles: x y z per vertex, three indices per triangle, wound either way, open or closed |
|
|
50
|
+
| `heightfield` | `origin`, `cell`, `cols`, `rows`, `heights` | `heights[row·cols + col]` at `origin + (col·cell[0], row·cell[1])`, solid below. `null` (NaN in binary) is a hole |
|
|
51
|
+
|
|
52
|
+
Big arrays may be `{ "f32le": "<base64>" }` (`vertices`, `heights`) and `{ "u32le": "<base64>" }` (`indices`).
|
|
53
|
+
|
|
54
|
+
Write everything that collides: the fit decides what a hull can reach and drops the rest, so an exporter never prunes.
|
|
55
|
+
|
|
56
|
+
## Channels
|
|
57
|
+
`channels` on a surface says which traces it answers, and left out means both. `move` stops a hull. `shot` stops a bullet.
|
|
58
|
+
|
|
59
|
+
| Engine says | Write |
|
|
60
|
+
|---|---|
|
|
61
|
+
| Blocks pawns and traces | leave it out |
|
|
62
|
+
| Clip wall: blocks pawns, not traces | `["move"]` |
|
|
63
|
+
| Shot-only mesh: bullets stop, characters pass | `["shot"]` |
|
|
64
|
+
| Neither | leave the surface out |
|
|
65
|
+
|
|
66
|
+
`genex world fit` reads `move`, `genex world shots` reads `shot`, from the same file.
|
|
67
|
+
|
|
68
|
+
## Where a level ends
|
|
69
|
+
`bound` is optional: `{ "min": [x, y], "max": [x, y], "ceiling": z }`, `ceiling` optional. Write it when the level is meant to stop
|
|
70
|
+
somewhere its geometry alone doesn't stop a hull. The fit adds solid of its own, `move` only, named `Bound` in the report and counted
|
|
71
|
+
in the budget: walls outside those edges, tall enough that nothing in the level lifts a hull over, and a lid whose underside is
|
|
72
|
+
at `ceiling`, so a hull's head stays under it. A spawn outside the bound or over the ceiling is refused at its path. Leave it out and
|
|
73
|
+
the level's reach is whatever its geometry gives; the fit doesn't guess where a creator meant it to end.
|
|
74
|
+
|
|
75
|
+
## Tags
|
|
76
|
+
Free strings on an actor or a surface, carried for the game. The fit reads two:
|
|
77
|
+
- `movable`: moves or disappears at run time. Never in a static world; the report names it.
|
|
78
|
+
- `prop`: may be cut when a budget runs out, as one box a surface, before anything else that costs reach. An exporter tags
|
|
79
|
+
only what its engine or creator already calls a prop: an actor no wider than eight hull radii counts as one with no tag.
|
|
80
|
+
|
|
81
|
+
## The shot world
|
|
82
|
+
`genex world shots <scene.json> <shots.json> [--max-boxes <n>] [--max-bytes <n>] [--report <file>] [--json]` writes what a module
|
|
83
|
+
exports as `shotWorld`, `{ boxes, ramps, tolerance }`, or exits 3 and writes none. `fitShotWorld` is the same thing in code. It keeps
|
|
84
|
+
what a muzzle can see from where a hull can be: a surface no hull reaches and a line reaches stays, one no line reaches goes, and that
|
|
85
|
+
is sound on the lattice. A straight line moves one way on each axis, so it stays in the flood of its own octant, which from a yard open
|
|
86
|
+
to the sky does not come back down outside the wall. A source box on the axes stays one box. Anything else gets boxes cut round the
|
|
87
|
+
surface they hold, so a stop is off by less than a cell. The cell is the finest of a ladder (a quarter of the hull's radius, doubling) the
|
|
88
|
+
budget holds. Boxes only: no ramps yet. The report reads rays from where a hull stands, against the source's own triangles: `accuracy`
|
|
89
|
+
(hit or miss, along the ray, and `surface`, how far the fit's stops are from a real surface: along a ray a near miss of an edge
|
|
90
|
+
reads as the whole way to the next wall) and `claims`, what a tolerance is worth by the rule of `ctx.world.claim`: honest claims refused or
|
|
91
|
+
capped, and how far past a real surface the most a claim may say reaches. The world's `tolerance` is `surface` at the 99th percentile.
|
|
92
|
+
It names what moves at run time and what no muzzle sees. It is not fed to any client.
|
|
93
|
+
|
|
94
|
+
## What `genex world fit` reads and says
|
|
95
|
+
`genex world fit <scene.json> <world.json> [--max-boxes <n>] [--max-ramps <n>] [--report <file>] [--json]` writes the §17.7
|
|
96
|
+
world, or exits 3 and writes none. `@genex-ai/multiplayer/world-fit`'s `fitScene` is the same thing in code. The report gives
|
|
97
|
+
reach lost and extra against the source, how far floors really are from the source's (`floor`: the highest surface under a
|
|
98
|
+
hull's whole footprint, with no cell rounding, at the lattice's points, with the worst ones named), how far walls may recede, how far the reach runs past the level (`reachExtent`, below), the costliest
|
|
99
|
+
actors and, on a refusal, what each would lose as one box. What it models: a square hull walking, stepping, dropping, jumping, crouch jumping and leaping off
|
|
100
|
+
edges. What it does not: a slope a room can't stand on reads as walkable if its steps are, up to a steep one. Steeper, a column a cell
|
|
101
|
+
wide with a face rising a crouching hull's height or more beside it and falling two cells or more on its other side is no floor:
|
|
102
|
+
closed up the face's height and counted in `leanLedges`, so a tall leaning wall is its outer envelope, as wide at its top as at its
|
|
103
|
+
widest, and a true lip under a cell wide goes the same way. Held to leans up to 12 degrees off the vertical; past that a hull can still
|
|
104
|
+
stand on some of a face, because its lips can be a step apart. The floor figures skip the part of that top past the real wall. A door a hull clears by under a
|
|
105
|
+
cell is found on a finer lattice of its own, down to a thirty-second of a cell: open between exact boxes, shut and named in
|
|
106
|
+
`narrowDoorsShut` between walls the fit can't keep exact. The rule for the other way: a gap too narrow for the
|
|
107
|
+
hull in the source never opens, at any rung, and stays shut by `gapMarginUnits`, an eighth of a cell on the radius.
|
|
108
|
+
|
|
109
|
+
Floors win over budget. Where a coarser rung leaves feet past a radius from the source's floor, repair rounds spend boxes there; if
|
|
110
|
+
feet stay past a radius at worst or half a radius at the 99th percentile, the fit refuses with `floor_over`, writes no world, and
|
|
111
|
+
names who costs the most, as `over_budget` does. `open_slits` is the same for a gap left open: a report with either is never `ok`.
|
|
112
|
+
|
|
113
|
+
What the floor numbers say, in the scene's units, all against the same reading of the highest surface under a hull's footprint:
|
|
114
|
+
|
|
115
|
+
| Field | What it is | What can pass it |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `floor.p99Units`, `floor.maxUnits` | Source against fit at the lattice's points. These are what the fit holds itself to: half a radius and a radius, or `floor_over` | A real hull rests between lattice points, so a seat's feet can read more than `maxUnits` at an edge, resting on a ledge by a hair |
|
|
118
|
+
| `floor.upperBoundUnits` | The same with the footprint half a cell wider and narrower, which holds every position between the points | Nothing: a seat's feet stay under it. It is a ledge's height wherever a footprint can overlap a ledge by a hair more in one world than the other, so it says little of how a level looks |
|
|
119
|
+
| `floor.edgeFreeUnits` | How far apart the two ranges of height are: nothing if a shift of half a cell can make them read alike | |
|
|
120
|
+
|
|
121
|
+
So compare a seat's worst feet with `upperBoundUnits`, and its 99th percentile with `p99Units`. A seat over `maxUnits` at an edge rest
|
|
122
|
+
is not a broken fit; a seat over `upperBoundUnits` is.
|
|
123
|
+
|
|
124
|
+
`reachExtent` says how far the source's reach runs past what the level holds, and is measured whether or not anything is wrong:
|
|
125
|
+
`coreMin` and `coreMax` are the plan of the spawns and every actor but flat ground as wide as the level; `outsideCoreM2` and
|
|
126
|
+
`outsideCorePct` the reach further than a run jump past that, `farthestFromCoreM` how far it gets; `walkedM2` what a hull gets to from
|
|
127
|
+
a spawn with no jump, `beyondWalkPct` the share of the reach only a jump gets to, and `outsideWalkedPct` the share further than a run
|
|
128
|
+
jump past the plan of what walking gets to. `flagged` is true at 25% of either `outsideCorePct` or `outsideWalkedPct`, with no `bound`.
|
|
129
|
+
It never refuses: whether the reach is what the creator meant is theirs to say, with a `bound`.
|
|
130
|
+
|
|
131
|
+
Spawns are where the hull's reach starts: `at`, with optional `name` and `tags`. A level with no spawn cannot be fitted.
|
|
132
|
+
|
|
133
|
+
## Compatibility
|
|
134
|
+
`version` is checked and a new meaning is a new version. A reader refuses a bad value with its path (`WorldSceneError`,
|
|
135
|
+
`$.actors[3].surfaces[0].half[1] is below zero`).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { F as FastSocketOpener } from '../fast-client--A88q0kk.js';
|
|
2
|
+
import '../module-CSpXrzFc.js';
|
|
3
|
+
import '../shot-world-D22tqOUT.js';
|
|
4
|
+
|
|
5
|
+
/** The host (an IPv6 host unbracketed) and port of a `udp://host:port` url, else null: the url is
|
|
6
|
+
* server-supplied, so anything more than a host and a port in 1..65535 is refused. */
|
|
7
|
+
declare function udpEndpoint(url: string): {
|
|
8
|
+
host: string;
|
|
9
|
+
port: number;
|
|
10
|
+
} | null;
|
|
11
|
+
|
|
12
|
+
/** A {@link FastSocketOpener} for `udp://` urls on node:dgram; null for any other url. */
|
|
13
|
+
declare const openUdpFastSocket: FastSocketOpener;
|
|
14
|
+
|
|
15
|
+
export { openUdpFastSocket, udpEndpoint };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
udpEndpoint,
|
|
3
|
+
udpFastOpener
|
|
4
|
+
} from "../chunk-YWQ3ALYY.js";
|
|
5
|
+
|
|
6
|
+
// src/authority/fast-udp.ts
|
|
7
|
+
import { createSocket } from "dgram";
|
|
8
|
+
import { lookup } from "dns";
|
|
9
|
+
import { isIP } from "net";
|
|
10
|
+
var openUdpFastSocket = udpFastOpener({ createSocket, lookup, isIP });
|
|
11
|
+
export {
|
|
12
|
+
openUdpFastSocket,
|
|
13
|
+
udpEndpoint
|
|
14
|
+
};
|