# Message bus (`DiscoVR.bus`)

When several parts of an experience need to talk to each other - a button that opens a door, a trigger that starts a video, a score shown on a sign - plain DOM events get awkward: you need a reference to the other object's element, you have to wait for it to exist, and a message sent before the listener is ready is lost. `DiscoVR.bus` is a small page-wide message bus built for exactly that.

`DiscoVR.bus` exists as soon as `discovr-core.js` has run, **before the scene does**, so a script can subscribe at load time. It works without creating a `DiscoVR` instance.

```js
DiscoVR.bus.on("door:open", function (payload) {
    console.log("opening at speed", payload.speed);
});

DiscoVR.bus.emit("door:open", { speed: 2 });
```

## Topics

A topic is a string without spaces, conventionally `area:action` (`door:open`, `score`, `quiz:answer:correct`).

In a **subscription**, `*` is a wildcard:

| Pattern | Matches |
|---|---|
| `door:open` | exactly `door:open` |
| `door:*` | everything below `door:` - `door:open`, `door:open:slow` |
| `*:open` | one segment, then `open` - `door:open`, `lamp:open` |
| `door:*:done` | `door:open:done` (one segment in the middle) |
| `*` | every message (handy for logging) |

Emitted topics are always literal.

## API

| Call | What it does |
|---|---|
| `bus.on(pattern, handler, options?)` | Subscribe. `handler(payload, message)`. Returns a function that unsubscribes. Retained messages (below) are delivered immediately unless `{ replay: false }`. |
| `bus.once(pattern, handler)` | Like `on`, for the first message only. |
| `bus.off(pattern, handler)` | Unsubscribe by handler. |
| `bus.emit(topic, payload?, options?)` | Publish. Returns how many handlers received it. `{ retain: true }` keeps it as the topic's current value. |
| `bus.set(key, value)` / `bus.get(key, fallback?)` / `bus.has(key)` | Shared state: `set` is `emit(key, value, { retain: true })`. |
| `bus.watch(pattern, handler)` | Subscribe *including the current value* - "keep this in sync". |
| `bus.clear(topic?)` | Forget the retained value of a topic (or of all). |
| `bus.forward(element, eventName, topic, options?)` | Publish a DOM event of an element as a bus message. |
| `bus.scope(component?)` | A handle that removes all its subscriptions together (see below). |
| `bus.getHistory()` / `bus.topics()` | The last 200 messages / every topic seen so far. |

`message` is `{ topic, payload, time, retained, replayed }`. Handlers run synchronously, in subscription order. A handler that throws is logged and does not stop the others. A chain of more than 32 nested emits is cut and logged (it is almost always a message loop).

## Retained messages: never "too early"

```js
DiscoVR.bus.set("score", 10);                       // somewhere, early

// ...much later, from another script or component - still gets 10 right away:
DiscoVR.bus.watch("score", function (value) { sign.setAttribute("value", "Score: " + value); });
```

## Cleaning up automatically

Inside an A-Frame component, subscribe through a scope: everything is removed when the component - or the whole entity - is removed, so `remove()` has nothing to undo.

```js
AFRAME.registerComponent("door", {
    init: function () {
        this.bus = DiscoVR.bus.scope(this);
        this.bus.on("door:open", this.open.bind(this));
    },
    open: function () { this.el.setAttribute("visible", false); }
});
```

Outside components, `var scope = DiscoVR.bus.scope(); ... scope.destroy();`.

## Wiring without code: the `i-bus` component

```html
<!-- click -> publishes "door:open" -->
<a-box class="raycastable" i-bus="on: click; emit: door:open"></a-box>

<!-- hears "door:open" -> hides itself -->
<a-entity id="door" i-bus="listen: door:open; do: hide"></a-entity>
```

A `sound` entity already publishes the DOM event `sound-ended` when it finishes by itself, so `on: sound-ended; emit: ...` chains sounds or actions.

An entity can carry several rules: `i-bus__open="..."`, `i-bus__close="..."`.

