Skip to content

System Architecture

This page is for contributors who need a map of the codebase before diving in. It covers how the major subsystems relate — the event bus, gateway, plugin host, session manager, and access control layer — and links to deeper explanations where the design gets interesting.

HoloMUSH is a modern MUSH platform with an event-oriented architecture, dual protocol support (telnet + web), and extensibility through plugins.

The event bus is the heart of HoloMUSH. All game actions produce events that flow through the system:

ConceptDescription
EventsImmutable records with ULID ordering
StreamsLogical channels (typically per-location)
SubscribeClients and plugins register for event types
ReplayReconnecting clients catch up from their last seen event

Events enable:

  • Decoupling - Components communicate through events, not direct calls
  • Persistence - All actions are recorded in an append-only audit log
  • Plugins - Extensions react to and emit events

HoloMUSH uses per-stream ordering, not a global event loop. This is a deliberate architectural choice:

ApproachTrade-off
Global event loopSimple consistency, but bottleneck at scale
Per-stream orderParallelism across streams, ordering where it matters (chosen)

Guarantees:

  • Events MUST be delivered in order within a stream
  • Events across different streams have no ordering guarantee
  • Clients reconnecting MUST receive missed events in order

Why this works for MUSHes: Players in the same location need to see “Alice says hi” before “Bob says yo” if Alice spoke first. Players in different locations don’t care about each other’s event ordering — and shouldn’t be blocked by it.

Subjects use NATS dot-style notation: events.<game_id>.<domain>.<entity-id>[.<facet>...]. Producers emit domain-relative references (e.g. location.01ABC) and the host qualifies them into fully-qualified JetStream subjects on the way in.

Stream (domain-relative)Full subject formContentSubscribers
location.<id>events.<gid>.location.<id>Poses, says, arrivals, departuresCharacters in location
character.<id>events.<gid>.character.<id>Private messages, system notificationsThat character
scene.<id>.ic / scene.<id>.oocevents.<gid>.scene.<id>.ic / .oocScene IC / OOC roleplayScene participants
channel.<name>events.<gid>.channel.<name>Channel messages (future)Channel members

Movement between locations produces events in the destination stream. If departure visibility is needed in the origin location, that requires a separate event to the origin stream. Each stream maintains independent ordering.

Handles connection lifecycle with reconnection support:

Sessions persist across disconnects, allowing players to resume where they left off.

Manages the virtual world structure:

EntityDescription
LocationA place in the world
ExitConnection between locations (bidirectional)
CharacterPlayer-controlled entity with location
ObjectItems that can be in locations or inventories

Two-tier architecture for extensibility:

TierTechnologyUse Case
Lua Scriptsgopher-luaCommands, simple behaviors
Go Pluginsgo-plugin (gRPC)Complex systems (combat, AI)

Plugins declare ABAC policies and the engine enforces them:

# Plugin manifest
name: combat-system
policies:
- name: "combat-access"
dsl: |
permit(principal is plugin, action in ["subscribe", "emit"], resource is stream) when {
principal.plugin.name == "combat-system" &&
resource like "stream:location:*"
};
- name: "world-read"
dsl: |
permit(principal is plugin, action in ["read"], resource is world_object) when {
principal.plugin.name == "combat-system" &&
resource like "world:*"
};

Note: the stream:location:* form in the DSL is an ABAC resource ID using <resource_type>:<id> convention — not a pub/sub stream subject. ABAC resource IDs retain the colon separator; only pub/sub subjects use dot-style.

Three plugin runtimes exist: lua (gopher-lua VM, lightweight event handlers), binary (hashicorp/go-plugin subprocess, complex services with proto contracts), and setting (bootstrap-only, configuration plugins with no runtime).

Manifest schema (plugin.yaml): Each plugin declares requires (proto services it depends on), provides (proto services it implements), and storage (database tables it needs). The plugin loader performs DAG dependency resolution to determine load order and validates that all requires are satisfied by another plugin’s provides.

Service registry: Maps proto service names to implementations. Binary plugins register services over gRPC; Lua plugins register via the Lua host.

