Skip to main content

Events

Subscribe to real-time character events using Server-Sent Events (SSE). The event system lets MODs react to pointer interactions, state changes, animation events, drag operations, and persona updates.

Import

import { Vrm } from "@hmcs/sdk";

Creating an Event Source

vrm.events() returns a VrmEventSource connected to the character's SSE stream.

const character = await Vrm.findByName("MyAvatar");
const eventSource = character.events();

Disposable Support

VrmEventSource implements the Disposable protocol. Use TypeScript's using declaration to automatically close the connection when the variable goes out of scope:

{
using eventSource = character.events();
eventSource.on("state-change", (e) => {
console.log("State:", e.state);
});
// eventSource is automatically closed at the end of this block
}

Without using, remember to call close() manually:

const eventSource = character.events();
// ... register listeners ...

// When done:
eventSource.close();

Registering Listeners

Use .on(event, callback) to register event handlers. Callbacks can be synchronous or async.

const eventSource = character.events();

eventSource.on("state-change", (e) => {
console.log("New state:", e.state);
});

eventSource.on("pointer-click", async (e) => {
console.log(`Clicked at (${e.globalViewport[0]}, ${e.globalViewport[1]})`);
console.log(`Button: ${e.button}`);
});

Event Types

State Events

EventPayloadDescription
state-change{ state: string }Character state changed (e.g., "idle", "drag", "sitting")
expression-change{ state: string }Expression changed

Animation Events

EventPayloadDescription
vrma-play{ state: string }VRMA animation started playing
vrma-finish{ state: string }VRMA animation finished

Pointer Events

EventPayloadDescription
pointer-click{ globalViewport, button }Character was clicked
pointer-press{ globalViewport, button }Mouse button pressed on character
pointer-release{ globalViewport, button }Mouse button released on character
pointer-over{ globalViewport }Mouse entered character area
pointer-out{ globalViewport }Mouse left character area
pointer-move{ globalViewport }Mouse moved within character area
pointer-cancel{ globalViewport }Pointer interaction cancelled

Drag Events

EventPayloadDescription
drag-start{ globalViewport }Drag started
drag{ globalViewport, delta }Dragging in progress. delta is the cursor movement since last event.
drag-end{ globalViewport }Drag ended

Persona Events

EventPayloadDescription
persona-change{ persona }Persona data was updated (profile, OCEAN, metadata)

Event Payloads

The globalViewport field is a [number, number] tuple representing cursor position in global screen coordinates (multi-monitor origin at the leftmost screen edge).

Mouse events include a button field with values "Primary", "Secondary", or "Middle".

Drag events include a delta field -- a [number, number] tuple with the cursor movement since the previous event.

Example: State Machine

A common pattern is using events to drive animation and behavior based on character state. This is the pattern used by the built-in Elmer MOD:

import { Vrm, repeat } from "@hmcs/sdk";

const character = await Vrm.spawn("my-mod:character");
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

const animOption = {
repeat: repeat.forever(),
transitionSecs: 0.5,
} as const;

// Start with idle animation
await character.playVrma({ asset: "vrma:idle-maid", ...animOption });

character.events().on("state-change", async (e) => {
if (e.state === "idle") {
await character.playVrma({ asset: "vrma:idle-maid", ...animOption });
await sleep(500);
await character.lookAtCursor();
} else if (e.state === "drag") {
await character.unlook();
await character.playVrma({
asset: "vrma:grabbed",
...animOption,
resetSpringBones: true,
});
} else if (e.state === "sitting") {
await character.playVrma({ asset: "vrma:idle-sitting", ...animOption });
await sleep(500);
await character.lookAtCursor();
}
});

Example: Click Counter

const character = await Vrm.findByName("MyAvatar");
let clickCount = 0;

const eventSource = character.events();

eventSource.on("pointer-click", (e) => {
if (e.button === "Primary") {
clickCount++;
console.log(`Clicked ${clickCount} times`);
}
});

eventSource.on("pointer-over", () => {
console.log("Mouse hovering over character");
});

eventSource.on("pointer-out", () => {
console.log("Mouse left character");
});

Types

class VrmEventSource implements Disposable {
on<K extends keyof EventMap>(
event: K,
callback: (event: EventMap[K]) => void | Promise<void>,
): void;
close(): void;
}

interface VrmPointerEvent {
globalViewport: [number, number];
}

interface VrmDragEvent extends VrmPointerEvent {
delta: [number, number];
}

interface VrmMouseEvent extends VrmPointerEvent {
button: "Primary" | "Secondary" | "Middle";
}

interface VrmStateChangeEvent {
state: string;
}

interface PersonaChangeEvent {
persona: Persona;
}

Next Steps