| Property | Meaning |
|---|---|
| `on` / `emit` | Publish `emit` when this entity gets the DOM event `on` (`click`, `mouseenter`, `animation-finished`, ...). |
| `payload` | Constant payload (JSON is parsed: `5`, `true`, `{"speed":2}`; anything else is a string). Without it the payload is `{ source: <entity id>, detail: <event detail> }`. |
| `retain` | Keep the published message as the topic's current value. |
| `listen` / `do` | React to a topic or pattern with an action (below). |
| `target` | CSS selector of the entity the action applies to (default: this entity). Looked up when the message arrives, so it may be created later. |
| `attribute`, `value` | For `do: set`. `attribute` may be `component.property` (`text.value`); `value: $payload` uses the message's payload. |
| `volume`, `rate` | For `do: sound-play`, `sound-once`: volume (0-1) and playback speed for that play (negative volume / 0 rate = leave as configured). |
| `value`, `duration`, `easing`, `relative` | For `do: move`, `rotate`, `scale`: `value` is `"x y z"` (or `$payload`: an `"x y z"` string or `{x, y, z}`), reached over `duration` ms (default 1000; 0 = jump) with `easing` (default `easeInOutQuad`). `relative: true` adds to the current position/rotation (and multiplies the current scale). A new move on the same property restarts an unfinished one. |