Plugin admin commands:

Terminal window
plugin list # List loaded plugins with name, type, version
plugin info <name> # Detailed plugin info (requires, provides, storage, commands)

HoloMUSH uses phased access control:

PhaseModelDescription
CoreStatic rolesAdmin/builder/player with fixed perms
Full ABACAttribute-basedDynamic policies with attributes

The core phase provides a simple Check(subject, action, resource) interface that the full ABAC implementation extends. Default deny — explicit permission is required for all operations.

Commands use two-layer authorization at dispatch time:

  1. Layer 1 — Command Execution: engine.Evaluate(subject, "execute", "command:<name>") — can this character run this command?
  2. Layer 2 — Capability Pre-Flight: engine.CanPerformAction(subject, action, resource, scope) per declared capability — does this character have the class of permissions this command needs?

Commands declare capabilities as structured objects:

Capabilities: []command.Capability{
{Action: "write", Resource: "location", Scope: command.ScopeLocal},
}

Scope: ScopeSelf (default, own character), ScopeLocal (current location), ScopeGlobal (server-wide).

1. Player types "say Hello"
2. Telnet/WebSocket adapter receives input
3. Session manager routes to command parser
4. Parser identifies command + args
5. Command handler validates permissions
6. Handler emits "say" event to location stream
7. Event bus delivers to:
- Other sessions in location
- Subscribed plugins
8. Sessions render event to their clients
1. Event emitted to stream
2. Event bus notifies subscribed plugins
3. Plugin receives event via runtime
4. Plugin processes, may:
- Emit new events
- Query world state
- Update plugin storage
5. Response events flow back through bus
ComponentTechnologyRationale
CoreGoPerformance, concurrency, type safety
DatabasePostgreSQLReliability, SQL power, JSONB
EventsCustom (ULID ordered)Append-only audit/notification log
TelnetGo stdlibStandard protocol support
WebSocketgorilla/websocketMature, widely used
Web ClientSvelteKit PWAModern, offline-capable
Lua Runtimegopher-luaLightweight (~40KB per instance)
Go Pluginsgo-pluginProcess isolation, gRPC transport
ObservabilityOpenTelemetryStandard tracing/metrics
  • All game state changes emit immutable, ordered events
  • World state is canonical in PostgreSQL; the event feed is an append-only audit and notification log
  • Recovery reads state from the database — see the decided model in ADR docs/adr/holomush-i4784-world-state-model-decision.md
  • Components define interfaces, not implementations
  • Enables testing with mocks
  • Supports future extensibility
  • Plugins declare ABAC policies in their manifest
  • Access Policy Engine enforces policy boundaries
  • Default deny — no seed policies for plugins

The gateway process (cmd/holomush gateway) is a thin protocol translation layer. After Phase 1.6 it holds no domain state and contains no verb registry.

  • Accepts incoming connections (telnet, web)
  • Translates protocol formats: telnet/ConnectRPC ↔ core gRPC
  • Reads RenderingMetadata off EventFrame and shapes it for the wire

Single enrichment site: RenderingPublisher

Section titled “Single enrichment site: RenderingPublisher”

All rendering metadata is stamped at the RenderingPublisher layer inside the core server — before events reach JetStream. The gateway reads the pre-stamped EventFrame.Rendering field and passes it through without any domain knowledge.

This means:

  • The gateway MUST NOT import internal/world, internal/plugin, internal/eventbus, or other domain packages (enforced by INV-GW-1).
  • Verb labels, categories, and display targets are determined once, at emit time, by the publisher chain — not at delivery time by the gateway.

See Gateway Boundary for the full forbidden-import list and the gateway_imports_test.go CI tripwire.

When wrapping http.ResponseWriter (e.g., cookie middleware), the wrapper MUST implement http.Flusher and Unwrap() — ConnectRPC server-streaming calls Flush() after each frame and will error if the interface is missing.

See web/CLAUDE.md for SvelteKit-specific patterns including theme system architecture, shadcn-svelte conventions, Tailwind v4 guidance, and Svelte 5 runes patterns.