@jgengine/ws 0.17.0 → 0.18.1

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/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- The lockstep game SDK set is `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`
3
+ The lockstep game SDK set is `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets}`
4
4
  (this file’s 0.x.y releases). The CLI package `jgengine` and `@jgengine/github` use
5
5
  **separate** version lines and may lag. Format follows [Keep a Changelog](https://keepachangelog.com);
6
6
  each release **leads with a Migrate block** — the concrete steps to move a game
@@ -17,6 +17,13 @@ between (`--json` for structured output).
17
17
 
18
18
  ## [Unreleased]
19
19
 
20
+ ### Fixed
21
+
22
+ - Separate SDK publication validation from external Games quality checks while retaining the full local and main-branch gates.
23
+
24
+ - Correct foot-IK endpoint recomputation after hip rotation; verify reachable targets and clamping without changing bone lengths.
25
+ - Restore release CI prerequisites and make catalog-loader tests independent of the external Games checkout.
26
+
20
27
  <!--
21
28
  Every PR that changes `packages/*/src` records its consumer-facing change here, so
22
29
  the next release's notes are complete by construction. Add a bullet under the right
@@ -26,14 +33,130 @@ At publish, rename this heading to the new version and mirror the entries into
26
33
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
34
  -->
28
35
 
36
+ ## 0.18.1
37
+
38
+ ### Migrate
39
+
40
+ - **Bump lockstep SDK packages to `^0.18.1`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.1`; `@jgengine/github` is `0.5.1`.
41
+ - Declare `topology: "shared"` on runtime definitions. Shared Convex hosts migrate the bounded legacy roster into indexed membership and per-player session rows; use `getServerCapacity` for stable lobby subscriptions.
42
+ - Put every command's read scope on its definition. Omitted scopes now load the actor and no chunks; explicitly request whole-world reads only where needed. Trusted host mutations can compose writes through `helpers.runCommand`.
43
+ - Handle typed join outcomes (`ok` and `reason`) or use `useServerSession` and `JoinGate`. Pass runtimes to `jgengineCronSpecs`; tick registration requires an actual tick hook.
44
+ - Declare currency precision with `decimals` and remove game-side cent conversions. Public amounts stay in major units and wallet writes round through integer minor units.
45
+ - Replace custom presence, chat, territory and tick batching with `createWorldPresenceStore`, chat helpers, chunk territory, and `forEachOnlinePlayer`. Legacy claim backfills must finish before removing their old schema.
46
+
47
+ ### Added
48
+
49
+ - Indexed shared membership and capacity rows, bounded neighborhood poses with 100 ms server/client gates, durable rate limits, authenticated diagnostics and bounded retention.
50
+ - Territory footprint planning and atomic placement claims, chat validation and headless chat/join/territory surfaces, elapsed accrual, safe quantities, and player-profile reset through the initializer.
51
+ - An explicit `--patch` release option.
52
+
53
+ ### Added
54
+
55
+ - Added smooth interpolation for remote presence player poses.
56
+ - WS ping/pong messages and client RTT sampling on the backend object.
57
+ - Added skin-backed HUD chrome variables, font loading, and the procedural `HudSkinPreview` fixture.
58
+
59
+ - Core gamepad snapshot model, deadzone/curve resolver, and controller glyph labels.
60
+ - Shell gamepad source polls connected pads, merges analog actions, supports synthetic `?gamepad=1` input, and exposes `ctx.input.rumble`.
61
+ - Core action context stacks layer gameplay, menus, and passthrough overlays with snapshot/restore; shell action tracking now respects the active context.
62
+ - Shell audio now supports listener orientation and per-sound Web Audio panners. `setListenerPose` accepts `{ position, forward, up }`; bare positions remain supported for one release.
63
+ - Core perception tracks deterministic sight, hearing, occlusion, and bounded observation memory for AI.
64
+ - `syncWorldColliders` now keeps terrain and static object colliders synchronized with a physics backend.
65
+ - Core decision graphs provide deterministic selector, sequence, condition, action, and utility runtimes with snapshot/restore.
66
+ - Core behavior descriptors can run registered decision graphs at the interest-scheduler cadence.
67
+ - Core navigation meshes provide deterministic polygon routing, off-mesh links, closest-point queries, and nav raycasts.
68
+ - WS sessions now issue resume tickets and retain disconnected memberships for a 15-second grace window, allowing reconnects to rejoin without replacing player state.
69
+ - Shell entity sprites can play atlas-backed sprite clips while preserving raw texture sprites.
70
+ - Tilemap layers can render atlas-backed textured tiles as one instanced quad mesh with parallax.
71
+ - Core pixel-perfect orthographic frustum and pixel-grid camera snapping are available to shell cameras.
72
+ - Core sprite atlas adapters and deterministic 2D sprite clip playback primitives.
73
+ - `src/art-direction.md` scaffold and `check-art-direction` gate for created games.
74
+ - Host-authoritative shell sessions now expose each accepted world frame to the local prediction buffer, reconciling and snapping the possessed pose only when drift exceeds its threshold.
75
+ - Shared GLTF loader configuration for Draco and KTX2-compressed assets, with CDN defaults and renderer capability detection.
76
+ - Material map overrides now support metalness, emissive, and height maps on standard and physical materials.
77
+ - Presentation and editor documents now accept gradient, HDRI/EXR, and cube-map environment sources.
78
+ - Authored `light` markers now feed bounded point/spot punctual lights with an eight-light default budget and editor warning.
79
+ - Graphics profiles now resolve per-tier render scale, shadow budgets, culling distance, particles, cascades, and post stages; games can override them with `defineGame({ graphics })`.
80
+ - `defineGame({ physics: { backend } })` now adopts a capsule character controller for shell/headless movement and registers the backend simulation after movement, including floor collision and step-up behavior.
81
+ - Added the `physics-probe` dev scene for Rapier crate, ragdoll, and vehicle motion.
82
+ - Assets now expose import-spec validation and magic-byte classification for supported source files.
83
+ - Hosted WS sessions now support spectator joins, viewer roles, throttled low-priority snapshot modules, and default command rate limits.
84
+ - `@jgengine/navbake` bakes deterministic navigation meshes and editor documents can store named bake data.
85
+ - Core tactical queries derive cover points and score bounded candidate positions.
86
+ - Added createSnapshotBuffer for delayed snapshot interpolation.
87
+
29
88
  ### Migrate
30
89
 
90
+ - `AudioEngine.setListenerPose` now accepts `{ position, forward, up }`. Bare `Vec3` positions remain compatible for one release and use forward `[0, 0, -1]` with up `[0, 1, 0]`.
91
+ - **Hosted world stores are asynchronous.** Implement `HostedWorldStore.load()` and `save()` as `Promise`-returning methods and construct persisted sessions with `createHostedWorldSessionAsync`; synchronous tests can keep using `memoryWorldStore`.
92
+ - **Look presets now default to `neutral`.** Games that relied on the previous implicit cinematic
93
+ sky/post stack should set `look: "photoreal"` (or keep `look: "cinematic"`) explicitly.
94
+
95
+ - **`ServerTickPlan.due` is `{ id, runs }[]` instead of a repeated id list.** `planServerTick` repeated a system id once per missed interval, and the obvious way to consume that — `due.includes(id)` or a `Set` — ran the system once while the returned anchor had already advanced by every repeat, silently dropping the catch-up work. A host now reads `runs` (or `tickRunCount(plan, id)`) and loops that many times.
96
+ - **`CommandDef.validate` / `CommandDef.apply` take a fourth argument, `nowMs`.** Existing implementations that ignore it are unaffected. Commands that took a `now` in their `input` should read this instead — the client supplied the old one.
97
+
31
98
  ### Changed
32
99
 
100
+ - Hosted game runner queues authoritative inputs by simulation tick and applies the in-force frame each fixed step.
101
+ - Create scaffolds now use a 60 Hz simulation with interpolation enabled by default.
102
+
103
+ - Rigged `ModelConfig` entries can opt into shell foot IK with named thigh/shin/foot bones; the driver raycasts the shared scene terrain, solves each chain, and fades corrections while airborne.
104
+
105
+ - Animation graph clips may carry root-bone position tracks; states with `rootMotion: true` return the sampled displacement and the shell applies it to the entity while restoring the root bone's bind translation.
106
+
107
+ - Added renderer-free two-bone, FABRIK, and look-at inverse-kinematics solvers to `@jgengine/core`.
108
+
109
+ - Shell model animation now routes states and one-shots through the shared animation graph runtime.
110
+
111
+ - CI now clones the external games repository with `GAMES_CLONE_TOKEN` and runs the game shape, content, front-end, art-direction, and feel gates against it; those gates fail loudly in CI when the checkout is missing while remaining no-ops locally.
112
+
113
+ - `jgengine create` now accepts `--player`, `--ground`, and `--scene` so generated games can choose their model, terrain ground, and empty or starter scene.
114
+
115
+ - **Directional shadow frustum config actually reaches the GPU.** `shadowCameraSize` on a single (non-cascaded) shadow light was silently inert: the shell wrote the bounds through R3F pierced props and nothing refreshed the shadow camera's projection matrix, so three rendered depth with its default ~10-unit box at the world origin — the reason no open-world game had cast shadows away from spawn. The shell now syncs the projection (and reallocates the shadow map when `shadowMapSize` changes). `bloom.threshold`'s doc also now states it measures raw HDR before the exposure stage.
116
+ - **CSM (`cascades > 1`) no longer over-lights streamed meshes or clobbers material shaders.** Cascade scoping is injected per material, but the scan ran every 30 frames, so a model that streamed in rendered lit by every cascade light at once (a cascades× sun) until the next scan — a sub-second flash on desktop, the whole shot on capture rigs. The scan now runs every frame behind a WeakSet, and `setupMaterial` chains with a material's own `onBeforeCompile` (rim light, detail maps) instead of replacing it.
117
+
118
+ - **The host's wall clock reaches the runtime hooks.** `RuntimeInitContext` (so `onInit`, `onNewPlayer`, and `onTick` alike) carries `nowMs`, the host timestamp for that call. Anything keyed to real time — a UTC date rollover, a `lastTickAt` anchor read by non-tick code, an RNG seed or `createdAt` stamp — reads it rather than accumulating `dtSeconds` against an epoch persisted in the snapshot, which drifts by whatever the tick loses to stalls and `maxCatchUp` clamping. `GameRuntime.hydrate` takes an optional `nowMs`; `tick`, `joinPlayer`, and `runCommand` take an optional trailing `nowMs`. All default to `Date.now()`, and the shipped WS and Convex hosts pass the timestamp they already computed to plan the tick.
119
+ - **Imported models cast and receive shadows.** `cloneModelScene` never set `castShadow`/`receiveShadow` on a GLB's meshes (only the magenta fallback box had them), so every catalog model in every game rendered shadowless however the lights were configured. Cloned models now default to `ModelConfig.shadows: "both"`; set `"cast"`, `"receive"`, or `"none"` per model (foliage cards, decals, viewmodels).
120
+ - **`jgengine create` installs `game-design` and `jgengine-ui` by default.** The minimal skill set was intake, editor, and verify, so a real project never saw the greenfield design contract or the UI art-direction template unless someone ran `jgengine skills --all`; `MINIMAL_GAME_SKILLS` now carries both.
121
+
33
122
  ### Added
34
123
 
124
+ - **Host-authoritative world replication over WS.** Hosted sessions expose revision-cursor pulls, so server subscribers receive compact world diffs and automatically recover with a fresh baseline after missed revisions; viewer-specific projections still receive per-viewer baselines.
125
+ - **Animation graph — `ModelAnimationConfig.graph` and `@jgengine/core/anim/animGraph`.** A serializable, layered animation state machine: `clip`, `blend1D`, and `blend2D` states, transitions as data (parameter comparisons, consumed triggers, exit times, crossfade durations), masked and additive layers, and clip events. `createAnimGraphRuntime` is headless and owns every clip's playback time and weight, so the shell's mixer only seeks and weights actions (`useModelAnimation` takes the graph path when `graph` is set) and a headless host or test advances the same graph without three.js. The shell feeds the entity's smoothed ground speed as `speed`, merges parameters from the entity blackboard key `ANIM_PARAMS_KEY`, arms triggers from `entity.animation` / `combat.hitReaction` / `entity.died`, and emits the new `animation.event` for clip events (foot plants, hit frames). `locomotionGraph()` builds the previous idle/walk/run plus one-shot behavior as one authored graph.
126
+ - **`PhysicsBackend` — one interface over interchangeable rigid-body solvers.** `@jgengine/core/physics/physicsBackend` defines bodies (`BodyDesc` with a box / sphere / capsule / convex / trimesh / heightfield shape union, quaternion rotation, angular velocity, layers and mask, CCD flag), joints, `raycast`, `shapecast`, `overlap`, contact events, and `snapshot`/`restore` with stable handles; `capabilities` says what a backend really does. `createPhysicsWorldBackend` puts the zero-dependency `PhysicsWorld` behind it (translation-only, non-box shapes collide as their bounding box, warned once), and `runPhysicsBackendConformance` is the behavioral contract any adapter runs. `createCharacterController` (`@jgengine/core/movement/characterController`) is the first consumer: a capsule collide-and-slide controller with step-up, slope limit, ceiling test, crouch with headroom check, ground snapping, and moving-platform carry, pure over any backend and retunable during play.
127
+ - **Fixed-step simulation with render interpolation — `defineGame({ simulation: { hz } })` and `ctx.sim`.** Every driver (shell frame, HUD-only, headless runner, authoritative host) now steps through one `SimLoop` (`@jgengine/core/runtime/simLoop`): a fixed `hz` accumulates real time and runs equal-dt steps behind a catch-up cap (`maxCatchUpSteps`, default 5) instead of spiralling; the default stays `"variable"` (one step per frame) so existing games are unchanged until they opt in. Under a fixed rate the shell renders entities at `ctx.sim.renderPose(id)` — the pose interpolated between the previous and current step (`@jgengine/core/runtime/poseInterpolation`), with teleports past `snapDistance` snapping — so a 20 Hz sim no longer stutters. `ctx.sim.tick()` counts steps and `InputFrame.tick` carries it over the wire; `ctx.sim.addStage({ phase, run })` inserts per-step work before movement, after movement, or after `onTick` (the hook a physics backend or netcode buffer registers through). System fixed-rate groups gain the same substep cap. For replay and rollback: `seededRng` streams expose `state()`/`restore()` (`rngStateOf`/`restoreRng` for any `ctx.rng`), `createSimSnapshotRegistry`/`createContextSimSnapshot` capture world, clock, loop, and rng cursor together, `createInputRecorder` logs frames by tick, and `HeadlessRunner` gains `snapshot()`/`restore()`/`tick()` — a test proves that replaying recorded input from a snapshot reproduces every entity pose.
128
+ - **City and street races share one generator.** `extractCircuitRoute` lifts a sealed-off race lap from a city street network; city rules, editor baking, playground modes, and the shell renderer carry the same street dials through the resulting race surface and junction dressing.
129
+ - **`ctx.particles` — the game-reachable particle seam.** Game logic queues one-shot bursts (`ctx.particles.burst(config, count)`) and keyed standing emitters (`attach`/`retune`/`detach`, with `follow` to track a scene entity) as plain serializable data (`@jgengine/core/vfx/particleDirector`); the shell auto-mounts a renderer that turns them into pooled GPU point clouds via the existing `createParticleSystem`/`ParticleField`, caps pool sizes by graphics quality (low 128 / medium 256 / high 512 per effect, degrading density instead of dropping effects), and reaps finished bursts. Dust, sparks, exhaust, and debris no longer require forking the shell; headless tests assert the requested effects straight off the director.
130
+ - **`resolveSourceWalkerStep(source, cache, position, stepX, stepZ, options)`** (`@jgengine/core/movement/solidObstacles`) — `resolveWalkerStep` with no `GameContext`, for movement rules that live in a pure package with no React or three.js. `SolidObstacleSource`, `ObstacleReachCache`, `createObstacleReachCache`, `sourceObstacleReach`, `sourceObstaclesNear`, and `slideStep` are public alongside it instead of `@internal`, so a game no longer has to push the resolver up into its app layer and inject it back down. `resolveWalkerStep`'s doc now states that it already resolves X and Z separately, so a caller doing its own axis-split must hand it one axis per call.
131
+ - **`tickRunCount(plan, id)`** (`@jgengine/core/time/serverTick`) — how many runs a plan owes a system, or 0 when it is not due.
132
+ - **`grass()` exposes the clump/lighting knobs its shader already had.** `GrassEnvironmentConfig` gains optional `bladeBend`, `tuftRadius`, `colorVariation`, and `normalLift`, forwarded by the shell's environment renderer — a world descriptor can now author bushy wind-combed scrub instead of the fixed lawn-blade look; defaults unchanged.
133
+ - **`TerrainDetailConfig.sweeps`** — the detail shader's two macro colour sweeps (sun-dried patches, cooler wet pockets) take per-world RGB multipliers instead of hardcoded meadow tints. The old green-leaning "lush pocket" default painted moss onto arid/ashen biomes; defaults are unchanged, so a desert world should set warm/neutral `sweeps` explicitly.
134
+ - **`@jgengine/rapier`** — a Rapier-backed `PhysicsBackend` adapter with native capsules, shapecasts, rotation, CCD, joints, and collision queries.
135
+
136
+ ## 0.18.0
137
+
138
+ ### Migrate
139
+
140
+ - **Bump lockstep SDK packages to `^0.18.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.0`; `@jgengine/github` is `0.5.0`.
141
+ - **Register the presence reaper and redeploy your schema.** `jgPoses` gains `sessionId` / `kind` / `label` / `revokedAt` (all optional) and a `by_updated` index, and `createPresenceFunctions` now also returns `reapIdlePresence`. Re-export it (`export const { list, sync, leave, reapIdlePresence } = createPresenceFunctions(...)`) and add `crons.interval("jg presence reap", { seconds: 60 }, internal.presence.reapIdlePresence, {})`. Without it nothing deletes the row of a player who closed the tab — `list` filtered stale rows at read time but the table grew forever. `jgengineCronSpecs()` carries the new entry, and every spec now names the `module` (`"runtime"` / `"presence"`) it is exported from.
142
+ - **`presence.sync` returns `{ pose, lastSeenAt, displaced }` instead of `null`,** and `presence.leave` marks the row revoked instead of deleting it (the reaper collects it once idle). A caller that ignored the return value is unaffected.
143
+
144
+ ### Changed
145
+
146
+ - **`presence.sync` accepts a call with no `pose` at all,** which holds the stored position and moves only the liveness stamp. `createConvexPresenceSync` sends one itself when the pose gate has suppressed writes for 10s, so a standing player keeps their row alive without a pose write — and without the reaper collecting a live session.
147
+ - **`PresencePoseRow` carries optional `sessionId` / `kind` / `label`,** so a nameplate reads off the presence row instead of joining against users per frame.
148
+
149
+ ### Added
150
+
151
+ - **`createPresenceFunctions` gains the policy hooks the pose lane was missing** — `resolveSpawn(ctx, { serverId, userId, kind })` for where a session with no row starts (async and ctx-bearing, so a spawn can read the world, replacing a hardcoded origin), `poseRules` as a function of the actor `kind` so an agent need not obey a human's speed cap, `idleCutoffMs` / `maxReapPerRun` for the reaper, and optional `sessionId` on `sync` / `leave`: a second tab is a second row, syncing revokes the others, and the displaced session learns it on its next call through `displaced: true`.
152
+
153
+ - **A command declares what it touches, so `runCommand` hydrates that and nothing else.** `CommandDef` gains an optional `scope(input, actorUserId)` returning `{ players?, chunkKeys? }`, evaluated before hydration; the registered `runCommand` mutation also accepts an explicit `scope` argument, which wins over the declared one. A game that never sets either keeps the whole-world default. `ServerLoopHooks.joinScope(userId, isNew)` does the same for `onNewPlayer`.
154
+ - **`GameRuntime.commandScope` / `GameRuntime.joinScope`** expose those declarations to any host, alongside the pure helpers `resolveCommandScope` / `scopeWithActor` / `commandScopeEscape` (`@jgengine/core/runtime/commandRunner`).
155
+
35
156
  ### Fixed
36
157
 
158
+ - **`runCommand` no longer reads the whole world on every command.** `LoadSnapshotScope` landed in 0.17 but only `helpers.loadSnapshot` accepted one, so the supported way to run a command — the registered `runCommand` mutation, a one-line delegation to `helpers.runCommand` — hydrated every member profile and every chunk however narrow the command was, and a game could only get bounded hydration by reimplementing the mutation. `runCommand`, `joinServer`, and `leaveServer` now hydrate a scope; a command whose `apply` dirties a player or chunk its scope never named is refused rather than persisted, since writing an unhydrated chunk overwrites the stored one with a blank. `flushSave` / `flushServerIfDue` / `tickActiveServers` / `flushDirtyServers` still hydrate everything, which is correct for what they write.
159
+
37
160
  ## 0.17.0
38
161
 
39
162
  ### Migrate
@@ -149,13 +272,18 @@ At publish, rename this heading to the new version and mirror the entries into
149
272
  - **`advanceStepCadence` (`@jgengine/core/audio/stepCadence`) — distance-driven footstep timing.** Pure state-in/state-out: feed it the ground distance a mover covered this frame and it reports how many steps landed and which foot led, so foley stays locked to distance instead of a wall-clock timer that desyncs the moment speed changes. Configurable stride, per-advance `strideScale` for crouch/sprint, and a shorter first step from rest so walking off is heard immediately.
150
273
  - **`resolveMusicState` (`@jgengine/core/audio/musicState`) — combat-reactive soundtrack selection.** The engine could already crossfade themes but nothing decided *when*, so every game re-handrolled the threshold and got a track that flapped as one enemy walked in and out of range. Give it a tier ladder (intensity → theme) and it resolves the theme to play: escalation is immediate, de-escalation waits for the tier's `holdMs` and needs intensity to fall a `release` margin below the tier it is leaving. Pure and serialisable.
151
274
  - **Synth voices take a `sustain` (`ToneVoice`/`NoiseVoice`).** Seconds held at full gain before the decay. Default 0 preserves every existing patch. Without it there was no way to author a *sustained* sound at all: every voice decayed to silence inside its duration, so a patch rendered once and looped as an ambient bed pulsed audibly once per loop. Set `sustain` to `duration` for a flat wind, rain, or machine-hum bed that loops seamlessly.
152
-
275
+ - **A street race is a section of a city, sealed off.** `extractCircuitRoute(network, rules)` (`@jgengine/core/world/raceCircuit`) lifts a closed lap out of an existing street network — the same asphalt the open world already drives on — and seals every side street leaving it. Returns checkpoints ready for `raceTrack()`, start/finish, grid, corners, kerbs, and sectors. Circuit-mode networks take the same path (their loop edges are the cycle). The city volume kind gains a Race group (`race`, `raceLapLength`, `raceArterial`, …) that resolves a world-space `ResolvedRace` on `ResolvedCity.race`; `CityRenderer` draws the lap surface, kerbs, start/finish, grid, and barrier seals. Playground adds a Street race mode over the same shared slider set.
276
+ - **Parked vehicles and junction signals on a city district.** `CityRules.parkedCars` / `signals` seed curb and pad fleets plus mast-arm signals (arterials) and sign posts (the rest); `ResolvedCity.vehicles` / `signals` hand them to `CityRenderer`.
153
277
  - **`ModelMaterialOverride.rim` — a fresnel rim light on any entity/object model.** `{ color, strength, power }` adds a bright edge where the surface turns away from the viewer, patched into the material's own shader so it shadows, fogs, and tonemaps with the body rather than floating in front of it. This is the standard fix for an actor that shares a value band with its ground: no albedo choice separates a body from terrain lit by the same key, but an edge does. Omit for no rim (default).
154
278
 
155
279
  ### Fixed
156
280
 
157
281
  - **Noise voices longer than the shared noise buffer no longer go silent partway through.** The 1-second buffer is sampled from a random offset, so any `NoiseVoice` whose duration exceeded the remainder ran off the end and finished in silence — invisible on short impact bursts, fatal for anything sustained. The source now wraps; the voice still ends on time.
158
282
 
283
+ - **CityRenderer junctions use the welded carriageway-union seam end-to-end.** District roads go through `buildTrimmedIntersections` + `buildIntersectionMarkings` with the same compact opts as StreetGeometryPreview / playground (`curbReturnRadius: 2`, `apronMargin: 0.25`). Plaza hull aprons, fallback discs, and fat zebra ladders are gone; race resurfacing stops at junction mouths so welded patches stay visible.
284
+ - **Denser urban fabric for metro/Manhattan districts.** Commercial class setbacks/spacing tighten toward a streetwall; slabs/towers pick L-plans and mid-block courts; dense blocks push footprints to the curb and paint courtyards as hard courts rather than lawn leftovers.
285
+ - **City and track are one slider surface again.** `CityRules` and the editor `generate_streets` bake now forward every `StreetNetworkRules` dial (`elevation`, `maxGrade`, `compactness`, `sidewalkWidth`, `outline`, `residentialBranches`); `check-street-rule-parity` fails CI if either drifts. Playground no longer hides layout sliders when you switch modes — modes are presets over one shared set.
286
+ - **City facades dissolve their window stripes before they alias into speckled static**, and ground floors now read as storefronts rather than repeating floor 30. Block interiors paint developed fabric instead of raw lawn, and crosswalks only land at real junctions.
159
287
  - **`terrain({ detail: { material: { tint } } })` separates a ground texture's grain from its hue.** A detail material's colour map used to replace the terrain's authored colour outright, so the biome palette, macro sweeps, and slope-rock blend under it were painted over and a whole region flattened to the texture's single hue — despite the API documenting that it "blends with, never replaces". `tint` defaults to 1 (the existing behaviour, so no shipped game changes); drop it toward 0 to keep the authored colour and take only the map's light and shade.
160
288
  - **`sky({ gradientExponent, cloudiness })` are now authorable — and `sky()` no longer silently drops them.** `gradientExponent` controls how fast the gradient reaches `zenithColor` off the horizon (default 0.65, unchanged): below 1 pulls the zenith hue down into the frame, which is what an eye-level camera in flat terrain actually needs — at the default the sky reads as one horizon-coloured wash however saturated the authored zenith is. `cloudiness` (default 0.35, unchanged) is the procedural cloud-band opacity.
161
289
  - **A model that throws while rendering is now reported instead of vanishing (#1588).** `ModelFallbackBoundary` swapped in its fallback (usually nothing) and stayed silent: `probes.fallbacks` stayed empty, no warning was logged, and an entity simply disappeared. It now reports a new `renderError` fallback cause on every failed render and warns once with the model url and the caught error.
@@ -36,6 +36,10 @@ export type WsVoiceSync = {
36
36
  publish: (serverId: string, channelId: string, streamId: string) => Promise<void>;
37
37
  };
38
38
  export type WsBackend = GameBackend & {
39
+ rtt: {
40
+ sampleMs: number;
41
+ smoothedMs: number;
42
+ };
39
43
  pushFeedEntry: (args: {
40
44
  serverId: string;
41
45
  action: string;
@@ -49,10 +53,12 @@ export type WsBackend = GameBackend & {
49
53
  joinByCode: (args: {
50
54
  gameId: string;
51
55
  code: string;
56
+ role?: "player" | "spectator";
52
57
  }) => Promise<JoinServerResult | null>;
53
58
  createSession: (args: {
54
59
  gameId: string;
55
60
  attributes?: SessionAttributes;
61
+ role?: "player" | "spectator";
56
62
  }) => Promise<JoinServerResult>;
57
63
  presenceSync: WsPresenceSync;
58
64
  chatSync: WsChatSync;
@@ -46,6 +46,20 @@ export function createWsBackend(options) {
46
46
  let reconnectTimer = null;
47
47
  const pending = new Map();
48
48
  const subscriptions = new Map();
49
+ const resumeTickets = new Map();
50
+ const joinedGames = new Map();
51
+ const joinedRoles = new Map();
52
+ const rtt = { sampleMs: 0, smoothedMs: 0 };
53
+ let pingTimer = null;
54
+ const schedulePing = () => {
55
+ pingTimer = schedule(() => {
56
+ pingTimer = null;
57
+ if (open)
58
+ rawSend({ v: 1, t: "ping", id: nextId++, at: now() });
59
+ if (!closed)
60
+ schedulePing();
61
+ }, 1000);
62
+ };
49
63
  const clearRequestTimer = (request) => {
50
64
  if (request.timer !== null) {
51
65
  cancel(request.timer);
@@ -103,6 +117,12 @@ export function createWsBackend(options) {
103
117
  });
104
118
  }
105
119
  };
120
+ const rejoinServers = () => {
121
+ for (const [serverId, gameId] of joinedGames) {
122
+ rawSend({ v: 1, t: "join", id: nextId++, gameId, serverId, role: joinedRoles.get(serverId) });
123
+ }
124
+ };
125
+ const helloToken = () => resumeTickets.values().next().value?.token ?? options.token;
106
126
  const handleMessage = (raw) => {
107
127
  const message = decodeWsServerMessage(raw);
108
128
  if (message === null) {
@@ -131,6 +151,11 @@ export function createWsBackend(options) {
131
151
  }
132
152
  return;
133
153
  }
154
+ if (message.t === "pong") {
155
+ rtt.sampleMs = Math.max(0, now() - message.at);
156
+ rtt.smoothedMs = rtt.smoothedMs === 0 ? rtt.sampleMs : rtt.smoothedMs * 0.8 + rtt.sampleMs * 0.2;
157
+ return;
158
+ }
134
159
  const key = subscriptionKey(message.channel, message.serverId, message.channel === "feed" || message.channel === "chat" || message.channel === "voice"
135
160
  ? message.action
136
161
  : undefined);
@@ -185,18 +210,21 @@ export function createWsBackend(options) {
185
210
  pipe = pipeFactory({
186
211
  onOpen: () => {
187
212
  open = true;
213
+ if (pingTimer === null)
214
+ schedulePing();
188
215
  const id = nextId++;
189
216
  const helloBuild = (requestId) => ({
190
217
  v: 1,
191
218
  t: "hello",
192
219
  id: requestId,
193
220
  userId: options.userId,
194
- token: options.token,
221
+ token: helloToken(),
195
222
  });
196
223
  pending.set(id, {
197
224
  id,
198
225
  resolve: () => {
199
226
  reconnectAttempt = 0;
227
+ rejoinServers();
200
228
  resubscribeAll();
201
229
  resendPendingRpcs();
202
230
  settleResolve();
@@ -213,6 +241,10 @@ export function createWsBackend(options) {
213
241
  },
214
242
  onMessage: handleMessage,
215
243
  onClose: () => {
244
+ if (pingTimer !== null) {
245
+ cancel(pingTimer);
246
+ pingTimer = null;
247
+ }
216
248
  open = false;
217
249
  pipe = null;
218
250
  ready = null;
@@ -268,20 +300,46 @@ export function createWsBackend(options) {
268
300
  });
269
301
  });
270
302
  };
303
+ const onPageHide = () => {
304
+ for (const serverId of joinedGames.keys())
305
+ void transport.leaveServer({ serverId }).catch(() => undefined);
306
+ };
307
+ globalThis.addEventListener?.("pagehide", onPageHide);
271
308
  const transport = {
272
309
  async joinServer(args) {
273
- const result = await request((id) => ({
274
- v: 1,
275
- t: "join",
276
- id,
277
- gameId: args.gameId,
278
- serverId: args.serverId,
279
- }));
280
- return result;
310
+ try {
311
+ const result = await request((id) => ({
312
+ v: 1,
313
+ t: "join",
314
+ id,
315
+ gameId: args.gameId,
316
+ serverId: args.serverId,
317
+ role: args.role,
318
+ }));
319
+ const joined = result;
320
+ joinedGames.set(joined.serverId, args.gameId);
321
+ joinedRoles.set(joined.serverId, args.role ?? "player");
322
+ if (joined.resumeTicket !== undefined)
323
+ resumeTickets.set(joined.serverId, joined.resumeTicket);
324
+ return { ...joined, ok: true };
325
+ }
326
+ catch (error) {
327
+ const message = error instanceof Error ? error.message : String(error);
328
+ if (/full/i.test(message))
329
+ return { ok: false, reason: "full" };
330
+ if (/closed|not found|does not exist/i.test(message))
331
+ return { ok: false, reason: "closed" };
332
+ if (/unauthorized|authenticat|forbidden/i.test(message))
333
+ return { ok: false, reason: "unauthorized" };
334
+ throw error;
335
+ }
281
336
  },
282
337
  async leaveServer(args) {
283
338
  poseGates.delete(args.serverId);
284
339
  await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId }));
340
+ joinedGames.delete(args.serverId);
341
+ joinedRoles.delete(args.serverId);
342
+ resumeTickets.delete(args.serverId);
285
343
  },
286
344
  async runCommand(args) {
287
345
  const opId = String(nextOpId++);
@@ -334,6 +392,18 @@ export function createWsBackend(options) {
334
392
  subscribeFeed(args, onChange) {
335
393
  return addSubscription("feed", args.serverId, args.action, (data) => onChange(data));
336
394
  },
395
+ requestServerBaseline(serverId) {
396
+ const subscription = subscriptions.get(subscriptionKey("server", serverId));
397
+ if (subscription === undefined)
398
+ return;
399
+ void request((id) => ({
400
+ v: 1,
401
+ t: "subscribe",
402
+ id,
403
+ channel: "server",
404
+ serverId,
405
+ })).catch(() => undefined);
406
+ },
337
407
  };
338
408
  const poseGates = new Map();
339
409
  const presenceSync = {
@@ -397,6 +467,7 @@ export function createWsBackend(options) {
397
467
  publish: (channelId, streamId) => voiceSync.publish(serverId, channelId, streamId),
398
468
  subscribers: (channelId, onChange) => voiceSync.subscribe(serverId, channelId, onChange),
399
469
  }),
470
+ rtt,
400
471
  async pushFeedEntry(args) {
401
472
  await request((id) => ({
402
473
  v: 1,
@@ -425,8 +496,16 @@ export function createWsBackend(options) {
425
496
  id,
426
497
  gameId: args.gameId,
427
498
  code: args.code,
499
+ role: args.role,
428
500
  }));
429
- return result;
501
+ const joined = result;
502
+ if (joined !== null) {
503
+ joinedGames.set(joined.serverId, args.gameId);
504
+ joinedRoles.set(joined.serverId, args.role ?? "player");
505
+ if (joined.resumeTicket !== undefined)
506
+ resumeTickets.set(joined.serverId, joined.resumeTicket);
507
+ }
508
+ return joined;
430
509
  },
431
510
  async createSession(args) {
432
511
  const result = await request((id) => ({
@@ -435,12 +514,23 @@ export function createWsBackend(options) {
435
514
  id,
436
515
  gameId: args.gameId,
437
516
  attributes: args.attributes,
517
+ role: args.role,
438
518
  }));
439
- return result;
519
+ const joined = result;
520
+ joinedGames.set(joined.serverId, args.gameId);
521
+ joinedRoles.set(joined.serverId, args.role ?? "player");
522
+ if (joined.resumeTicket !== undefined)
523
+ resumeTickets.set(joined.serverId, joined.resumeTicket);
524
+ return joined;
440
525
  },
441
526
  close: () => {
527
+ globalThis.removeEventListener?.("pagehide", onPageHide);
442
528
  closed = true;
443
529
  wantConnection = false;
530
+ if (pingTimer !== null) {
531
+ cancel(pingTimer);
532
+ pingTimer = null;
533
+ }
444
534
  clearReconnectTimer();
445
535
  failPending("Backend closed");
446
536
  pipe?.close();
package/dist/host.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
2
  import type { HostPersistence, ServerListing, SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
3
3
  import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
4
- import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
4
+ import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult, WorldSyncFrame } from "@jgengine/core/runtime/transport";
5
+ import type { SnapshotViewer } from "@jgengine/core/runtime/worldSnapshot";
5
6
  /** A change notification emitted by a `GameHost` for a server, player, or feed mutation. */
6
7
  export type HostChangeEvent = {
7
8
  type: "server";
@@ -33,6 +34,7 @@ export type GameHost = {
33
34
  serverId?: string;
34
35
  attributes?: SessionAttributes;
35
36
  code?: string;
37
+ role?: SnapshotViewer["role"];
36
38
  }) => Promise<JoinServerResult>;
37
39
  browseServers: (args: {
38
40
  gameId: string;
@@ -61,7 +63,14 @@ export type GameHost = {
61
63
  getServerView: (args: {
62
64
  userId: string;
63
65
  serverId: string;
66
+ role?: SnapshotViewer["role"];
64
67
  }) => Promise<GameRuntimeServerView | null>;
68
+ pullWorld?: (args: {
69
+ userId: string;
70
+ serverId: string;
71
+ sinceRevision: number | null;
72
+ role?: SnapshotViewer["role"];
73
+ }) => Promise<WorldSyncFrame | null>;
65
74
  getPlayerView: (args: {
66
75
  userId: string;
67
76
  serverId: string;
package/dist/host.js CHANGED
@@ -96,6 +96,7 @@ export function createGameHost(options) {
96
96
  playersByUserId: buildHydratePlayers(record, profiles),
97
97
  chunksByKey,
98
98
  revision: record.revision,
99
+ nowMs: now(),
99
100
  });
100
101
  const existing = live.get(record.serverId);
101
102
  if (existing)
@@ -160,7 +161,7 @@ export function createGameHost(options) {
160
161
  return { ticked: 0, saved: 0 };
161
162
  const runtime = resolveRuntime(entry.record.gameId);
162
163
  const before = entry.snapshot.revision;
163
- entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
164
+ entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000, timestamp);
164
165
  entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
165
166
  if (entry.snapshot.revision !== before) {
166
167
  markMutated(entry);
@@ -265,7 +266,7 @@ export function createGameHost(options) {
265
266
  updatedAt: timestamp,
266
267
  dirtyAt: timestamp,
267
268
  };
268
- entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
269
+ entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew, timestamp);
269
270
  await flushServer(entry);
270
271
  emit({ type: "server", serverId: entry.record.serverId });
271
272
  emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
@@ -329,7 +330,7 @@ export function createGameHost(options) {
329
330
  }
330
331
  const commandInput = envelope !== null ? envelope.input : args.input;
331
332
  const runtime = resolveRuntime(entry.record.gameId);
332
- const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput);
333
+ const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput, now());
333
334
  const outcome = result.ok
334
335
  ? { ok: true }
335
336
  : { ok: false, reason: result.reason };
@@ -17,13 +17,15 @@ export type HostRouterOptions = {
17
17
  chatRateLimit?: ChatRateLimit;
18
18
  chatHistoryLimit?: number;
19
19
  chatMaxBodyLength?: number;
20
- /** Per-op rate limits for pose/runCommand/join/browse/voice. Omit (default) for no rate limiting; pass {@link DEFAULT_COMMAND_LIMITS} or a custom {@link CommandLimits} to opt in. */
20
+ /** Per-op rate limits for pose/runCommand/join/browse/voice. Defaults to {@link DEFAULT_COMMAND_LIMITS}. */
21
21
  limits?: CommandLimits;
22
22
  /** Per-command authorization hook; defaults to allow-all. */
23
23
  authorize?: CommandAuthorize;
24
24
  /** Declared `runCommand` names and input validators. Omit (default) to pass every command through unchanged; set to reject unknown command names and validate declared ones. */
25
25
  validate?: CommandCatalog;
26
26
  now?: () => number;
27
+ /** How long a disconnected membership remains reclaimable. Defaults to 15 seconds. */
28
+ graceMs?: number;
27
29
  };
28
30
  export type HostRouterTransport = {
29
31
  send: (data: string) => void;
@@ -51,5 +53,7 @@ export type HostRouter = {
51
53
  export declare const DEFAULT_POSE_RULES: PoseSyncRules;
52
54
  /** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
53
55
  export declare const MAX_QUEUED_MESSAGES = 64;
56
+ /** Default reconnect grace period for hosted socket sessions. */
57
+ export declare const DEFAULT_GRACE_MS = 15000;
54
58
  export declare function createHostRouter(options: HostRouterOptions): HostRouter;
55
59
  export declare function loopbackPipe(router: HostRouter): TransportPipeFactory;