Skip to content

Plugin Guide

HoloMUSH supports two plugin types for extending game functionality:

Type Language Use Case Performance
Lua Lua 5.1 Simple scripts, rapid iteration Fast
Binary Go Complex logic, external APIs Fastest

Both plugin types use the same event-driven model: plugins receive events and can emit new events in response.

Lua Plugins

Lua plugins are ideal for simple game logic. They run in a sandboxed Lua VM and require no compilation.

Project Structure

plugins/my-plugin/
├── plugin.yaml    # Plugin manifest
└── main.lua       # Entry point

Manifest (plugin.yaml)

name: my-plugin
version: 1.0.0
type: lua
events:
  - say
capabilities:
  - events.emit.location
lua-plugin:
  entry: main.lua
Field Required Description
name Yes Unique identifier (lowercase, a-z0-9, hyphens)
version Yes Semantic version (e.g., 1.0.0)
type Yes Must be lua for Lua plugins
events No Event types to receive
capabilities No Required capabilities
lua-plugin Yes Lua-specific configuration

Event Handler

Lua plugins implement a single on_event function:

-- SPDX-License-Identifier: Apache-2.0
-- Copyright 2026 HoloMUSH Contributors

function on_event(event)
    -- Only respond to say events
    if event.type ~= "say" then
        return nil
    end

    -- Don't echo plugin messages (prevents loops)
    if event.actor_kind == "plugin" then
        return nil
    end

    -- Parse message from payload
    local msg = event.payload:match('"message":"([^"]*)"')
    if not msg then
        return nil
    end

    -- Return events to emit
    return {
        {
            stream = event.stream,
            type = "say",
            payload = '{"message":"Echo: ' .. msg .. '"}'
        }
    }
end

Event Structure

The event table passed to on_event contains:

Field Type Description
id string Unique event ID (ULID)
stream string Event stream (e.g., location:room1)
type string Event type (say, pose, arrive, etc.)
timestamp number Unix milliseconds
actor_kind string "character", "system", or "plugin"
actor_id string Actor identifier
payload string JSON-encoded event data

Host Functions

Lua plugins can call host functions via the holomush global:

-- Logging (no capability required)
holomush.log("info", "Plugin loaded")
holomush.log("debug", "Processing event")
holomush.log("warn", "Something unexpected")
holomush.log("error", "Failed to process")

-- Request ID generation (no capability required)
local id = holomush.new_request_id()

-- Key-value storage (requires kv.read or kv.write capability)
local value, err = holomush.kv_get("my-key")
local _, err = holomush.kv_set("my-key", "my-value")
local _, err = holomush.kv_delete("my-key")

Binary Plugins

Binary plugins are Go programs that communicate with HoloMUSH over gRPC using HashiCorp's go-plugin system. They offer maximum performance and access to Go's ecosystem.

Project Structure

plugins/my-binary-plugin/
├── plugin.yaml    # Plugin manifest
├── main.go        # Plugin source
└── my-plugin      # Compiled executable

Manifest (plugin.yaml)

name: my-binary-plugin
version: 1.0.0
type: binary
events:
  - say
capabilities:
  - events.emit.location
binary-plugin:
  executable: my-plugin

Implementation

Binary plugins import github.com/holomush/holomush/pkg/plugin and implement the Handler interface:

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 HoloMUSH Contributors

package main

import (
    "context"
    "encoding/json"

    "github.com/holomush/holomush/pkg/plugin"
)

type EchoPlugin struct{}

func (p *EchoPlugin) HandleEvent(ctx context.Context, event plugin.Event) ([]plugin.EmitEvent, error) {
    // Only respond to say events
    if event.Type != plugin.EventTypeSay {
        return nil, nil
    }

    // Don't echo plugin messages
    if event.ActorKind == plugin.ActorPlugin {
        return nil, nil
    }

    // Parse payload
    var payload struct {
        Message string `json:"message"`
    }
    if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
        return nil, nil
    }

    // Emit echo response
    responsePayload, _ := json.Marshal(map[string]string{
        "message": "Echo: " + payload.Message,
    })

    return []plugin.EmitEvent{
        {
            Stream:  event.Stream,
            Type:    plugin.EventTypeSay,
            Payload: string(responsePayload),
        },
    }, nil
}

func main() {
    plugin.Serve(&plugin.ServeConfig{
        Handler: &EchoPlugin{},
    })
}

Building

cd plugins/my-binary-plugin
go build -o my-plugin .

SDK Types

The plugin SDK provides these core types:

// Event received by plugins
type Event struct {
    ID        string
    Stream    string
    Type      EventType
    Timestamp int64     // Unix milliseconds
    ActorKind ActorKind
    ActorID   string
    Payload   string    // JSON string
}

// Event to emit in response
type EmitEvent struct {
    Stream  string
    Type    EventType
    Payload string // JSON string
}

// Event types
const (
    EventTypeSay    EventType = "say"
    EventTypePose   EventType = "pose"
    EventTypeArrive EventType = "arrive"
    EventTypeLeave  EventType = "leave"
    EventTypeSystem EventType = "system"
)

