Table of Contents

smudgy:core — Shared state & events

Generated from smudgy v0.4.0 (smudgy-core.d.ts @ 3c804cdb8fa3). Index: scriptref.

Scripts and packages share live data through three kinds of handles: states, events, and procedures. A state holds a value other scripts can read at any time, such as your hp or a roster of group members. Consumers can also watch a state for changes, or bind a widget property to one of its entries; a health bar bound to an hp entry repaints whenever the value changes, without any script running.

An event is a notification that something happened, delivered to every subscriber. Handling one with on() is much like handling a matching line with a trigger, with another package as the source instead of incoming text. Smudgy's own events are delivered the same way: smudgy:events/sys and smudgy:events/map announce connection and disconnection, sent commands, received lines, and map movement. A procedure is a function one package implements and other packages can call; a script might use one to ask a mapper package to start a speedwalk.

Packages declare their own handles with createState, createEvent, and createProcedure, and use another package's with an ordinary import.

Producing Shared State & Events

A package declares its shared surface by exporting handles from its entry module. The export is the whole declaration; there is no registration step, and the exported name is the name consumers use to find the handle.

// In the entry module of kapusniak/arctic-prompt:
import { createState, createEvent, createProcedure } from "smudgy:core";
 
export interface PromptData { hp: number; maxhp: number }
 
export const promptState = createState<PromptData>();
export const prompt = createEvent<PromptData>();
export const refresh = createProcedure((args: { full: boolean }, sender) => {
  // runs when another script posts to refresh
});

The package then publishes through the handles it exported, from any of its modules: set() or assignment through value for a state, emit() for an event. Because the exported name is the handle's identity, renaming the export renames the handle for every consumer. To keep an established name through a refactor, or to create a handle inside a function where there is no export to take a name from, pass the name explicitly: createState('promptState').

User scripts and local modules can declare handles the same way; between your own modules they can be imported directly, like any other export.

Using Shared State & Events

Consuming a handle does not involve importing the package that declared it. Each kind of handle is served by a virtual module named after the kind and the producing package:

import { promptState } from "smudgy:state/kapusniak/arctic-prompt";
import { prompt } from "smudgy:events/kapusniak/arctic-prompt";
import { refresh } from "smudgy:procedures/kapusniak/arctic-prompt";
 
const hp = promptState.value?.hp;   // read-only live view
prompt.on(p => { /* runs on each emit */ });
refresh.post({ full: true });       // fire-and-forget

A scheme module exports every handle of its kind that the producer declared, under the producer's names. Importing one does not run the producer's code and does not require the package to be installed: an absent producer's state reads as undefined, its events never fire, and posts to its procedures do nothing. What the import provides is the consumer's half of each handle:

