Mapping Events to Entities
The Event Mapping is the only part you typically write. It defines how incoming events update your scene, while transports, agents, and sessions are simply configuration.
Every mapping answers three questions:
- Which events should be processed? — selecting events
- Which entities should they affect? — resolving entities
- What should be updated? — updating entities
Anatomy of a mapping
import type { EventMapping } from "@3dverse/livelink-agent";
const mapping: EventMapping = {
// ── Which events should be processed ──────────────────────────
channel: "devices/+/telemetry", // optional: a wildcard channel pattern
when: event => event.payload !== null, // optional: a payload predicate
schema: {
/* optional JSON Schema */
},
// ── Which entities they affect ────────────────────────────────
entities: { byName: "device-{id}" },
// ── What should be updated ────────────────────────────────────
updates: event => ({
id: event.channel.split("/")[1],
update: { local_transform: { position: (event.payload as { pos: [number, number, number] }).pos } },
}),
};
Only entities and updates are required.
A source can contain multiple event types, each represented by its own mapping. Every incoming event is evaluated against every mapping, and only matching mappings are executed.
What an event looks like
Whatever the transport, a mapping sees the same envelope:
type IngestEvent = {
channel: string; // where it arrived — meaning is transport-defined
payload: unknown; // the JSON-decoded message
received_at?: Date; // when this process saw it
source_timestamp?: Date; // when the source says it was produced
metadata?: Record<string, unknown>; // qos and retain flags, stream offsets...
};
Prefer source_timestamp over received_at for staleness checks: the arrival clock says when this process saw the
event, not when the machine produced it.
Selecting events
A mapping can filter incoming events using:
channel— a wildcard pattern matched against the event's channelwhen— a predicate evaluated on the event payloadschema— optional JSON Schema validation
All three are optional, and channel and when combine: a mapping declaring neither handles every event.
By channel
channel is matched segment by segment against the event's channel. The syntax follows MQTT topic filters, but it
applies to every transport:
| Pattern | Matches |
|---|---|
+ or * | exactly one segment |
# (trailing) | one or more remaining segments — plant/# matches plant/a, not plant |
channel: "plant/+/+/motor"; // plant/line-a/3/motor ✓
channel: "plant/#"; // plant/line-a/3/motor ✓
Omit it to handle every channel.
By payload
Some sources publish every kind of event on a single channel — one Event Hub, one webhook endpoint, an OPC UA server
without aliases. when looks inside the message body and returns true for the events this mapping handles:
when: event => (event.payload as { type?: string }).type === "telemetry";
Resolving entities
Once an event has been selected, the mapping needs to determine which scene entity it should update. Choose exactly one strategy.
| Strategy | How the id finds its entity | Typical use |
|---|---|---|
byName | looks up an entity already in the scene by name, from a pattern or a function | the scene is already named after the stream's ids |
byUuid | a fixed id-to-UUID table | a small, known population — the named parts of one machine |
resolve | an arbitrary function returning a UUID, or null | an external lookup service, or a naming convention with exceptions |
spawn | no pre-existing entity: one is created per new id, from a template | the stream defines the population — a device joining a fleet |
They are mutually exclusive: set exactly one.
byName (recommended)
The lightest option, and the one to reach for first. The pattern embeds an {id} placeholder:
entities: {
byName: "cell-{id}";
}
The id line-a-3 then drives the entity named cell-line-a-3. Only the literal token {id} is substituted — there is
no {0}, no {payload.serial}, no expression syntax. When the name is not a simple substitution, use the function
form:
entities: {
byName: ({ id }) => `cell-${id.toUpperCase()}`;
}
Whenever possible, name your scene entities using the same identifiers as your live data. This avoids maintaining UUID lookup tables and keeps mappings simple — see preparing a scene for live data.
byUuid
A fixed table, for a closed population you know up front:
entities: {
byUuid: {
"servo-01": "1f3c9a4e-...",
"servo-02": "8b21d0f7-...",
},
}
resolve
Anything the two above cannot express — an external lookup, a normalization step, an id that needs decoding. It may be
asynchronous, and returns null for an id that addresses nothing:
entities: {
resolve: async ({ id, event }) => (await assetRegistry.lookup(id))?.entity_uuid ?? null,
}
byName, byUuid and resolve all find entities that are already in the scene, and all accept a linkage — the
chain of scene references leading to them, for entities that live inside a sub-scene:
entities: { byName: "{id}", linkage: [SUB_SCENE_UUID] }
spawn
When the stream itself defines the population, there is nothing to look up. spawn creates one entity per new id, from
a template, the first time that id is seen:
entities: {
spawn: {
name: "AGV-{id}",
components: {
mesh_ref: { value: MESH_UUID },
local_transform: { position: [0, 0, 0] },
},
options: { delete_on_client_disconnection: true },
},
}
One entity is created per distinct id, the first time that id appears; every later event carrying the same id reuses
it. name substitutes {id} exactly as byName does, so a stream carrying the ids 07 and 12 yields two entities,
AGV-07 and AGV-12.
name and components both accept a function, which receives the id and the triggering event — so an entity can be
created with values read from the very first message rather than snapping into place on the second. The function runs
once per id, on that first message only:
components: ({ id, event }) => ({
debug_name: { value: `AGV-${id}` },
local_transform: { position: initialPosition(event) },
}),
options are the usual entity creation options. delete_on_client_disconnection: true is
worth considering: the spawned entities then vanish when the agent stops, instead of accumulating in the scene.
Spawned entities are always created at the scene root, so spawn takes no linkage. An id removed by a
"delete" directive is forgotten, so the next event carrying it spawns a fresh entity.
Updating entities
The updates function receives the entire event and returns:
- one entity update
- several entity updates
nullto ignore the event
One object per event
The common case. The id can come from anywhere in the event, because the function sees all of it:
updates: event => ({
id: (event.payload as { serial: string }).serial, // from the payload
update: { local_transform: { position: [1, 2, 3] } },
});
updates: event => ({
id: event.channel.split("/")[3], // from the channel
update: { local_transform: { position: [1, 2, 3] } },
});
Several objects per event
The norm for a machine publishing a whole-state frame — one message, every axis:
entities: { byName: "{id}" },
updates: ({ payload }) => {
const { angle, height } = payload as { angle: number; height: number };
return [
{ id: "blade", update: { local_transform: { eulerOrientation: [0, angle, 0] } } },
{ id: "carriage", update: { local_transform: { position: [0, height, 0] } } },
];
},
Ignoring an event
Return null (or an empty array). This is how a mapping declines a message it matched but cannot use — a field missing,
a value out of range:
updates: event => {
const value = asNumber((event.payload as { value?: unknown }).value);
if (value === null) {
return null;
}
return { id: "gauge", update: { local_transform: { position: [value, 0, 0] } } };
},
Dropping deliberately is better than writing a NaN into a component.
Updating components
The update field is a set of component patches, keyed by component name — the same shape
entity.updateComponent takes, merged into whatever the component already holds:
update: {
local_transform: { position: [1, 2, 3] },
material_ref: { value: MATERIAL_UUID },
}
Any component works — the ones a data stream usually drives being:
- Transform
- Position, orientation and scale — motion of every kind
- Material Reference
- Recolor an entity to show a state: running, idle, in fault
- Mesh Reference
- Swap the geometry itself, for a part that changes shape
See the entity components reference for the full list.
Writes are deduplicated: the applier remembers the last patch per entity and component, and skips a write whose value is unchanged. A stream that repeats the same value at 40 Hz costs nothing after the first message.
Adding and removing components
A component the entity does not have yet is created. An update naming a missing component adds it, initialized from its defaults and your patch — there is nothing to declare up front. An existing component is merged one level deep: the top-level keys you provide are replaced wholesale, and nested objects are not deep-merged.
A mapping cannot remove a component. A patch left undefined is skipped rather than treated as a removal, and the
only removal a mapping can express is the whole-entity "delete" directive below. If you genuinely need
entity.deleteComponent(), reach for it through
the agent directly.
Whole-entity directives
When an event does not change an entity but changes whether it is there, write a directive instead of components:
| Directive | Effect |
|---|---|
"delete" | removes the entity from the scene |
"hide" | makes it invisible, keeping it in the scene |
"show" | makes it visible again |
// One boolean, two lamps.
updates: event => {
const { value } = event.payload as { value: unknown };
if (typeof value !== "boolean") {
return null;
}
return [
{ id: "Green", update: value ? "show" : "hide" },
{ id: "Red", update: value ? "hide" : "show" },
];
},
A deleted entity is forgotten, so a later event carrying the same id resolves — or respawns — it. That is what makes
"delete" the right way to model a vehicle leaving the fleet: it comes back on its own when it returns.
Broadcast is handled for you
An entity driven by a data stream should not re-broadcast those writes to other clients: the agent's own update loop already sends them, once, at a controlled rate. Doing it twice costs bandwidth and makes motion stutter.
The pipeline takes care of this. On the first successful resolution it sets auto_broadcast = false on every entity
it resolves or spawns — which suppresses the additional broadcast pass, not the live update every client sees. There
is nothing to configure, and nothing to remember, as long as the ingestion layer is the thing touching the entity.
Pass manage_auto_broadcast: false to the pipeline to take this over yourself.
Validating the payload
schema is a JSON Schema for the event type. The first matching event is validated against it — a cheap sanity
check that the stream carries what the mapping reads:
schema: {
type: "object",
properties: { rpm: { type: "number" }, temp_c: { type: "number" } },
required: ["rpm", "temp_c"],
},
Without it, a renamed field is silently ignored and the scene simply never moves. With it, you get an error the first time a message arrives.
Validation is optional, and so is its validator: run npm install ajv if any of your mappings declares a schema. It
is imported lazily, only when a schema is actually used.
How resolution is cached
An id is resolved to an entity once per scene, then served from cache — including failures, so an id that maps to nothing is looked up once rather than on every event.
The cache mostly invalidates itself from the scene's own events: an id that resolved to nothing is retried when an entity carrying the name or UUID it was looking for appears, and resolutions pointing at entities another client deleted are dropped. You should not have to think about it.
One consequence is worth knowing during bring-up: an id that resolves to nothing is counted under the
unresolved_entity drop reason, and that is normal at startup in a scene where a byName mapping runs alongside a
spawn mapping — retained status messages, for instance, arrive before any telemetry has spawned the entities they
refer to. They resolve on their own as soon as the entities appear.
Testing a mapping
IngestionPipeline has no dependency on an agent, a session or a broker. Bind a scene and push an event in:
const pipeline = new IngestionPipeline({ mappings: telemetryMapping });
pipeline.bind({ scene });
await pipeline.ingest({ channel: "devices/dev-01/telemetry", payload: { pos: [1, 2, 3] } });
That is the whole data layer. It makes a mapping straightforward to unit-test, to drive from a webhook or a REST handler, and to step through one frame at a time from a debug control.
Next steps
- Data sources — where the events come from.
- Running an agent — sessions, statistics, and update rates.