decentraland / decentraland/bevy-explorer

Server viewer: stream server state to a scene-based viewer (design + handoff)

Open
#1,198 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
27
Forks
20
Avg merge
1d 30m
Merged PRs (30d)
78

Description

## Context

[#1119](https://github.com/decentraland/bevy-explorer/pull/1119) added a windowed `--viewer`: because the engine *is* a renderer, the server draws its own ECS directly, with no snapshot protocol and no second process. [Review feedback on that PR](https://github.com/decentraland/bevy-explorer/pull/1119#issuecomment-5368695345) asked for the viewer's presentation to live in **a scene** driving the existing explorer APIs — the same pattern as the system scene, the editor scene and the movement controller — rather than as Rust in the engine.

There is a second, independent problem the window cannot solve: **you cannot run it against a deployed scene at all.** `--viewer` is refused under `--orchestrated` (co-tenant scenes physically overlap in world space, so a window would render every scene superimposed), and a deployed task has no display anyway. So for anything on `.zone` the server has to *stream* what it holds.

This issue records the architecture, shipping plan and implementation learnings from a working prototype of both halves, so it can be rebuilt without re-deriving the surprises. Filed as a handoff — the work is paused, not in flight.

---

## What you can see today on a deployed scene

| Surface | Answers |
| --- | --- |
| `GET /logs` (SSE, signedFetch) | what the scene printed |
| `GET /stats`, `/debug/stats`, `/stats-ui?token=` | fetch / CRDT / CPU / heap usage |
| — | **where the server thinks players are** |

Console output and resource counters, but nothing about world state. To see positions today you have to join the room with a real client and infer. That gap is what this design closes.

---

## Architecture

```
┌── local mode ─────────────────────────────────┐
│ headless --viewer ← the same scene, but │
│ reads the scene CRDT directly (no protocol) │
└────────────────────────────────────────────────┘

headless engine ──stdout──▶ multiplayer-server ──JSON/SSE/WS──▶ web client
@scene-state {json} GET /scene-state?sceneId=… viewer scene draws it
```

Three parts, each with a reason for its shape.

### 1. Producer — the engine emits `@scene-state` on stdout

A `PostUpdate` system in the headless binary writes one JSON line per scene.

**The engine never opens a listening socket.** Snapshots ride stdout to the orchestrator exactly like the existing `@scene-log` and `@scene-stats` lines, so the orchestrator stays the trust boundary and owns authentication. An engine that listens on a port would be a new attack surface on a process that runs untrusted scene code.

Wire format:

```json
{
"scene": "bafkrei…",
"tick": 1234,
"broken": false,
"base": [0, 0],
"players": [
{
"address": "0x…",
"name": "alice",
"position": [8.0, 0.0, 8.0],
"rotation": [0.0, 0.0, 0.0, 1.0]
}
]
}
```

Coordinates are **DCL-convention world space (+z forward)**, not bevy's. Positions are the transforms the server trusts, verbatim: server mode applies no ground snap, so a marker can sit somewhere a client would never draw an avatar — that divergence is the entire point of the tool.

Players are the scene room's foreign-player set, resolved **per scene through its own CRDT context**, so co-tenant scenes on one orchestrated engine never leak each other's presence. The server's own placeholder player is excluded (it is not a participant).

Cadence ~10 Hz per scene while the room has players, with a slow keepalive otherwise. Emission reads state the server maintains anyway — it adds no render plugins and does not disturb the measured headless resource profile. Always on when orchestrated; standalone runs opt in behind an env var, because 10 Hz of JSON drowns a developer's preview terminal.

**What the payload deliberately does not carry:** the scene's ECS/CRDT contents. No entities, components or geometry. A viewing client loads the same scene from the content server itself, so geometry never needs to cross the wire, and the stream stays small enough to be free. Full ECS inspection is a different and much larger problem (`robtfm/editor-scene` does it via `/crdt_snapshot`).

### 2. Relay — the orchestrator re-serves it

The bevy-engine backend in `decentraland/sdk-multiplayer-server` demuxes `@scene-state` lines into a sanitising per-scene store and exposes:

```
GET /scene-state?sceneId=… → SSE
GET /scene-state?sceneId=…&ws=1 → WebSocket, one JSON snapshot per message
GET /scene-state?sceneId=…&once=1 → latest snapshot as plain JSON
```

All three are fed by one per-scene subscription.

Auth mirrors `/debug/logs`: admin signed-fetch, or the read-only stats token via an `x-stats-token` header **or** a `?token=` query. The query form is not laziness — the browser `EventSource` API cannot set headers.

`404` on `ENV=prd` for everyone. Player positions are visible to room participants, not to any token holder; opening this on production needs a creator-permissioned design first (see Open questions).

### 3. Consumer — the viewer scene

A super-user SDK7 scene, seeded from the conventions in [`robtfm/editor-scene`](https://github.com/robtfm/editor-scene). It runs in two modes from **one implementation**, chosen by a `server_view` launch param read through `getParams()`:

| Mode | Runs in | Source | Used for |
| --- | --- | --- | --- |
| local | the server's own `--viewer` window | scene CRDT directly | local dev, preview scenes |
| remote | an ordinary web client | the `/scene-state` relay | anything deployed |

One implementation for both is the key property: the local window and the remote view **cannot drift from each other**. (The first attempt at this used a shared Rust presentation crate consumed by two hosts; a scene is strictly better, because there is only one implementation rather than two kept in sync.)

The scene uses the WebSocket form and falls back to polling `&once=1` if the socket cannot be opened. Players are drawn as markers, not avatars — the server holds a transform, a name and an address and nothing else, so an avatar would invent detail it does not have.

---

## How to ship this

Four independently reviewable units. Deliberately not one PR: different reviewers, different risk, different release cadence.

```
C ──(engine release + version bump)──▶ D
A ──▶ B (A/B run in parallel with C)
```

### A. The viewer scene → its own repo, ships first

Follows the `editor-scene` precedent. Nothing depends on it at build time — the engine references it only as a default *string* — so it can land and deploy immediately. Deploy to a `.dcl.eth` name; that name becomes the engine's default viewer scene.

After this, **viewer changes are a scene deploy, not an engine release.** That is the whole payoff of the scene-based design.

### B. Engine: the viewer refactor → amend #1119

This *is* the response to the review, so it belongs on the existing PR rather than a new one. The viewer module shrinks to roughly a quarter of its size: it opens the window, paces winit, adds the render/camera plugins and loads a super-user startup scene, and draws nothing itself. Adds a `--viewer-scene` override (ENS name, urn, preview URL) for iterating on the scene.

Two engine-side enablers are the non-obvious part of the diff and should be called out explicitly in the PR description:

- **`InputManagerPlugin` + `UserInputPlugin` in viewer mode.** A scene's `VirtualCamera`/`MainCamera` only sets `PrimaryCamera.scene_override`; the system that *reads* that and actually moves the camera lives in `user_input`, which headless otherwise omits. Without these the scene can ask for a camera all it likes and nothing moves. This is also the "add more of the normal client plugins" half of the review comment.
- **`PermissionType::ForceCamera` allowed in viewer mode only.** `update_camera_mode_area` gates the camera override behind it, and headless denies anything not explicitly allowed, so the request is silently refused otherwise. Production servers render nothing, so it stays denied there.

### C. Engine: the `@scene-state` emitter → separate PR

Split from B on purpose: it is production telemetry rather than a local debug window, it is the only part of this that runs on production servers, and it should not wait on viewer UX review.

### D. Relay → PR on `sdk-multiplayer-server`

Depends on C being released and the engine dependency bumped. Note this must target the bevy-engine backend work; the hammurabi backend has no `@scene-state` to demux.

Two things reviewers should look at specifically: clearing stored state whenever a scene stops being tracked (see Learnings), and that registering a `WebSocketServer` on the HTTP server component is a **server-wide** capability change rather than something scoped to this one route.

---

## Debugging a `.zone`-deployed scene

Once C and D are deployed:

```bash
export TOK=
BASE=https://multiplayer-server.decentraland.zone

# 1. find the sceneId — the stats dashboard lists scenes with world / parcel
open "$BASE/stats-ui?token=$TOK"
# (or, as an admin signed-fetch: GET /debug/processes)

# 2. what the server holds, right now
curl -s -G --data-urlencode "sceneId=$SCENE" --data-urlencode "once=1" \
--data-urlencode "token=$TOK" "$BASE/scene-state" | jq

# 3. watch it live (add &ws=1 for the WebSocket form)
curl -sN -G --data-urlencode "sceneId=$SCENE" --data-urlencode "token=$TOK" "$BASE/scene-state"
```

The one-shot JSON is usually the whole answer: `tick` (is it ticking?), `broken`, and every player at the position the server trusts — which is the number that matters when a scene misbehaves, because it is what the authoritative logic acted on.

Use `-G --data-urlencode` rather than a literal query string: scene ids are base64-ish, and a raw `+` decodes to a space and silently 404s.

For the visual version, load the web client against the same realm with the viewer scene as its `systemScene` and `server_view` pointing at the endpoint above.

Three operational notes:

- `.zone` runs `ENV=dev` **with** a stats token configured, so the dev bypass is off and the token is mandatory.
- Orchestrated engines emit unconditionally — the stream is live as soon as the bumped engine deploys, with no extra configuration.
- Pick the surface by question: **logs** = what the scene *said*, **stats** = what it *consumed*, **scene-state** = what the server *believes about the world*.

---

## Implementation learnings

Things that cost real time and are not visible from reading the code. Anyone rebuilding this should read these first.

**The player roster needs no new explorer API.** The engine already writes `PLAYER_IDENTITY_DATA` + `TRANSFORM` per player into scene CRDTs, so a scene reads the server-trusted set with plain SDK7 (`PlayerIdentityData` + `Transform`). An earlier draft of this design assumed an API addition would be required; it is not.

**Use `PrimaryPointerInfo.screenDelta`, never deltas derived from `screenCoordinates`.** Under pointer lock the cursor does not move, so `screenCoordinates` is constant and any delta computed from it is always zero — mouse look is structurally dead and it looks like "the camera ignores input".

**DCL is +Z-forward; bevy is −Z-forward.** Porting the orbit line literally puts the camera *in front of* the focus. The eye belongs at `focus + rotate((0, 0, -distance), rotation)`. Getting the sign wrong still *looks* correct while orbiting — aiming at the focus hides it — but leaves yaw/pitch describing the opposite direction, so handing over to a free camera spins the view 180°. Symptom: pressing Escape points at the sky. Make orbit and free flight share one rotation so the handover is seamless.

**Clear stored scene state whenever a scene stops being tracked** (termination, crash-loop drop, quarantine). The relay's demux gates on the tracked set, so an untracked scene silently stops updating while the endpoint keeps serving its last snapshot **as if live** — a confidently stale viewer, which is the exact failure this feature exists to prevent. Unlike logs, where a retained tail is the point, stale positions are actively misleading.

**A UI button click is an `IA_PRIMARY` press.** Binding "exit follow mode" to `IA_PRIMARY` means clicking a roster row enables follow and the same input immediately cancels it.

**`--viewer-scene` cannot take a raw source directory.** The startup-scene resolver accepts a local directory only if it contains an `about` file (an `export-static` layout); anything else falls through to an ENS lookup and 404s. The dev loop is to run the scene's own preview server and pass its URL, exactly as `editor-scene` does.

**The scene runtime has `fetch` and `WebSocket` but no `EventSource`,** so a scene cannot consume SSE. This is why the relay grew a WebSocket variant; the SSE form remains for `curl` and browser-native consumers.

**Never run two authoritative servers against one preview.** The SDK spawns its own server for auth-multiplayer scenes; if you also run a `--viewer` instance or point an orchestrator at the same scene, they all join the same room and fight. Symptom: players flickering in and out and tick counters alternating between two values.

**Treat engine stdout payloads as advisory telemetry, never as authorization input.** The relay sanitises every snapshot to a bounded numbers-and-short-strings shape before storage: address format validated, names stripped of control characters (newlines would forge SSE frames) and length-bounded, player counts capped, subscriber fanout capped. Only snapshots for currently-tracked scenes are accepted, so an unknown label cannot allocate a buffer that retention will never evict.

**Scene input is a fixed action set**, so some native bindings have no equivalent: wheel zoom has to become keys, and there is no ctrl-to-slow modifier. Budget for the viewer's controls being *similar to* rather than identical to a native implementation.

---

## Open questions / not done

- **Production auth.** `/scene-state` is closed on `ENV=prd`. A creator-permissioned design is needed — signed-fetch like `/logs`, except neither `EventSource` nor a scene's `fetch` can sign the way `/logs` expects, so this probably wants a short-lived minted stream token.
- **Remote mode has not been exercised in a browser.** The transport was verified with a WebSocket probe and local mode was verified in the window, but the end-to-end remote path is unproven.
- **No interpolation.** Snapshots step at 10 Hz; the viewer should lerp between them.
- **Local viewer mode stays single-scene** — `--viewer` remains refused under `--orchestrated`. The streaming path is what covers the multi-tenant case, and that asymmetry is intentional.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.