Actions: `show`, `hide`, `toggle` (visibility); `set`; `xray-on`, `xray-off`, `xray-toggle` (the target's `i-xray`); `animation-play`, `animation-pause`, `animation-toggle` (the target's `animation-mixer`); `mixer-set`, `mixer-toggle` (the audio mixer, below); `move`, `rotate` (degrees), `scale` (animated transforms); `sound-play` (restarts), `sound-stop`, `sound-toggle` (the target's `sound` component); `sound-once` (plays the audio file in `value` - `#assetId` or a URL - once, flat, without needing a sound entity); `emit` (publish the topic in `value` with the same payload - chain topics).

## Sound zones: `i-sound-zone`

Makes an entity's `sound` play only while the visitor is inside an area, fading in on entry and out on exit - room ambience, or narration that starts when someone comes close:

```html
<a-entity sound="src: #rain; loop: true; positional: false" i-sound-zone="shape: box; size: 10 4 10; fade: 2"></a-entity>
```

| Property | Meaning |
|---|---|
| `shape`, `radius`, `size` | `sphere` (radius in meters) or `box` (full size `x y z`), measured in the entity's own space - moving, rotating or scaling the entity moves, turns or scales the zone. |
| `fade` | Seconds to fade in / out (0 = instant). |
| `volume` | Volume while fully inside. |
| `restart` | `true`: every entry starts the sound again from the beginning. |
| `target` | CSS selector of the entity that counts as the visitor (default: the active camera). |
| `topic` | Bus topic prefix, default `zone:<entity id>`: publishes `<topic>:enter` and `<topic>:exit`. |

The entity also gets the DOM events `zone-enter` / `zone-exit` (usable as `on:` in an `i-bus` rule). The zone decides when the sound plays - its `autoplay` is overridden.

## The audio mixer: `i-audio-mixer` and `i-sound-channel`

Every sound belongs to a channel - `effects`, `ambience`, `music` or `narration` - and is heard at `its own volume x master x its channel`. The scene's `i-audio-mixer` (created automatically; set it yourself to choose the starting mix) holds the gains and changes them while the page runs:

```html
<a-scene i-audio-mixer="master: 0.8; music: 0.4"> ...
<a-entity sound="src: #theme; loop: true" i-sound-channel="channel: music"></a-entity>
```

```js
DiscoVR.bus.emit("mixer:music", 0.2);                           // fade to 20%
DiscoVR.bus.emit("mixer:master", { value: 0, duration: 1500 }); // mute over 1.5 s (duration in ms, 0 = at once)
DiscoVR.bus.emit("mixer:music", { toggle: true });              // mute / restore the previous level
```

The background music (`audio:bgMusic`) is on the `music` channel. In `i-bus` rules use `do: mixer-set; attribute: music; value: 0.2; duration: 500` and `do: mixer-toggle; attribute: master`. A command sent before the scene has finished loading is not delivered. An entity with `i-sound-zone` has the mixer applied to its zone volume.

## Messages from outside: `i-net-feed`

An outside system (a sensor gateway, a PLC bridge, a customer's backend) can drive a scene through DiscoVR NET. The scene listens with `i-net-feed`:

```html
<a-scene i-net-feed="server: wss://net.discovr-engine.eu; room: plant-3; channels: telemetry, alarm">
```

Every message the outside system broadcasts on one of those channels becomes a bus message whose topic is the channel and whose payload is its data, retained by default (`retain: false` to turn that off). From there, `i-bus` rules or scripts react to it: `i-bus="listen: alarm; do: show"` on a warning sign, or `do: set` with `value: $payload` to use the data itself as a value (send a string or a number for that; an object is for scripts, `DiscoVR.bus.on("telemetry", (data) => data.temp)`).

The outside system is a plain WebSocket client, in any language:

```js
const ws = new WebSocket("wss://net.discovr-engine.eu");
ws.onopen = () => {
  ws.send(JSON.stringify({ event: "joinRoom", data: { room: "plant-3", app: "default" } }));
  setInterval(() => ws.send(JSON.stringify({
    event: "broadcast",
    data: { type: "telemetry", data: { temp: 23.5 } },
  })), 1000);
};
```

- The page joins as a NET *listener*: it doesn't need multiplayer or a player rig, isn't a person in the room, and receives only the listed channels. It never sends anything.
- Messages reach the pages open at that moment; a page opened later gets the next one (send at regular intervals, or the last value is missing until then).
- `net-feed:status` (retained) says `connected` / `disconnected`; the feed reconnects on its own.
- Anyone who knows the server and the room can send. Don't drive anything sensitive with it until NET has client keys.

## Showing a value: `i-gauge`

A round gauge (an arc that fills from `min` to `max`, a needle, the number with its unit, and a label) that follows a topic:

```html
<a-entity position="0 1.5 -2" i-gauge="topic: bio:heart-rate; label: Heart rate; min: 40; max: 160; decimals: 0; unit: bpm"></a-entity>
```

It shows the topic's retained value at once, then every new message. The payload can be a number, a numeric string, or an object with a `value` field (`{ value: 0.7 }`). Until the first message it shows `--`; while `<topic>:signal` is `lost` it turns grey and shows `--` too, so a source can say it has lost its sensor with `DiscoVR.bus.set("bio:heart-rate:signal", "lost")` (and `"ok"` afterwards).

Without a `topic` it shows a fixed `value` instead (`i-gauge="value: 72; max: 100; unit: %; decimals: 0"`), which a rule can still change: `i-bus="listen: load; do: set; attribute: i-gauge.value; value: $payload"`.

Other properties: `radius` 0.3 (meters), `thickness` 0.05, `sweep` 240 (degrees of arc), `needle` true, `smoothing` 0.4 (seconds to glide to a new value, 0 = jump), and the colors `colorLow` / `colorHigh` (the fill fades from one to the other along the range), `trackColor`, `lostColor`, `textColor`. The unit follows the number after a space (`72 bpm`), except a unit starting with `%` or `°` (`72%`, `21.5°C`). Example: `public/examples/gauge/`.

## Debugging

With `debug="true"` on the `<discovr>` tag, emitting a topic nobody listens to prints a warning - with a "did you mean ...?" hint when a similar topic exists. (A lone `*` monitor does not count as listening, so a logger can't hide a typo.) To watch all traffic:

```js
DiscoVR.bus.on("*", function (payload, message) {
    if (!message.replayed) console.log(message.topic, payload);
}, { replay: false });
```

A complete example is in `examples/bus/`.
