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.

flowchart TB
subgraph Clients["External Clients"]
TC[Telnet Clients]
WC[SvelteKit PWA]
Admin[Admin Tools]
end
subgraph HoloMUSH["HoloMUSH Server"]
subgraph Gateway["Gateway Layer"]
TA[Telnet Adapter]
WS[WebSocket Adapter]
end
SM[Session Manager<br/><i>Connection lifecycle<br/>Reconnection support<br/>Character binding</i>]
EB[Event Bus<br/><i>Pub/sub with ULID ordering<br/>Stream isolation<br/>Plugin event delivery</i>]
subgraph Core["Core Services"]
WE[World Engine<br/><i>Locations, Exits<br/>Objects, Characters</i>]
AC[Access Control<br/><i>Roles & Policies</i>]
subgraph PH["Plugin Host"]
LUA[Lua Runtime<br/><i>gopher-lua</i>]
GP[Binary Plugins<br/><i>go-plugin / gRPC</i>]
end
end
CP[Control Plane<br/><i>Server management<br/>Player administration<br/>Plugin lifecycle</i>]
DB[(PostgreSQL<br/><i>Event store<br/>World state<br/>Accounts</i>)]
end
TC <--> TA
WC <--> WS
Admin <--> CP
TA --> SM
WS --> SM
SM --> EB
EB --> WE
EB --> AC
EB --> PH
WE --> DB
AC --> DB
CP --> DB

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

Concept Description
Events Immutable records with ULID ordering
Streams Logical channels (typically per-location)
Subscribe Clients and plugins register for event types
Replay Reconnecting 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:

Approach Trade-off
Global event loop Simple consistency, but bottleneck at scale
Per-stream order Parallelism 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 form Content Subscribers
location.<id> events.<gid>.location.<id> Poses, says, arrivals, departures Characters in location
character.<id> events.<gid>.character.<id> Private messages, system notifications That character
scene.<id>.ic / scene.<id>.ooc events.<gid>.scene.<id>.ic / .ooc Scene IC / OOC roleplay Scene 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:

flowchart LR
A[Connect] --> B[Authenticate]
B --> C[Select Character]
C --> D[Resume/Create Session]
D --> E[Disconnect]
F[Reconnect] --> D

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

Manages the virtual world structure:

erDiagram
Location ||--o{ Exit : "has"
Exit }o--|| Location : "leads to"
Location ||--o{ Character : "contains"
Character ||--o{ Object : "carries"
Location ||--o{ Object : "contains"
Entity Description
Location A place in the world
Exit Connection between locations (bidirectional)
Character Player-controlled entity with location
Object Items that can be in locations or inventories

Two-tier architecture for extensibility:

Tier Technology Use Case
Lua Scripts gopher-lua Commands, simple behaviors
Go Plugins go-plugin (gRPC) Complex systems (combat, AI)
flowchart TB
subgraph GoCore["Go Core"]
subgraph Lua["Lua Runtime (gopher-lua)"]
L1["In-process"]
L2["~40KB/instance"]
L3["Sandboxed"]
end
subgraph GoPlugin["go-plugin Host (gRPC)"]
G1["Process isolated"]
G2["Full language power"]
G3["External API access"]
end
end
Lua --> LuaFiles["*.lua files<br/>(drop in folder)"]
GoPlugin --> Binaries["plugin binaries<br/>(per platform)"]

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:

Phase Model Description
Core Static roles Admin/builder/player with fixed perms
Full ABAC Attribute-based Dynamic 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
Component Technology Rationale
Core Go Performance, concurrency, type safety
Database PostgreSQL Reliability, SQL power, JSONB
Events Custom (ULID ordered) Append-only audit/notification log
Telnet Go stdlib Standard protocol support
WebSocket gorilla/websocket Mature, widely used
Web Client SvelteKit PWA Modern, offline-capable
Lua Runtime gopher-lua Lightweight (~40KB per instance)
Go Plugins go-plugin Process isolation, gRPC transport
Observability OpenTelemetry Standard 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.