Documentation
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.
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"
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.
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
<!-- 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:
<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:
<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>
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:
<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:
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) saysconnected/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:
<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:
DiscoVR.bus.on("*", function (payload, message) {
if (!message.replayed) console.log(message.topic, payload);
}, { replay: false });
A complete example is in examples/bus/.