Publishing (set, emit, a procedure's implementation) stays with the producer.

Bound state is how script-built widgets stay live. A widget property given bind() follows the published value on its own; the widget repaints as writes arrive, and no script runs in between. While the bound path has nothing published, the property shows its fallback value. A binding path cannot contain an expression, so a computed value is first published as a state of its own with createDerived, and bound like any other:

import { createDerived } from "smudgy:core";
import { createWidget, Column, Text, ProgressBar } from "smudgy:widgets";
import { vitals } from "smudgy:state/kapusniak/arctic-prompt";
 
export const hpPct = createDerived(vitals, v => v.hp / v.maxhp);
 
createWidget("health", (
  <Column>
    <Text>HP: {vitals.bind('hp', { fallback: 0 })}</Text>
    <ProgressBar value={hpPct.bind()} max={1} />
  </Column>
));

Appending a handle's name to the module path imports that handle alone, as the default export: import promptState from "smudgy:state/kapusniak/arctic-prompt/promptState". Payload types come with the same imports: each scheme module also exports a type named after each handle, so after import { prompt } from ... a handler can be typed (p: prompt) => ...; the single-handle form exports the same type as Payload.

The built-in catalogs below work identically; smudgy:events/sys and smudgy:events/map are scheme modules whose producer is smudgy itself, and GMCP data arrives the same way (see gmcp). Importing a package's own code (smudgy://owner/package) does not provide its handles. Handle exports are only served inside the producing package; a script importing them from outside gets a console notice pointing at the scheme modules.

createState

export function createState<T = unknown>(name?: string): StateHandle<T>;

Creates a shared state object. Like createEvent, the export names the state:

export interface PromptData { hp: number; maxhp: number }
 
export const promptState = createState<PromptData>();
 
promptState.set({ hp: 42, maxhp: 100 });

Consumers then get a fully typed read-only view:

import { promptState } from "smudgy:state/you/your-package";
const hp = promptState.value?.hp;

It is also possible to specify a name explicitly (export const thisStateIsCalled_promptState = createState('promptState')), which can be useful in some situations, such as if a script author wants to provide both a state and an event with the same name.

State objects are great when other scripts might need to know the current value of something, or if they might subscribe only to some changes deep in a complicated state structure.

If recipients should be notified of every occurrence, and aren't interested in comparing old vs new values, consider using an event instead.

createEvent

export function createEvent<T = unknown>(name?: string): EventHandle<T>;

Creates an event emitter. Like createState, the export names the event: the system-wide name of the event is the name of the export, and it must be exported from the top level of a package or module.

export const prompt = createEvent<PromptData>();
 
// ...
 
prompt.emit({ hp: 42, maxhp: 100 });

If you only need light event-passing within a package or module, consider using an EventEmitter from node:events instead of a system-wide event.

createProcedure

export function createProcedure<A = unknown, R = void>(
  impl: (args: A, sender: string) => R | Promise<R>,
): ProcedureHandle<A, R>;
 
export function createProcedure<A = unknown, R = void>(
  name: string,
  impl: (args: A, sender: string) => R | Promise<R>,
): ProcedureHandle<A, R>;

Creates a procedure: a function other scripts and packages can call. Because it's impossible to call functions or share data across sandboxes, procedures are the only way to expose a callable function to other scripts.

Also, because all scripts run sequentially in the same thread, calling a procedure is an asynchronous operation. The event loop on the caller side completes a full cycle, the called sandbox then runs, receiving the procedure, and then, after it in turn completes a cycle, the caller sandbox receives the result of the procedure.

export const refresh = createProcedure(async (full: boolean, sender) => {
  // the first argument is the payload, and can be any type that can be serialized to JSON.
  // the second argument is the name of the sender, which will be "user" if the sender was
  // not in a sandbox, otherwise it will be the package owning the sandbox that called us, e.g., `smudgy://foo/bar`
});

createDerived

export function createDerived<U = unknown, S = any>(
  source: StateConsumer<S>,
  compute: (snapshot: Readonly<S>) => U,
): DerivedHandle<U>;
 
export function createDerived<U = unknown, S = any>(
  name: string,
  source: StateConsumer<S>,
  compute: (snapshot: Readonly<S>) => U,
): DerivedHandle<U>;

Creates a state whose value is computed from another package's state. Especially useful for binding a computed value to a widget, since binding paths are plain lookups and can't contain expressions:

import { vitals } from "smudgy:state/kapusniak/arctic-prompt";
export const hpPct = createDerived(vitals, v => v.hp / v.maxhp);
// <ProgressBar value={hpPct.bind()} />

Like createState, the export names the handle; pass a name first (createDerived('hpPct', vitals, …)) to set it explicitly.

The computation re-runs when the source changes (once per writing turn), and the result is published under the name as your own shared state, so other scripts can bind, watch, and consume it like any state you declare. Nothing is computed while the source has no published value.

StateHandle

export interface StateHandle<T = unknown> {
  value: T;
  readonly previousValue: Readonly<T> | undefined;
  set(value: T): void;
  set(path: string, value: unknown): void;
  bind(): Binding<T>;
  bind<P extends StatePath<T> & string>(
    path: P,
    options?: BindOptions<StateAt<T, P>>,
  ): Binding<StateAt<T, P>>;
  bind(path: string, options?: BindOptions): Binding<any>;
}

A shared state owned by the current script or package, created by createState. Publish with set or by assigning through value.

Other scripts and packages get a read-only view by importing from smudgy:state/<owner>/<package> (see StateConsumer).

EventHandle

export interface EventHandle<T = unknown> {
  emit(payload: T): void;
}

An event owned by the current script or package, created by createEvent. Other scripts and packages subscribe by importing from smudgy:events/<owner>/<package> (see EventConsumer).

ProcedureHandle

export interface ProcedureHandle<A = unknown, R = void> {
  readonly __smudgyProcedure?: (args: A) => R;
}

A procedure implemented by the current script or package, created by createProcedure. Other scripts and packages call it by importing from smudgy:procedures/<owner>/<package> (see ProcedureConsumer); every call runs this implementation.

The handle has no members of its own. The implementation is passed to createProcedure, so all there is to do with the handle is export it, which names the procedure and types your callers.

DerivedHandle

export interface DerivedHandle<U = unknown> {
  readonly value: Readonly<U> | undefined;
  bind(): Binding<U>;
  bind<P extends StatePath<U> & string>(
    path: P,
    options?: BindOptions<StateAt<U, P>>,
  ): Binding<StateAt<U, P>>;
  bind(path: string, options?: BindOptions): Binding<any>;
  off(): void;
}

Returned by createDerived: read the computed value, bind it to widgets, and off() to stop computing.

StateConsumer

export interface StateConsumer<T = unknown> {
  readonly value: Readonly<T> | undefined;
  readonly previousValue: Readonly<T> | undefined;
  watch(handler: (snapshot: Readonly<T> | undefined) => void): EventSubscription;
  watch<P extends StatePath<T> & string>(
    path: P,
    handler: (snapshot: Readonly<StateAt<T, P>> | undefined) => void,
  ): EventSubscription;
  watch(path: string, handler: (snapshot: unknown) => void): EventSubscription;
  onWrite(handler: (path: string, snapshot: unknown) => void): EventSubscription;
  onWrite<P extends StatePath<T> & string>(
    path: P,
    handler: (path: string, snapshot: unknown) => void,
  ): EventSubscription;
  onWrite(path: string, handler: (path: string, snapshot: unknown) => void): EventSubscription;
  bind(): Binding<T>;
  bind<P extends StatePath<T> & string>(
    path: P,
    options?: BindOptions<StateAt<T, P>>,
  ): Binding<StateAt<T, P>>;
  bind(path: string, options?: BindOptions): Binding<any>;
}

A read-only view of another package's StateHandle. What import { … } from "smudgy:state/<owner>/<pkg>" gives you.

The two subscription verbs differ in cadence. watch coalesces each update into one delivery; onWrite replays every write:

import { vitals } from "smudgy:state/kapusniak/arctic-prompt";
// vitals is currently { hp: 20, maxhp: 100 }; its producer now
// writes, within a single update:
//   vitals.value.hp = 15;
//   vitals.value.hp = 12;
//   vitals.value.maxhp = 100;
 
vitals.watch(v => { ... });
// one delivery, after the update: v is { hp: 12, maxhp: 100 }
 
vitals.onWrite((path, value) => { ... });
// three deliveries, in write order:
//   ("hp", 15), ("hp", 12), ("maxhp", 100)
 
// In either handler, previousValue holds the value from before the
// update began:
vitals.previousValue;  // { hp: 20, maxhp: 100 }

bind

Connects the producer's published state to a widget property. The widget follows the published value on its own, repainting as writes arrive, with no handler in between (see Binding).

import { vitals } from "smudgy:state/kapusniak/arctic-prompt";
 
<ProgressBar value={vitals.bind('hp', { fallback: 0 })}
             max={vitals.bind('maxhp', { fallback: 100 })} />

While the producer has published nothing (including when it is not installed), the bound path is unpublished and the widget shows the fallback value, or nothing when no fallback was given.

EventConsumer

export interface EventConsumer<T = unknown> {
  on(handler: (payload: Readonly<T>) => void): EventSubscription;
  once(): Promise<Readonly<T>>;
  once(handler: (payload: Readonly<T>) => void): EventSubscription;
}

A subscription surface for another package's EventHandle. What import { … } from "smudgy:events/<owner>/<pkg>" (or smudgy:events/sys / smudgy:events/map) gives you.

ProcedureConsumer

export interface ProcedureConsumer<A = unknown, R = void> {
  post(args: A): void;
  readonly __smudgyProcedure?: (args: A) => R;
}

The caller's side of another package's ProcedureHandle. What import { … } from "smudgy:procedures/<owner>/<pkg>" gives you.

ConsumerOf

export type ConsumerOf<H> = H extends StateHandle<infer T>
? StateConsumer<T>
: H extends EventHandle<infer T>
  ? EventConsumer<T>
  : H extends DerivedHandle<infer U>
    ? StateConsumer<U>
    : // Last: every member of ProcedureHandle is an optional phantom, so any object
      // type matches it structurally — the earlier arms must claim theirs first.
      H extends ProcedureHandle<infer A, infer R>
      ? ProcedureConsumer<A, R>
      : never;

Maps a producer handle type to the corresponding consumer type. The generated smudgy:state/... / smudgy:events/... / smudgy:procedures/... typings use it to derive what consumers see from a package's exports; you'll rarely need to name it yourself.

Payload

export type Payload<H> = H extends StateHandle<infer T>
? Readonly<T>
: H extends StateConsumer<infer T>
  ? Readonly<T>
  : H extends EventHandle<infer T>
    ? Readonly<T>
    : H extends EventConsumer<infer T>
      ? Readonly<T>
      : H extends DerivedHandle<infer U>
        ? Readonly<U>
        : // Last for the same structural reason as in ConsumerOf.
          H extends ProcedureHandle<infer A, any>
          ? A
          : H extends ProcedureConsumer<infer A, any>
            ? A
            : never;

The payload type a handle carries, from either side: what a handler receives (state snapshots and event payloads arrive read-only), or what a procedure call sends.

import { prompt } from "smudgy:events/kapusniak/arctic-prompt";
function onPrompt(p: Payload<typeof prompt>) { ... }

Usually you won't need it: every generated module also exports each handle's payload as a type with the handle's own name, so function onPrompt(p: prompt) works directly, and single-handle subpath modules export it as Payload. This helper is for generic code.

EventSubscription

export interface EventSubscription {
  off(): void;
}

A subscription returned by an event handle's on/once or a state handle's watch. Call EventSubscription.off to stop listening.

import { connect } from "smudgy:events/sys";
const sub = connect.on(() => { ... });
// later, when you no longer care:
sub.off();

StatePath

export type StatePath<T, Depth extends number = 4> = [Depth] extends [never]
? never
: T extends readonly unknown[]
  ? never
  : T extends object
    ? {
        [K in keyof T & string]:
          | K
          | `${K}.${StatePath<T[K], [never, 0, 1, 2, 3][Depth]> & string}`;
      }[keyof T & string]
    : never;

The dotted lookup paths into T, for bind autocompletion ('hp' | 'maxhp' | 'stats.str' | …). Paths are lookups, not expressions; nesting is suggested four levels deep, and any plain string is accepted where the shape is unknown.

StateAt

export type StateAt<T, P extends string> = P extends `${infer K}.${infer Rest}`
? K extends keyof T
  ? StateAt<T[K], Rest>
  : unknown
: P extends keyof T
  ? T[P]
  : unknown;

The value type at a StatePath into T.

Binding

export interface Binding<T = unknown> {
  readonly __smudgyStoreBinding: number;
  readonly fallback?: string;
  readonly format?: string;
  readonly __smudgyBindingValue?: T;
}

A live connection from a shared-state path to a widget property, created by a state handle's bind. It is accepted wherever a widget prop takes a value:

<ProgressBar value={vitals.bind('hp')} max={vitals.bind('maxhp')} />
<Text>HP: {vitals.bind('hp')}</Text>

The widget then tracks the published value on its own. No handler runs and no re-mount happens on updates; the mounted widget simply repaints.

BindOptions

export interface BindOptions<T = unknown> {
  fallback?: T;
  format?: string;
}

Options for a state handle's bind.

events

export const events: {
  lookup(producer: string, name: string): EventConsumer<unknown>;
};

Looks up an event by name at runtime, for generic tooling that doesn't know the event ahead of time. producer is "smudgy://owner/name", "user", or a platform name ("sys", "map"); the payload is untyped. The smudgy:events/... modules serve the same handles fully typed.

Built-in events: smudgy:events/sys

connect

export const connect: EventConsumer<Record<string, never>>;

Fires when the session connects to the MUD. Empty payload.

disconnect

export const disconnect: EventConsumer<Record<string, never>>;

Fires when the session disconnects from the MUD. Empty payload.

send

export const send: EventConsumer<{ command: string }>;

Fires just before a command goes to the MUD. command is the final outgoing line, after alias expansion and command splitting.

receive

export const receive: EventConsumer<{ text: string }>;

Fires for each complete line received from the MUD, after triggers have run but before the line is displayed. text is the line as originally received; any trigger edits are applied afterward.

Inside the handler, the ambient line from smudgy:core refers to this same incoming line, so line.gag(), line.redirect(), and line.replace() work just as they do in a trigger.

Built-in events: smudgy:events/map

room

export const room: EventConsumer<{ areaId: string; roomNumber: number | null }>;

Fires when the current map location changes, whether or not a mapper package is installed. areaId is the area's UUID as a string; roomNumber is the room number, or null when the location has no specific room.

Note that the string areaId is a different representation from the AreaId pair the mapper API uses; the two are not interchangeable.

Unstable: This event is new and may change in future releases. The event itself is guaranteed to remain, but the payload, particularly the areaId, may change.


Script API reference · smudgy:core — Sessions & output · smudgy:core — Lines & buffer · GMCP · smudgy:core — Automations · smudgy:core — Saved automations · smudgy:core — Panes · smudgy:widgets · Mapper · smudgy:params