feat: carry local scene development player state over pulse - #9857
feat: carry local scene development player state over pulse#9857mikhail-dcl wants to merge 3 commits into
Conversation
Pulse now defaults on in local scene development, alongside the local gatekeeper
LiveKit room rather than replacing it: player state (movement, emotes, teleports,
profile-version announcements) goes over Pulse, Scene Messages (SDK MessageBus and
client-to-client CRDT sync) keep going over LiveKit. Nothing is decommissioned.
Pulse has no rooms and partitions visibility by exact realm-string match, so every
concurrent dev process would otherwise land in the same realm. Each one now derives
a realm from the preview entity id its dev server already serves:
realmKey = "lsd:" + previewSceneId
collapsing to "lsd:sha256:" + SHA256Hex(previewSceneId) (lowercase hex) past Pulse's
MaxRealmLength of 255. Nothing is exchanged; every party derives the identical string,
which is what avoids the LiveKit preview-room-name-mismatch class of bug. The contract
matches js-sdk-toolchain's logic/lsd-realm.ts, and the tests pin its published vectors.
- FeaturesRegistry: Pulse defaults on in local scene development (which resolves no
remote flags), with --pulse false as the way back to LiveKit-only.
- IPulseRealm: the seam every realm read in PulseMultiplayerBus now goes through.
RealmDataPulseRealm passes IRealmData.RealmName through live, so nothing changes
outside local scene development; LocalSceneDevelopmentPulseRealm resolves the key.
- LocalSceneEntityIdSource: the dev server's two-step entity-id fetch, extracted from
LocalSceneDevelopmentSceneRoomMetaDataSource so both transports share one definition.
- StartPulseMultiplayerStartupOperation resolves the realm before connecting, and
deactivates Pulse when it cannot be resolved — an empty realm is rejected server-side,
so this falls back to LiveKit-only rather than joining a broken session.
- Warn when the scene's base parcel is outside Genesis City bounds, which Pulse's
FieldValidator rejects by disconnecting the peer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🚦 CI StatusWindows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below. Warnings count reduced: 12215 => 12212 Warnings/errors in files changed by this PR (23)All Unity tests passed ✅
|
decentraland-bot
left a comment
There was a problem hiding this comment.
PR Review — #9857 feat: carry local scene development player state over pulse
STEP 2 — Root-cause check: PASS
This PR adds a new capability — carrying LSD player state over Pulse alongside the existing LiveKit scene-room transport. It is a feature addition, not a workaround. The dual-transport design is explicit: Pulse handles player state (movement, emotes, teleports, profile announcements); LiveKit keeps carrying Scene Messages (SDK MessageBus, CRDT sync). The de-duplication in LiveKitMessagesBroadcaster self-suppresses the LiveKit player-state path when Pulse is active, verified by reading the wiring.
STEP 3 — Design & integration: PASS
Owner search — IPulseRealm (new interface, 2 implementations):
The realm string is a configuration value, not a lifecycle entity. Before this PR, PulseMultiplayerBus read IRealmData.RealmName directly at 11 sites. The new IPulseRealm seam wraps that with:
RealmDataPulseRealm— pure passthrough toIRealmData.RealmName(reads live). Behavior-identical to the old direct read outside LSD.LocalSceneDevelopmentPulseRealm— resolves once from the dev server, caches the derived key.
Both are constructed in DynamicWorldContainer (lines 253–255), passed through MultiplayerContainer.CreateAsync → PulseContainer → PulseMultiplayerBus. The bus is the consumer, not the lifecycle owner. The realm is resolved in StartPulseMultiplayerStartupOperation before the bus sends its first message. No existing owner is bypassed — IRealmData remains the source of truth (via RealmDataPulseRealm), and LSD had no prior realm concept to duplicate.
Owner search — LocalSceneEntityIdSource (new class, 1 implementation + test mock):
Extracts the dev-server two-step fetch (GET scene.json → POST content/entities/active) that was inline in LocalSceneDevelopmentSceneRoomMetaDataSource. Two production consumers: CommsContainer.Create (line 123, for LiveKit gatekeeper room) and DynamicWorldContainer (line 254, for Pulse realm). Each creates its own instance — correct, since they're used at different times in different containers, and the entity id is stable (path-based, not content-based).
Teardown trace:
SHA256.Create()inRealmKeyFor()→using var sha256(line 87). Properly disposed.- No subscriptions, events, connections, or
IDisposableresources opened by any new type. LocalSceneEntityIdSourceusesIWebRequestControllerfor HTTP — no persistent handles to track.
STEP 4 — Member audit
| Member | Consumers | Verdict |
|---|---|---|
IPulseRealm.Value |
PulseMultiplayerBus (7 sites), StartPulseMultiplayerStartupOperation (1) |
Multiple consumers, justified |
IPulseRealm.EnsureResolvedAsync |
StartPulseMultiplayerStartupOperation (1) |
Single caller, but 2 implementations with distinct behavior (resolve vs no-op) — strategy pattern, not single-use |
ILocalSceneEntityIdSource.EntityAsync |
LocalSceneDevelopmentSceneRoomMetaDataSource (1), LocalSceneDevelopmentPulseRealm (1) |
2 consumers, justified extraction |
LocalSceneEntity.Id / .BaseParcel |
2 / 1 consumers | Data carrier struct fields — BaseParcel has 1 consumer but is a natural property of the entity, not a derived predicate |
MultiplayerContainer.PulseRealm |
DynamicWorldContainer → InitializationFlowContainer (1) |
DI forwarding field, correct |
No single-use-merge, absent≠false, re-derive, or redundant-guard issues.
STEP 5 — Line-level findings
One P2 finding — see inline comment below.
STEP 6 — Complexity: COMPLEX
Touches Pulse multiplayer transport wiring, async startup realm resolution, cross-repo realm-key contract, feature flag gating, and 32 files with non-trivial logic changes.
STEP 7 — QA: YES
Runtime multiplayer behavior changes (Pulse enabled in LSD, dual-transport, realm isolation). Affects player-visible behavior (avatar movement sync). Requires multi-client manual testing per the PR's test plan (scenarios A–D).
STEP 8 — Non-blocking warnings
None. Main.unity not in changed files.
Security review
- Secrets/credentials: No hardcoded secrets. The realm key is derived from a path + machine id (already sent to the gatekeeper today). SHA256 hashing is correctly implemented with culture-independent lowercase hex.
- Input validation: The
realmparameter originates from--realmprogram argument. URLs constructed from it are used for local HTTP calls to the dev server. The same pattern exists in the pre-existing code that was refactored. No new attack surface. - SSRF: The local dev server URLs (
scene.json,content/entities/active) are the same endpoints the gatekeeper already fetches. No new exposure. - Auth/authz: No auth changes. Pulse uses the same org endpoint as non-LSD sessions.
- Hash correctness:
SHA256.Create()→ComputeHash(Encoding.UTF8.GetBytes(...))→ lowercase hex. The hex encoding spells digits out ("0123456789abcdef") rather than using culture-dependentToString("x2"). Correct and byte-identical to the js-sdk-toolchain implementation.
No security issues found.
Summary
This is a well-structured dual-transport change. The IPulseRealm abstraction is a clean seam — outside LSD it's a pure passthrough with no behavior change; inside LSD it resolves the realm from the dev server once and caches it. The cross-repo realm-key contract is pinned by test vectors matching the js-sdk-toolchain documentation. The startup gating (resolve → check empty → fallback to LiveKit) correctly prevents connecting with an empty realm, which would violate the server contract. Test coverage is thorough: 39 EditMode tests covering both boundary conditions, both sides of the 255-character limit, resolve-once semantics, error paths, and the feature flag matrix.
The feature flag change (localSceneDevelopment || featureFlags.IsEnabled(...)) correctly defaults Pulse on in LSD while preserving --pulse false as the opt-out, and does not affect non-LSD behavior.
REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Pulse multiplayer transport wiring, async startup realm resolution, cross-repo realm-key contract, feature flag gating changes
QA_REQUIRED: YES
Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub
|
|
||
| string? id = result[0].id; | ||
|
|
||
| if (string.IsNullOrWhiteSpace(id!)) |
There was a problem hiding this comment.
[P2] Null-forgiving operator defeats flow narrowing (CLAUDE.md: "Flag any null-forgiving operator (!) that lacks a justifying comment")
string.IsNullOrWhiteSpace accepts string? and carries [NotNullWhen(false)], so the compiler can narrow id to non-null on the happy path — but id! suppresses that analysis. Remove the ! to let flow narrowing work correctly.
(Pre-existing code moved verbatim from the old LocalSceneDevelopmentSceneRoomMetaDataSource — good opportunity to clean it up in the new file.)
| if (string.IsNullOrWhiteSpace(id!)) | |
| if (string.IsNullOrWhiteSpace(id)) |
| await webRequestController.GetAsync( | ||
| new CommonArguments(sceneDefinitionEndpoint), | ||
| ct, | ||
| ReportCategory.LIVEKIT, |
There was a problem hiding this comment.
[P2] ReportCategory.LIVEKIT is now misleading for a shared source. This class was extracted so both LiveKit (gatekeeper room) and Pulse (realm resolution) share one fetch. When the fetch fails while resolving the Pulse realm, the exception still gets tagged LIVEKIT, which could send someone triaging a Pulse connectivity issue down the wrong path.
Consider switching to ReportCategory.MULTIPLAYER (the category LocalSceneDevelopmentPulseRealm already uses), or accepting a ReportCategory parameter from the caller so each consumer tags with its own category.
| ReportCategory.LIVEKIT, | |
| ReportCategory.MULTIPLAYER, |
This comment has been minimized.
This comment has been minimized.
IPulseRealm had exactly two implementations that differed only in where the string came from, and neither was polymorphic anywhere else. Merged them into a single concrete PulseRealm: the optional ILocalSceneEntityIdSource constructor argument is the mode, so the class reads as "follow the realm, unless a local dev server tells us otherwise" instead of spreading that decision across an interface and two files. Removes IPulseRealm.cs, RealmDataPulseRealm.cs and LocalSceneDevelopmentPulseRealm.cs; adds PulseRealm.cs. Behaviour is unchanged — the derivation, the resolve-once caching, the Genesis-bounds warning and the empty-realm contract are carried over verbatim. ILocalSceneEntityIdSource stays: it has two production consumers (the gatekeeper metadata source and this class), and it is the seam that lets the whole local scene development path — resolve, dev-server-unreachable, fetch-throws — be tested without standing up HTTP. Tests merged into PulseRealmShould, which now also covers the passthrough mode that RealmDataPulseRealm had only implicitly: 41 pass, up from 39. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
PR Review: feat: carry local scene development player state over pulse
STEP 2 — Root-cause check: PASS
This PR implements a new feature — carrying local scene development (LSD) player state over Pulse while keeping Scene Messages on LiveKit. It is not patching a symptom; it is adding a dual-transport capability with a well-defined realm-key contract.
STEP 3 — Design & integration: PASS
PulseRealm — New class centralizing every realm read/write in PulseMultiplayerBus (11 sites across 4 partial files). The optional ILocalSceneEntityIdSource constructor arg is the mode selector: null → live passthrough to IRealmData.RealmName, supplied → derived LSD key resolved once. This avoids littering PulseMultiplayerBus with conditional LSD logic.
Owner search: The lifecycle owner for realm data is IRealmData, which PulseMultiplayerBus previously accessed directly. PulseRealm wraps that access to add the LSD derivation, a valid indirection since IRealmData has no concept of LSD and shouldn't. The wrapping class does not duplicate any creation/destruction lifecycle — it sits alongside PulseActivation at the same construction level (DynamicWorldContainer → MultiplayerContainer → PulseContainer).
LocalSceneEntityIdSource — Extracted verbatim from LocalSceneDevelopmentSceneRoomMetaDataSource, which now delegates to it. Two consumers (LocalSceneDevelopmentSceneRoomMetaDataSource for LiveKit gatekeeper, PulseRealm for Pulse) independently instantiate it. Both hit the same localhost dev server for the same stable entity ID — the duplicate fetch is negligible for a local endpoint, and sharing the instance would require threading it through container hierarchies that are otherwise independent. This is a pragmatic split that eliminates code duplication at the definition level.
ILocalSceneEntityIdSource interface with one implementation — Justified by test mocking: PulseRealmShould and StartPulseMultiplayerStartupOperationShould both substitute it via NSubstitute.
Teardown/consumption trace:
PulseRealmholds no subscriptions, event handlers, connections, or disposable resources. TheSHA256.Create()inRealmKeyForis wrapped inusing.LocalSceneEntityIdSourceis stateless with no subscriptions.StartPulseMultiplayerStartupOperationadds no new subscriptions — it only awaitsEnsureResolvedAsyncandConnectAsync.- No leaks.
STEP 4 — Member audit: PASS
| Member | Consumers | Verdict |
|---|---|---|
PulseRealm.Value |
11 sites in PulseMultiplayerBus (handshake, PlayerJoined filter, teleport broadcast/receive, wallet lookup, peer purge), plus StartPulseMultiplayerStartupOperation empty-check |
Multi-consumer — justified |
PulseRealm.EnsureResolvedAsync |
StartPulseMultiplayerStartupOperation |
Single consumer, but a lifecycle entry point (resolve-before-connect), not a derived predicate — legitimate |
PulseRealm.RealmKeyFor |
EnsureResolvedAsync (production), PulseRealmShould (tests, 4 vectors) |
internal static utility, test-pinned — justified |
LocalSceneEntity.Id |
LocalSceneDevelopmentSceneRoomMetaDataSource, PulseRealm |
Two consumers |
LocalSceneEntity.BaseParcel |
PulseRealm.WarnIfOutsideGenesisBounds |
Single consumer, but the struct represents domain data, not a single-use predicate |
MultiplayerContainer.PulseRealm |
DynamicWorldContainer (passes to InitializationFlowContainer) |
Forwarding field — legitimate container exposure |
STEP 5 — Line-level findings
One P2 finding (see inline comment).
Security review: Clean. No secrets committed, no injection vectors, no auth/authz changes. The base64-encoded path in the realm key is the same information already sent to the gatekeeper — not a new exposure vector. SHA256 derivation is deterministic and platform-independent.
STEP 6 — Complexity: COMPLEX
Touches networking/multiplayer sync (Pulse transport), modifies dependency injection wiring (PulseContainer, MultiplayerContainer, DynamicWorldContainer, InitializationFlowContainer), introduces new shared interfaces, and changes the feature-flag gate. 28 files changed.
STEP 7 — QA assessment: YES
Runtime multiplayer behavior changes — player-state traffic now routes through Pulse in LSD mode. The dual-transport coexistence, realm isolation, and fallback paths need manual verification with running Explorers and authenticated wallets.
STEP 8 — Non-blocking warnings
None. Main scene not modified.
STEP 9 — Verdict
REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies Pulse multiplayer transport, feature-flag gating, DI wiring, and cross-repo realm-key contract for local scene development.
QA_REQUIRED: YES
Reviewed by Jarvis 🤖 · Requested by mikhail-dcl via GitHub
|
|
||
| string? id = result[0].id; | ||
|
|
||
| if (string.IsNullOrWhiteSpace(id!)) |
There was a problem hiding this comment.
[P2] Null-forgiving ! without justifying comment (CLAUDE.md § Nullable Reference Types). id is declared string? and string.IsNullOrWhiteSpace accepts nullable — the ! should either be removed or annotated with why it is needed in this BCL version.
| if (string.IsNullOrWhiteSpace(id!)) | |
| if (string.IsNullOrWhiteSpace(id)) |
If this triggers CS8604 in Unity's BCL version, add a comment instead:
// ! suppresses CS8604 — IsNullOrWhiteSpace handles null despite the BCL annotation.
|
PR #9857, run #33390466695 Overall: ✅ no significant changes Builds: Windows change, Windows baseline, macOS change, macOS baseline How to read this table
Intel Core i5
Apple M1
|
Tested end to end against bevy on zone Pulse ✅The description's Test Instructions and evidence sections have been rewritten — flagging it here since This now has a real cross-implementation run, using 1. The realm key matched byte-for-byte. bevy's Rust derivation and this branch's C# That's the contract's one silent failure mode, now checked against an actual second implementation 2. Client state reached the auth server over Pulse, and tracked movement. The server roster went Avatar state rides Pulse exclusively, so a position in the server's roster proves the full One stale claim removedAuthoritative-server previews are no longer outside the Pulse story. bevy-headless gained a Pulse Two things QA must not miss
Still openTwo-client mutual visibility (and the absence of duplicate/double-moving avatars), the cross-process |
decentraland-bot
left a comment
There was a problem hiding this comment.
PR Review: feat: carry local scene development player state over pulse
STEP 1 — Context
Loaded: CLAUDE.md, docs/README.md, subsystem docs (docs/pulse.md, docs/multiplayer.md, docs/livekit-networking.md). Changed files span: FeatureFlags, Infrastructure/Dynamic containers (5 files), Multiplayer/Connections (Pulse + GateKeeper), Multiplayer/Movement (PulseMultiplayerBus across 4 partial files), UserInAppInitializationFlow, and docs (4 files). Full surrounding files read from the PR head commit.
STEP 2 — Root-cause check: PASS
This is a feature addition — Pulse transport for LSD — not a bug fix. The design addresses the core problem: LSD needs per-process realm isolation for Pulse, because Pulse has no rooms and partitions visibility by exact realm-string match. The solution derives a key from the preview entity id (lsd: prefix, SHA256 overflow form), which is stable across content edits and restarts. The feature flag inversion (localSceneDevelopment || featureFlags.IsEnabled(...)) correctly defaults Pulse ON in LSD where remote flags can never be resolved. No symptom-patching detected.
STEP 3 — Design & integration: PASS
New units introduced:
PulseRealm— not a lifecycle manager; a data source combining pass-through ofIRealmData.RealmName(normal mode) with a once-derived key (LSD mode). Constructed once inDynamicWorldContainer, injected intoPulseContainer→PulseMultiplayerBusandInitializationFlowContainer→StartPulseMultiplayerStartupOperation. No frame-by-frame reconciliation; the realm is read live via a property.LocalSceneEntityIdSource— extracts the two-step dev-server fetch (GET scene.json→POST content/entities/active) verbatim fromLocalSceneDevelopmentSceneRoomMetaDataSource, which now delegates to it. One class definition of "the local scene's entity id" instead of two.LocalSceneEntitystruct — a simple immutable value type carryingId+BaseParcel.ILocalSceneEntityIdSourceinterface — has one production implementation but is mocked via NSubstitute inPulseRealmShould(8 tests) andStartPulseMultiplayerStartupOperationShould(2 new tests). Justified per CLAUDE.md anti-pattern #4 exception.
Owner search: The realm was previously read via IRealmData.RealmName directly by PulseMultiplayerBus (7 sites). The new PulseRealm wraps that same live read and adds the LSD-specific derivation. Outside LSD, Value is a pure passthrough with no behaviour change. No lifecycle duplication, no per-frame scanning, no polling.
Teardown trace: PulseRealm holds no subscriptions, event hookups, connections, or disposable state. LocalSceneEntityIdSource holds no disposable state. No leak risk.
STEP 4 — Member audit
| Member | Consumers | Status |
|---|---|---|
PulseRealm.Value |
11 in PulseMultiplayerBus (4 partial files) + 1 in StartPulseMultiplayerStartupOperation + tests |
Not single-use |
PulseRealm.EnsureResolvedAsync |
1 call site (StartPulseMultiplayerStartupOperation) |
Resolve-once by design; guard localSceneRealm.Length > 0 makes subsequent calls no-ops |
LocalSceneEntity.Id, .BaseParcel |
Consumed at their creation sites | Clean value type |
LocalSceneEntityIdSource.EntityAsync |
2 consumers: LocalSceneDevelopmentSceneRoomMetaDataSource.MetaDataAsync + PulseRealm.EnsureResolvedAsync |
Not single-use |
No single-use-merge, absent≠false, or redundant guard issues.
STEP 5 — Line-level review
See inline comments. Two P2 findings:
- [P2] Stale doc claim —
docs/how-to-connect-to-a-local-scene.md:108says auth-server previews are NOT on Pulse, contradicted by this PR's own test evidence and body correction. - [P2] Redundant
LocalSceneEntityIdSourceconstruction —DynamicWorldContainer.cs:255andCommsContainer.cs:123each create an independent instance with identical arguments; both will make separate HTTP calls to the same dev server at startup.
Thread safety note (not a finding): PulseRealm.localSceneRealm is written once in EnsureResolvedAsync (before ConnectAsync) and read from Value on multiple threads. String reference assignment is atomic in .NET, and the resolve-before-connect ordering establishes a happens-before relationship. Functionally safe.
Security review: No secrets exposed, no injection risks, no auth/authz issues. The non-overflow realm key transmits base64-encoded filesystem paths and hostnames to the remote Pulse server — acknowledged in docs/pulse.md § Caveats (Privacy). The SHA256 fallback covers longer paths; shorter paths mirror information already sent to the gatekeeper. No action required.
STEP 6 — Complexity: COMPLEX
Touches Pulse transport realm filtering (PulseMultiplayerBus across 4 partial files), feature flag gating, 5 container wiring files, startup operation flow, and a cross-repo realm-key contract.
STEP 7 — QA: YES
Runtime multiplayer changes affecting player-visible behavior (avatar movement sync over Pulse in LSD, dual-transport wiring, feature flag default change). Scenarios B–F in the PR description require manual testing.
STEP 8 — Non-blocking warnings
None. Main.unity not modified.
STEP 9 — Verdict
Clean, well-tested feature addition. 41 tests covering realm key derivation (including cross-repo pinned vectors), boundary conditions (255-char limit), startup fallback, and feature flag semantics. The refactoring eliminates a code duplication risk between the gatekeeper and Pulse transports. Documentation is thorough. Two minor P2 findings — no blockers.
REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Touches Pulse transport realm filtering (4 partial files), feature flag gating, 5 container wiring files, startup operation flow, and a cross-repo realm-key contract.
QA_REQUIRED: YES
Reviewed by Jarvis 🤖 · Requested by mikhail-dcl via GitHub
| - **Authoritative-server previews are not on Pulse yet.** A bevy-headless server has no Pulse | ||
| transport in server mode (decentraland/sdk-multiplayer-server#132); that scenario still runs over | ||
| LiveKit. |
There was a problem hiding this comment.
[P2] Stale doc claim. The PR body explicitly corrects this — bevy-headless gained a Pulse transport in server mode (bevy-explorer #1137, the @next engine), and the end-to-end test above proves it. This bullet contradicts the PR's own evidence.
| - **Authoritative-server previews are not on Pulse yet.** A bevy-headless server has no Pulse | |
| transport in server mode (decentraland/sdk-multiplayer-server#132); that scenario still runs over | |
| LiveKit. | |
| - **Authoritative-server previews.** bevy-headless gained a Pulse transport in server mode | |
| ([bevy-explorer #1137](https://github.com/decentraland/bevy-explorer/pull/1137), the `@next` | |
| engine; `@latest` predates it), so authoritative-server scenes connect to Pulse alongside LiveKit. |
| // Pulse partitions visibility by exact realm string. Local scene development has no realm of its own, | ||
| // so each dev process derives one from the entity id its dev server serves, keeping concurrent previews apart. | ||
| var pulseRealm = new PulseRealm(staticContainer.RealmData, | ||
| localSceneDevelopment | ||
| ? new LocalSceneEntityIdSource(staticContainer.WebRequestsContainer.WebRequestController, dynamicWorldParams.LocalSceneDevelopmentRealm) | ||
| : null); |
There was a problem hiding this comment.
[P2] Redundant LocalSceneEntityIdSource construction. A second instance with identical arguments is created in CommsContainer.Create (line 123) for the gatekeeper metadata source. Both will independently HTTP-fetch the same dev-server endpoints (scene.json + content/entities/active) at startup. Consider creating the source once here and passing it into CommsContainer.Create (replacing the string localSceneDevelopmentRealm parameter with the pre-built ILocalSceneEntityIdSource?), so both transports share one instance. If caching the result in EntityAsync is added later, this also avoids a redundant network round-trip.
| // Pulse partitions visibility by exact realm string. Local scene development has no realm of its own, | |
| // so each dev process derives one from the entity id its dev server serves, keeping concurrent previews apart. | |
| var pulseRealm = new PulseRealm(staticContainer.RealmData, | |
| localSceneDevelopment | |
| ? new LocalSceneEntityIdSource(staticContainer.WebRequestsContainer.WebRequestController, dynamicWorldParams.LocalSceneDevelopmentRealm) | |
| : null); | |
| // Pulse partitions visibility by exact realm string. Local scene development has no realm of its own, | |
| // so each dev process derives one from the entity id its dev server serves, keeping concurrent previews apart. | |
| // NOTE: CommsContainer.Create (line 123) also constructs a LocalSceneEntityIdSource with the same arguments | |
| // for the gatekeeper metadata source. Consider sharing a single instance to avoid redundant HTTP fetches. | |
| var pulseRealm = new PulseRealm(staticContainer.RealmData, | |
| localSceneDevelopment | |
| ? new LocalSceneEntityIdSource(staticContainer.WebRequestsContainer.WebRequestController, dynamicWorldParams.LocalSceneDevelopmentRealm) | |
| : null); |
What does this PR change?
Local Scene Development (LSD) now carries player state over Pulse, while the local gatekeeper
LiveKit room keeps carrying Scene Messages. This is a dual-transport change, not a replacement —
comms-gatekeeper-local.decentraland.organdLocalSceneDevelopmentSceneRoomMetaDataSourceareuntouched, and nothing is decommissioned.
MessageBus, client↔client CRDT syncThe realm-key contract (cross-repo — please read)
Pulse has no rooms; it partitions visibility by exact realm-string match. Every concurrent dev
process would otherwise share one realm, so each derives a key from the preview entity id its dev
server already serves:
and past Pulse's
MaxRealmLengthof 255 it collapses deterministically:Hex casing is lowercase, and the hash is taken over
previewSceneIdincluding itsb64-prefix. The overflow form is always 75 characters. Hashed rather than truncated on purpose.
This matches js-sdk-toolchain#1554
(
logic/lsd-realm.ts) exactly — that PR was already open, so the casing is confirmed against itrather than merely declared. Node's
.digest('hex')is lowercase;PulseRealmspells the digits out (
"0123456789abcdef") instead of using culture-dependent formatting.PulseRealmShouldpins both worked examples published in that PR'sdocs/lsd-identity-and-pulse-realm.md, so a future drift on either side fails a test here:lsd:b64-L2hvbWUvZGV2L215LXNjZW5lLWRldi1ib3g=lsd:sha256:783635fb50eadaed0300d80104920bfc55894d5ad2ab69ab6b48c6ff1ddb9da5Nothing is exchanged at runtime — every party derives the key independently. That is what makes
isolation work with no paired endpoint, and it is also the failure mode: implementations that drift
do not error, their peers just never see each other (the LiveKit
preview-${sceneId}vsLocalPreview:{sceneId}bug class). bevy-explorer must match these strings byte-for-byte.The key derives from the entity id only, never a content hash — js-sdk-toolchain#1529 keeps the
project directory's own entity id path-only — so it survives content edits, hot reloads and
dev-server restarts.
Technical changes
FeaturesRegistry— dropped&& !localSceneDevelopmentfrom theFeatureId.Pulsegate. LSDresolves no remote feature flags (the flag host is the local dev server), so simply removing the
clause would have left Pulse driven by a flag that can never be on there. The fallback is now
localSceneDevelopment || featureFlags.IsEnabled(...), which is what "defaults ON in LSD" requires.--pulse falseremains the way back to LiveKit-only.PulseRealm— one concrete class every realm read/write inPulseMultiplayerBusnow goesthrough (11 sites across 4 files — more than the 6 originally listed; the extra ones are the
PlayerJoinedfilter and theRemoveWhereNotInRealmpurge). No interface and no secondimplementation: the optional
ILocalSceneEntityIdSourceconstructor argument is the mode, so itreads as "follow the realm, unless a local dev server tells us otherwise". With it null — every
session that is not local scene development —
ValueisIRealmData.RealmNameread live, sothis is a pure passthrough with no behaviour change.
PulseMultiplayerBusno longer takesIRealmDataat all; it had no other use for it.LocalSceneEntityIdSource— the dev server's two-step fetch (GET scene.json→ base parcel →POST content/entities/active→result[0].id), extracted verbatim out ofLocalSceneDevelopmentSceneRoomMetaDataSource, which now delegates to it. One definition of "thelocal scene's entity id" instead of two that can drift. It also returns the base parcel, which the
Pulse realm uses for the bounds warning below.
StartPulseMultiplayerStartupOperation— awaitsEnsureResolvedAsyncbeforeConnectAsync(the realm ships in the handshake's
PlayerInitialState.Realm, the very first message). If therealm is still empty it deactivates Pulse and returns success. Confirmed against the server:
FieldValidator.ValidateHandshakerejects an empty realm withINVALID_HANDSHAKE_FIELD, soconnecting anyway would join a session nothing can be filtered into. Resolution never throws — a
failure leaves the realm empty rather than failing log-in.
FieldValidatordisconnects peers reporting parcel indicesoutside Genesis City, so
PulseRealmlogs a warning naming the parcel and thebounds. (
sdk-commandsrefuses to start such a scene, so this only fires for dev servers it didnot launch.)
--pulse-url. The endpoint ispulse-server.{BaseDomain}, so it follows the session'senvironment — org by default,
pulse-server.decentraland.zone:7777under--dclenv zone.LocalGateKeeperSceneAdapterstays pinned to the org domain regardless of environment, so SceneMessages keep working either way. That is what makes the zone end-to-end run below possible with
no new argument.
Why there is no double delivery (dual-transport check)
Verified by reading the wiring rather than assuming:
LiveKitMessagesBroadcaster, which readsPulseActivationlive. WithPulse active it sends only to wallets in
announcedWallets— populated exclusively byAnnounceProfileVersionarriving over a LiveKit pipe. Since that announcement is itself sentthrough the same broadcaster, the set starts empty and stays empty, and
BuildMessageAndSendisskipped on the
Count > 0guard. The LiveKit player-state path self-suppresses. A peer running--pulse falsestill broadcasts to all, lands in the other'sannouncedWallets, and from then onreceives over LiveKit — mixed fleets interoperate without duplication.
AUTH_SERVER_IDENTITYisalways appended, so authoritative-server bots keep getting everything over LiveKit.
SceneCommunicationPipe, which usesmessagePipesHub.ScenePipe()directly and never consults
PulseActivation. They are structurally unaffected by this change.Both mechanisms are transport- and realm-agnostic, so they hold in LSD exactly as in Play mode.
Docs
docs/pulse.md(new "Realm —PulseRealm" section, plus the feature-flag and start-up-fallbacksections it quotes),
docs/how-to-connect-to-a-local-scene.md(new "Multiplayer in a local scene"),and touch-ups to
docs/multiplayer.mdanddocs/livekit-networking.md.Test Instructions
Use robtfm/lsd-zone-scene — an authoritative-multiplayer
scene built for exactly this feature. It spawns a Pulse-capable bevy auth server and publishes its
player roster (address + position, 1 Hz) into a synced component, so the server itself tells you
whether client state arrived over Pulse.
Prerequisites
>= 24.16; 24.15.0 worked in practice)pulse-server.decentraland.zonezone— pass--dclenv zone. The Pulse endpoint ispulse-server.{BaseDomain}and this scene's server is on zone, so a client left on the defaultorg lands in a different Pulse instance and is silently invisible — matching realm key, no
peers, no error. There is no
--pulse-urland none is needed.Step 1 — start the scene + auth server
--no-clientstops it launching its own explorer. The port is arbitrary as long as--realmbelowmatches it. Wait for:
If you see repeated
disconnected (None)and neverhandshake accepted, zone Pulse is unreachable —that is not a realm-key mismatch and not this PR. Confirm
handshake acceptedbefore testing.Step 2 — launch the build
Get the build with
metaforge explorer run 9857, then launch the executable directly so you can passargs:
Windows
macOS
--realm http://127.0.0.1:8000--position 0,0--local-scene true--dclenv zonepulse-server.decentraland.zone:7777--debug --skip-version-check trueDeliberately not passed:
--pulse. Its absence is the point — Pulse defaulting on in LSD iswhat this PR adds. Add
--pulse falseonly for scenario E.(In-Editor instead:
Main.unity→initialRealm: 6(Localhost, hardcoded to127.0.0.1:8000),targetScene: {x: 0, y: 0},decentralandEnvironment: 1(Zone),appParametersempty. The Editorreads
debugSettings.appParameters, not the command line.)Test Steps
A — client state reaches the auth server over Pulse (the core check)
emptyand names your address with aposition that changes as you walk:
server's roster proves
client → Pulse → scene-listener → server. The address alone can arriveover the LiveKit scene room and proves nothing about Pulse. A position that appears once and never
changes while you walk is a failure, not a pass.
a sphere and the rendered avatar is a presence lag.
B — two clients see each other, exactly once
that would mean LiveKit and Pulse are both applying the same movement.
C — cross-process isolation
--port 8001).--realm http://127.0.0.1:8001.both servers: each roster lists only its own client. The two servers'
realm resolved tolinesmust differ.
D — Scene Messages still work (LiveKit, untouched)
MessageBus/CRDT pathover the local gatekeeper room — this PR must not affect it.
E — opt-out parity
--pulse falseadded.will show the address but no useful position, since nothing is publishing to Pulse.
F — Pulse or dev server unreachable
LiveKit-only rather than hanging or erroring.
Additional Testing Notes
/reload, scene edits anddev-server restarts. Avatars should stay visible across all three.
index and disconnects the peer). Scene Messages still work.
sdk-commandsrefuses to start such ascene, so reproducing needs a hand-rolled dev server.
so
onEnterScenemay not fire server-side for guests (the roster heartbeat still shows them); andthe roster is a 1 Hz heartbeat, not a change feed, so a stationary avatar repeats the same position.
Quality Checklist
replacing a property read; no allocation in
BroadcastTeleportor the message handlersWhat was verified locally, and what was not
Executed — end to end against a real second implementation:
Ran
lsd-zone-scene(auth server pinned to thejs-sdk-toolchain#1565 CDN build, which
presets
PULSE_SERVER=pulse-server.decentraland.zone:7777and runs the Pulse-capable bevy@nextengine) with one Unity client from this branch, against zone.
PulseRealm.RealmKeyForindependently producedlsd:b64-RTpcRGVjZW50cmFsYW5kXGxzZC16b25lLXNjZW5lLVJldm9sdXRpb24=, decoding to<projectRoot>-<hostname>. This is the contract's one silent failure mode, now checked against anactual second implementation rather than only against published vectors.
from
emptyto the client's address with a position that followed the avatar:0.0,0.1,0.0→1.3,0.1,3.7→8.7,0.1,4.7, plus matchingenter/leave. Since avatar staterides Pulse exclusively, that is the full
client → Pulse → scene-listener → serverloop.org-connected peer.
Also executed:
error CS. EditMode suite — 41/41 passed(
PulseRealmShould,StartPulseMultiplayerStartupOperationShould,PulseMultiplayerBusRealmFilteringShould,FeaturesRegistryPulseShould,PulseActivationShould,PulseMultiplayerServiceShould,ENetTransportShould), including both published cross-repo vectorsand both sides of the 255-character boundary.
<projectRoot>-<hostname>.dev server on a fresh PID → key unchanged.
MaxRealmLength = 255, and realm validationis length-only with no charset restriction, so base64
+,/and=are safe.Not executed — please cover in QA:
visibility, and the absence of duplicate/double-moving avatars, is not.
not that two live sessions actually fail to see each other.
--pulse falseparity and the unreachable fallback are covered by unit tests,not by a running client.
One correction to an earlier claim in this PR: authoritative-server previews are no longer outside
the Pulse story. bevy-headless gained a Pulse transport in server mode
(bevy-explorer #1137, the
@nextengine —@latestpredates it), superseding the "not covered" note that referenceddecentraland/sdk-multiplayer-server#132. The evidence above is that scenario working.
🤖 Generated with Claude Code