// Actor kinds
const (
    ActorCharacter ActorKind = iota
    ActorSystem
    ActorPlugin
)

Event Types

Both plugin types handle the same event types:

Communication Events

Type Description Payload
say Character speech {"message": "text"}
pose Character action/emote {"message": "text"}
system System-generated message {"message": "text"}

World Events

Type Description Payload Fields
move Character or object moved entity_type, entity_id, from_type, from_id, to_type, to_id, exit_id?, exit_name?
object_create Object created object_id, object_name, location_id
object_destroy Object destroyed object_id, object_name
object_use Object used object_id, object_name, character_id
object_examine Object examined object_id, object_name, character_id
object_give Object transferred between chars object_id, object_name, from_character_id, to_character_id

See the World Model Design for complete payload specifications.

Capabilities

Plugins declare required capabilities in their manifest. The capability system uses glob patterns:

Pattern Matches
events.emit.* Direct children only
events.emit.** All descendants
world.read.location Exact match only
kv.read Key-value read access
kv.write Key-value write access

Example capabilities:

capabilities:
  - events.emit.location # Emit events to locations
  - kv.read # Read from key-value store
  - kv.write # Write to key-value store

Best Practices

Avoid Echo Loops

Always check actor_kind to avoid responding to your own events:

if event.actor_kind == "plugin" then
    return nil
end
if event.ActorKind == plugin.ActorPlugin {
    return nil, nil
}

Return Early

If an event doesn't match your criteria, return immediately:

if event.type ~= "say" then
    return nil
end
if event.Type != plugin.EventTypeSay {
    return nil, nil
}

Handle Missing Data

Check for missing or invalid data in payloads:

local msg = event.payload:match('"message":"([^"]*)"')
if not msg or msg == "" then
    return nil
end
var payload struct {
    Message string `json:"message"`
}
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
    return nil, nil // Skip invalid payloads
}
if payload.Message == "" {
    return nil, nil
}

Keep Handlers Fast

Plugin handlers have a 5-second timeout. If exceeded:

  • The call fails with a timeout error
  • The event is skipped for that plugin
  • The plugin continues receiving future events

For slow operations, consider caching results or offloading work to external systems.

Stream Patterns

Events are organized into streams. Subscribe to streams using patterns:

Pattern Matches
location:* All location events
location:room1 Specific location only
global:* All global events
character:* All character-specific events
* Everything

Example: Dice Roller

A complete example showing both plugin types:

-- plugins/dice/main.lua
-- Responds to "roll XdY" commands

function on_event(event)
    if event.type ~= "say" then
        return nil
    end

    local msg = event.payload:match('"message":"([^"]*)"')
    if not msg then
        return nil
    end

    -- Match "roll XdY" pattern
    local count, sides = msg:match("roll%s+(%d+)d(%d+)")
    if not count then
        return nil
    end

    count = tonumber(count)
    sides = tonumber(sides)

    if count < 1 or count > 100 or sides < 2 or sides > 100 then
        return nil
    end

    -- Roll dice
    local total = 0
    local results = {}
    for i = 1, count do
        local roll = math.random(1, sides)
        total = total + roll
        table.insert(results, tostring(roll))
    end

    local result_str = table.concat(results, " + ")
    local response = string.format("Rolled %dd%d: %s = %d",
        count, sides, result_str, total)

    return {
        {
            stream = event.stream,
            type = "say",
            payload = '{"message":"' .. response .. '"}'
        }
    }
end
// plugins/dice/main.go
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "math/rand"
    "regexp"
    "strconv"
    "strings"

    "github.com/holomush/holomush/pkg/plugin"
)

var dicePattern = regexp.MustCompile(`roll\s+(\d+)d(\d+)`)

type DicePlugin struct{}

func (p *DicePlugin) HandleEvent(ctx context.Context, event plugin.Event) ([]plugin.EmitEvent, error) {
    if event.Type != plugin.EventTypeSay {
        return nil, nil
    }

    var payload struct {
        Message string `json:"message"`
    }
    if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
        return nil, nil
    }

    matches := dicePattern.FindStringSubmatch(payload.Message)
    if matches == nil {
        return nil, nil
    }

    count, _ := strconv.Atoi(matches[1])
    sides, _ := strconv.Atoi(matches[2])

    if count < 1 || count > 100 || sides < 2 || sides > 100 {
        return nil, nil
    }

    var total int
    var results []string
    for i := 0; i < count; i++ {
        roll := rand.Intn(sides) + 1
        total += roll
        results = append(results, strconv.Itoa(roll))
    }

    response := fmt.Sprintf("Rolled %dd%d: %s = %d",
        count, sides, strings.Join(results, " + "), total)

    responsePayload, _ := json.Marshal(map[string]string{
        "message": response,
    })

    return []plugin.EmitEvent{
        {
            Stream:  event.Stream,
            Type:    plugin.EventTypeSay,
            Payload: string(responsePayload),
        },
    }, nil
}

func main() {
    plugin.Serve(&plugin.ServeConfig{
        Handler: &DicePlugin{},
    })
}

Next Steps