Entity Messaging
SAGE's event bus is a broadcast system. When you publish an event, every subscriber sees it. That works well for system-wide notifications, but it breaks down when you need to talk to a specific entity. You end up adding targetId checks to every processEvent(), broadcasting messages that 99% of entities ignore, and having no way to get a response back.
Entity messaging solves all three problems. It delivers messages directly to a single entity's behaviors, supports request-response, and both lookups are O(1).
Fire-and-Forget with send()
The simplest pattern. You send a message to a target entity, and its behaviors process it through processEvent() like any other event. The difference: no other entity sees it.
Example: A button activating a door
A button entity needs to open a specific door when the player interacts with it. With the event bus, you would broadcast an action:open event and every door in the level would need to check whether it is the intended target. With messaging, you send directly to the door you mean.
class ButtonBehavior extends GameEntityBehavior<{ targetDoor : string }>
{
name = 'button';
eventSubscriptions = [ 'action:interact' ];
processEvent(event : GameEvent, state : { targetDoor : string }) : boolean
{
if(event.type === 'action:interact')
{
// Send directly to the door -- no broadcast, no targetId filtering
this.entity.send(state.targetDoor, 'action:open');
return true;
}
return false;
}
}The door's behavior handles the message the same way it would handle a bus event:
class DoorBehavior extends GameEntityBehavior<{ isOpen : boolean; speed : number }>
{
name = 'door';
eventSubscriptions = [ 'action:open', 'action:close' ];
processEvent(event : GameEvent, state : { isOpen : boolean; speed : number }) : boolean
{
if(event.type === 'action:open')
{
state.isOpen = true;
this.$emitStateChanged(state, { isOpen: true });
return true;
}
if(event.type === 'action:close')
{
state.isOpen = false;
this.$emitStateChanged(state, { isOpen: false });
return true;
}
return false;
}
}Set up the entities and wire them together:
entityManager.registerEntityDefinition({
type: 'object:door',
defaultState: { isOpen: false, speed: 1.0 },
behaviors: [ DoorBehavior ],
});
entityManager.registerEntityDefinition({
type: 'object:button',
defaultState: { targetDoor: '' },
behaviors: [ ButtonBehavior ],
});
// Create the door with a name so the button can find it
await entityManager.createEntity('object:door', { name: 'vault_door' });
// Create the button, pointing it at the door by name
await entityManager.createEntity('object:button', {
initialState: { targetDoor: 'vault_door' },
});The button stores the door's name in its state. When the player interacts with the button, it sends action:open directly to vault_door. No broadcasts. No filtering.
Request-Response with request()
Sometimes you need to ask an entity a question and wait for the answer. The event bus has no response mechanism -- you would need to publish a question event and then subscribe to a separate answer event. Messaging handles this in a single call.
Example: Querying an entity for its interaction state
A player interaction system needs to check whether a chest is locked before attempting to open it:
class InteractBehavior extends GameEntityBehavior<{ targetEntity : string | null }>
{
name = 'interact';
eventSubscriptions = [ 'action:interact' ];
async processEvent(
event : GameEvent,
state : { targetEntity : string | null }
) : Promise<boolean>
{
if(event.type === 'action:interact' && state.targetEntity)
{
// Ask the target if it's locked
const result = await this.entity.request<boolean>(
state.targetEntity,
'query:is-locked'
);
if(result.success && result.value)
{
// It's locked -- notify the player
this.$emit({ type: 'ui:show-message', payload: { text: 'This chest is locked.' } });
return true;
}
// Not locked (or entity doesn't support the query) -- proceed with opening
await this.entity.send(state.targetEntity, 'action:open');
return true;
}
return false;
}
}The chest responds to the query through processRequest():
class ChestBehavior extends GameEntityBehavior<{ locked : boolean; lootTable : string }>
{
name = 'chest';
eventSubscriptions = [ 'action:open' ];
processEvent(
event : GameEvent,
state : { locked : boolean; lootTable : string }
) : boolean
{
if(event.type === 'action:open' && !state.locked)
{
this.$emit({ type: 'game:spawn-loot', payload: { table: state.lootTable } });
return true;
}
return false;
}
processRequest(
event : GameEvent,
state : { locked : boolean; lootTable : string }
) : unknown | undefined
{
if(event.type === 'query:is-locked')
{
return state.locked;
}
// Not a request we handle
return undefined;
}
}The processRequest() method works like processEvent() in ordering: SAGE calls it on each behavior in attachment order and stops at the first one that returns a non-undefined value.
Behavior-to-Behavior Messaging
Behaviors can message other entities through this.entity.send() and this.entity.request(). This is useful when entities in a parent-child hierarchy need to communicate.
Example: A weapon applying a buff to its holder
A weapon entity is a child of a character entity. When the weapon charges up, it applies a speed buff to whoever is holding it:
class ChargeWeaponBehavior extends GameEntityBehavior<{ chargeLevel : number; holderId : string | null }>
{
name = 'charge-weapon';
eventSubscriptions = [];
update(dt : number, state : { chargeLevel : number; holderId : string | null }) : void
{
if(!state.holderId) { return; }
state.chargeLevel = Math.min(1.0, state.chargeLevel + dt * 0.2);
// At full charge, buff the holder's speed
if(state.chargeLevel >= 1.0)
{
this.entity.send(state.holderId, 'action:apply-buff', {
stat: 'speed',
multiplier: 1.5,
duration: 3.0,
});
state.chargeLevel = 0;
}
}
}The holder's BuffBehavior picks up the message:
class BuffBehavior extends GameEntityBehavior<{ speed : number }>
{
name = 'buff';
eventSubscriptions = [ 'action:apply-buff' ];
processEvent(event : GameEvent, state : { speed : number }) : boolean
{
if(event.type === 'action:apply-buff')
{
const { stat, multiplier } = event.payload as { stat : string; multiplier : number };
if(stat === 'speed')
{
state.speed *= multiplier;
this.$emitStateChanged(state, { speed: state.speed });
}
return true;
}
return false;
}
}The weapon doesn't need a reference to the holder's behavior or even know that BuffBehavior exists. It sends a message, and whatever behavior on the target handles buff application takes care of it.
Error Handling with RequestResult
Every request() call returns a RequestResult<T>. Always check success before using the value.
type RequestResult<T> =
| { success : true; value : T }
| { success : false; error : string };A request fails in two cases:
- No handler -- no behavior on the target implements
processRequest()for that event type, or all of them returnedundefined. - Error thrown -- a behavior's
processRequest()threw an exception. SAGE catches it and continues to the next behavior, but if no subsequent behavior handles the request, the error message is returned.
const result = await this.entity.request<number>('turret_01', 'query:ammo-count');
if(!result.success)
{
// Could be "Entity not found", "No handler for request...", or an error message
console.warn(`Ammo query failed: ${ result.error }`);
return;
}
console.log(`Turret has ${ result.value } rounds remaining`);If the target entity does not exist (invalid ID or name), send() logs a warning and returns silently. request() returns { success: false, error: 'Entity "..." not found' }.
Address Resolution
Both send() and request() resolve the target string in two steps:
- ID lookup -- O(1) Map lookup against registered entity IDs
- Name fallback -- O(1) index lookup against entity names
You can use either:
// By ID (guaranteed unique)
await this.entity.send('8cd88e1a9b13aac0', 'action:open');
// By name (first match if multiple entities share the name)
await this.entity.send('vault_door', 'action:open');Names are more readable in level definitions and game scripts. IDs are better when you have a stored reference to a specific entity and need to guarantee you hit the right one.
When to Use Messaging vs. the Event Bus
Use the event bus when:
- Many systems need to react to the same event (
entity:died,level:loaded) - You do not know or care who is listening
- The event is a notification, not a command
Use entity messaging when:
- You need to command a specific entity (
action:openon a particular door) - You need a response (
query:is-locked,query:ammo-count) - Broadcasting would waste cycles (hundreds of entities filtering out messages not meant for them)
- Behaviors on one entity need to talk to behaviors on another entity
The two systems complement each other. A common pattern is to use messaging for the targeted interaction, then have the receiving behavior publish a broadcast event for any system that cares about the outcome:
processEvent(event : GameEvent, state : { isOpen : boolean }) : boolean
{
if(event.type === 'action:open')
{
state.isOpen = true;
// Targeted message was received -- now broadcast the result for UI, audio, etc.
this.$emit({ type: 'door:opened', payload: { entityId: this.entity.id } });
return true;
}
return false;
}