A Godot 4.5 development harness for creating and testing Lua-based multiplayer activity plugins for the daccord platform. Load plugin source, render it in a sandboxed viewport, simulate multiple virtual users, and loop actions back so multiplayer logic can be tested locally — no server required.
- Getting started
- Editor usage
- Plugin development guide
- Deploying to a server
- Editor architecture
- Example plugins
- Tips
- Godot 4.5 or later
- The lua-gdextension addon (included in
addons/)
# Open the editor with a plugin loaded directly
godot --plugin ./games/codenames/src/main.lua
# Or launch the editor UI and use the file picker
godotThe main scene is scenes/editor.tscn.
Create a minimal plugin to verify everything works:
hello/
├── plugin.json
└── src/
└── main.lua
plugin.json:
{
"id": "hello",
"name": "Hello World",
"type": "activity",
"format": "lua",
"entry": "src/main.lua",
"canvas_size": [640, 480],
"max_participants": 1,
"lobby": false
}src/main.lua:
function _draw()
api.draw_rect(0, 0, api.canvas_width, api.canvas_height, "#1a1a2e", true)
api.draw_text(50, 50, "Hello, daccord!", "white", 24)
endgodot --plugin ./hello/src/main.luaThe editor provides a local testing environment that simulates the daccord server.
- Load plugins from Lua source files or
.daccord-pluginbundles via the file picker or--pluginflag - Reload instantly with the Reload button — no restart needed
- Simulate multiple users by adding virtual participants and switching between them
- Action loopback — actions sent via
api.send_action()are echoed back as_on_event("action", data)events, simulating server broadcast
my-activity/
├── plugin.json # Activity metadata (required)
├── src/
│ └── main.lua # Entry point (required)
├── assets/ # Images, sounds (optional)
└── build.sh # Build script (optional)
{
"id": "my-activity",
"name": "My Activity",
"type": "activity",
"format": "lua",
"entry": "src/main.lua",
"description": "A short description of your activity.",
"version": "1.0.0",
"canvas_size": [640, 480],
"max_participants": 8,
"max_spectators": -1,
"lobby": true,
"permissions": []
}| Field | Required | Description |
|---|---|---|
id |
Yes | Unique identifier (lowercase, no spaces) |
name |
Yes | Display name shown to users |
type |
Yes | Plugin type: activity, bot, theme, or command |
format |
Yes | Always "lua" for scripted plugins |
entry |
Yes | Path to the main Lua file inside the bundle |
description |
No | Short description |
version |
No | Semver version string |
canvas_size |
No | [width, height] in pixels (min 64x64, max 1920x1080) |
max_participants |
No | Maximum active players (0 = unlimited) |
max_spectators |
No | Maximum spectators (-1 = unlimited) |
lobby |
No | Whether sessions start in a lobby before running |
permissions |
No | Permissions the plugin requests (e.g. "voice_activity") |
data_topics |
No | Data topics the plugin subscribes to |
Your activity communicates with the daccord runtime through four global functions. All are optional — only define the ones you need.
Called once after the Lua source is loaded. Use this to initialize state, seed the RNG, and load assets.
function _ready()
math.randomseed(os.clock() * 1000)
bg_image = api.load_image(api.read_asset("assets/background.png"))
endCalled every frame (~60fps). Render your activity using api.draw_* functions. The canvas is cleared automatically before each frame.
Keep _draw() fast. Do logic in _input and _on_event, store results in state variables, and let _draw just read and render.
function _draw()
api.draw_rect(0, 0, api.canvas_width, api.canvas_height, "#1a1a2e", true)
api.draw_text(100, 200, "Score: " .. score, "white", 20)
endCalled on mouse or keyboard input. The event parameter is a table with a "type" field.
Mouse button event:
{
type = "mouse_button",
button_index = 1, -- 1 = left, 2 = right, 3 = middle
pressed = true, -- true on press, false on release
position_x = 320.0,
position_y = 240.0,
}Mouse motion event:
{
type = "mouse_motion",
position_x = 320.0,
position_y = 240.0,
relative_x = 2.0, -- movement delta since last frame
relative_y = -1.0,
}Key event:
{
type = "key",
keycode = 65, -- Godot keycode
physical_keycode = 65,
unicode = 65, -- Unicode codepoint (use for text input)
pressed = true,
echo = false, -- true if this is a key-repeat event
}Important: Always use bracket notation (event["type"]), not dot notation (event.type). Data from the host runtime uses dictionary-style tables.
Called when the server broadcasts an action to all participants. Every client receives every action — apply them deterministically to keep state in sync.
function _on_event(event_type, data)
if event_type == "action" then
local action = data["action"]
if action == "move" then
board[data["x"]][data["y"]] = data["player"]
end
end
endAll API functions are accessed through the global api table.
Drawing functions should only be called inside _draw(). There is a limit of 4,096 draw commands per frame.
Draw a rectangle.
| Parameter | Type | Description |
|---|---|---|
x, y |
number | Top-left corner position |
width, height |
number | Dimensions |
color |
color | Fill/stroke color (see Colors) |
filled |
boolean | true for filled, false for outline only |
Draw a filled circle.
Draw a line between two points.
| Parameter | Type | Description |
|---|---|---|
x1, y1 |
number | Start point |
x2, y2 |
number | End point |
color |
color | Line color |
width |
number | Line width in pixels |
Draw a text string. Text is left-aligned, and y is the baseline position. Font size is clamped between 4 and 128.
Draw a single pixel. For bulk pixel work, use pixel buffers instead.
Load and draw PNG, JPEG, and WebP images. Limit: 64 loaded images.
Load an image from raw byte data (typically from api.read_asset). Returns an integer handle, or -1 on failure.
local img = api.load_image(api.read_asset("assets/sprite.png"))Draw a loaded image at its original size.
Draw a loaded image stretched to fit the given dimensions.
Draw a rectangular sub-region of an image (useful for sprite sheets).
| Parameter | Type | Description |
|---|---|---|
handle |
int | Image handle from load_image |
x, y |
number | Destination position on canvas |
sx, sy |
number | Top-left corner of source region |
sw, sh |
number | Size of source region |
For per-pixel rendering (particle effects, procedural art, etc.), pixel buffers are much faster than calling draw_pixel thousands of times. Limit: 4 buffers.
Create an RGBA pixel buffer. Returns a handle, or -1 on failure.
Set a single pixel in a buffer.
Replace the entire buffer contents with raw RGBA8 byte data (row-major, 4 bytes per pixel).
Draw a buffer at its original size.
Draw a buffer stretched to fit the given dimensions.
Colors can be specified in three formats:
| Format | Example | Notes |
|---|---|---|
| Hex string | "#c62828", "#ff000080" |
Standard CSS hex (RGB or RGBA) |
| Named string | "white", "red", "black" |
Supported: white, black, red, green, blue, yellow, transparent |
| RGBA array | {0.78, 0.16, 0.16, 0.35} |
Float values 0.0-1.0. Alpha defaults to 1.0 if omitted |
Send an action to the server. The server broadcasts it to all participants (including the sender) via _on_event("action", data).
The data parameter must be a Godot Dictionary. Use the Dictionary{} constructor:
api.send_action(Dictionary{
action = "place_piece",
x = 3,
y = 5,
player = api.get_user_id(),
})Returns the current user's unique ID.
Returns the current user's role (e.g. "player", "spectator").
Returns a 0-indexed Godot Array of all participants. Each entry is a dictionary with user_id, display_name, and role fields.
local participants = api.get_participants()
local first = participants[0] -- NOT participants[1]
local user_id = first["user_id"]Returns the number of participants.
Returns a single participant by 0-based index. Returns an empty dictionary if out of bounds.
Returns the activity manifest (the contents of plugin.json).
Read a file from the activity's assets/ directory. Returns raw byte data.
local png_data = api.read_asset("assets/board.png")
local img = api.load_image(png_data)Load and play OGG Vorbis audio. Limit: 16 loaded sounds.
Load a sound from raw OGG Vorbis byte data. Returns a handle, or -1 on failure.
local click_sfx = api.load_sound(api.read_asset("assets/click.ogg"))Play a loaded sound.
Stop a playing sound.
Call a global function repeatedly at the given interval (minimum 16ms).
function tick()
elapsed = elapsed + 1
end
local timer = api.set_interval("tick", 1000) -- call tick() every secondImportant: The callback is specified by name (a string), not by reference. The function must be a global.
Call a global function once after a delay. Automatically cleaned up after firing.
Cancel a timer created by set_interval or set_timeout.
| Constant | Description |
|---|---|
api.canvas_width |
Canvas width in pixels (from plugin.json canvas_size) |
api.canvas_height |
Canvas height in pixels |
Prints to the Godot console. Useful for debugging.
Convert a Lua table to a Godot Dictionary. Required when passing structured data to api.send_action().
local d = Dictionary{ action = "move", x = 10 }Convert a Lua table (sequential, 1-indexed) to a Godot Array.
local a = Array{ "red", "blue", "green" }Load a Lua module from the activity's src/ directory. Modules are resolved by filename without extension (e.g. require("utils") loads src/utils.lua). Modules are cached after first load.
There are a few important quirks when working with data that crosses the Lua/host boundary:
- Dictionaries use bracket notation: Access fields with
data["key"], notdata.key. - Godot Arrays are 0-indexed: Unlike native Lua tables (which start at 1), arrays returned by
api.get_participants()and similar functions start at index 0. - Sending structured data: Use
Dictionary{...}andArray{...}constructors when building data to pass back to the runtime.
Activities run locally on every client. When a player takes an action:
Player input → api.send_action(data) → Server broadcasts → _on_event("action", data)
(on ALL clients, including sender)
Key rules:
- All state changes must go through actions. Don't mutate shared state directly in
_input— send an action and handle it in_on_event. - Actions must be deterministic. Given the same sequence of actions, every client must arrive at the same state. Avoid
math.random()in_on_event— generate random values on one client (typically the host) and include them in the action data. - One client is the host. The first participant is the host:
function is_host()
local first = api.get_participant(0)
return first["user_id"] == api.get_user_id()
endFor anything beyond a trivial demo, split your code into modules:
src/
├── main.lua # Entry point — lifecycle functions only
├── state.lua # Game state variables and helpers
├── constants.lua # Colors, layout values, static data
├── drawing.lua # All rendering logic
├── input.lua # Input handling
└── events.lua # Action/event processing
main.lua stays minimal:
local state = require("state")
local drawing = require("drawing")
local input = require("input")
local events = require("events")
function _ready()
-- initialization
end
function _draw()
drawing.render()
end
function _input(event)
input.handle(event)
end
function _on_event(event_type, data)
events.dispatch(event_type, data)
endModules return a table:
-- state.lua
local M = {}
M.score = 0
M.phase = "lobby"
return MActivities run in a restricted Lua environment. Only these standard libraries are available:
base(print, type, tostring, pairs, ipairs, pcall, etc.)coroutinestringmathtable
Not available: io, os, package, debug, ffi. There is no filesystem access, no network access, and no way to break out of the sandbox.
| Resource | Limit |
|---|---|
| Draw commands per frame | 4,096 |
| Loaded images | 64 |
| Pixel buffers | 4 |
| Loaded sounds | 16 |
| Canvas size | 64x64 to 1920x1080 |
| Font size | 4-128 |
| Timer minimum interval | 16ms |
A build script packages your activity into a .daccord-plugin file (a ZIP archive):
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
EXPORT_DIR="export"
BUNDLE_NAME="my-activity.daccord-plugin"
echo "==> Packaging ${BUNDLE_NAME}..."
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cp plugin.json "$TMPDIR/"
mkdir -p "$TMPDIR/src"
cp src/*.lua "$TMPDIR/src/"
if [[ -d assets ]]; then
cp -r assets "$TMPDIR/"
fi
mkdir -p "${EXPORT_DIR}"
cd "$TMPDIR"
zip -r "/tmp/${BUNDLE_NAME}" plugin.json src/ assets/ 2>/dev/null || \
zip -r "/tmp/${BUNDLE_NAME}" plugin.json src/
cd - > /dev/null
mv "/tmp/${BUNDLE_NAME}" "${EXPORT_DIR}/${BUNDLE_NAME}"
echo "==> Built: ${EXPORT_DIR}/${BUNDLE_NAME}"The resulting .daccord-plugin contains:
plugin.json— metadatasrc/*.lua— all Lua source filesassets/— images, sounds, and other static files (if present)
Once your plugin is built and tested locally, you can deploy it to a running Accord server. Plugins are installed per-space and managed through the REST API.
Upload a .daccord-plugin bundle to a space. The installing user must have the manage_space permission.
curl -X POST /api/v1/spaces/{space_id}/plugins \
-H "Authorization: Bearer <token>" \
-F "bundle=@my-activity.daccord-plugin"The server validates the bundle, extracts the manifest, and stores the plugin. A plugin.installed gateway event is broadcast to space members.
# List plugins in a space (optionally filter by type)
curl /api/v1/spaces/{space_id}/plugins?type=activity \
-H "Authorization: Bearer <token>"
# Uninstall a plugin (requires manage_space)
curl -X DELETE /api/v1/spaces/{space_id}/plugins/{plugin_id} \
-H "Authorization: Bearer <token>"
# Download the full plugin bundle
curl /api/v1/plugins/{plugin_id}/bundle \
-H "Authorization: Bearer <token>" -o plugin.zip
# Get plugin icon
curl /api/v1/plugins/{plugin_id}/icon \
-H "Authorization: Bearer <token>" -o icon.pngActivity plugins support multiplayer sessions with lobby, running, and ended states.
# Create a session (starts in "lobby" if the plugin has lobby enabled)
curl -X POST /api/v1/plugins/{plugin_id}/sessions \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"channel_id": "123"}'
# Join as a player or spectator
curl -X POST /api/v1/plugins/{plugin_id}/sessions/{session_id}/roles \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"user_id": "456", "role": "player"}'
# Start the session (host only, transitions lobby → running)
curl -X PATCH /api/v1/plugins/{plugin_id}/sessions/{session_id} \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"state": "running"}'
# Send an action to other participants (running sessions only)
curl -X POST /api/v1/plugins/{plugin_id}/sessions/{session_id}/actions \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"type": "move", "x": 10, "y": 20}'
# End a session (host or manage_space permission)
curl -X DELETE /api/v1/plugins/{plugin_id}/sessions/{session_id} \
-H "Authorization: Bearer <token>"Session state transitions: lobby → running → ended (or lobby → ended to cancel).
Plugin events are broadcast over the WebSocket gateway under the plugins intent:
| Event | Description |
|---|---|
plugin.installed |
A plugin was installed in a space |
plugin.uninstalled |
A plugin was removed from a space |
plugin.session_state |
A session was created, changed state, or ended |
plugin.role_changed |
A participant's role changed in a session |
plugin.event |
An action was relayed to session participants |
Three core GDScript files handle everything:
| File | Role |
|---|---|
scripts/editor.gd |
Editor UI: file loading, plugin reload, virtual user simulation, action loopback |
scenes/plugins/scripted_runtime.gd |
Core Lua runtime: sandboxed LuaState (via lua-gdextension), bridge api table injection, lifecycle management, timers, audio |
scenes/plugins/plugin_canvas.gd |
Draw backend: accumulates draw commands per frame, renders shapes/text/images/buffers |
The editor replaces the real server with a local action loopback:
- Plugin calls
api.send_action(data) - Editor captures the action
- Editor immediately calls
_on_event("action", data)on every loaded runtime instance
This lets you test the full multiplayer flow — including host logic, turn taking, and state sync — without deploying to a server.
Two complete example activities are included in the games/ directory:
A word-guessing party game for up to 8 players. Teams take turns giving one-word clues to help their teammates identify secret agents on a 5x5 grid.
- Team selection and role assignment (spymaster vs operative)
- Turn-based clue giving and guessing
- Spymaster view with color overlays on unrevealed cards
- Asset loading (tiled background pattern)
Classic naval combat for 2 players. Place your fleet on a 10x10 grid, then take turns firing at your opponent.
- Ship placement phase with rotation and validation
- Turn-based firing with hit/miss/sunk feedback
- Dual grid display (own board + enemy board)
- Win detection when all enemy ships are sunk
Both follow the recommended module structure (main.lua, state.lua, constants.lua, drawing.lua, input.lua, events.lua) and serve as practical references for building your own activities.
# Try them out
godot --plugin ./games/codenames/src/main.lua
godot --plugin ./games/battleships/src/main.lua
# Build a distributable bundle
cd games/codenames && ./build.sh- Start simple. Get a rectangle on screen, then add input, then multiplayer.
- Use the editor's user simulation. Add virtual users and switch between them to test multiplayer flows without deploying.
- Keep
_draw()lean. Do logic in_inputand_on_event, store results in state, and let_drawjust read and render. - Debug with
print(). Output appears in the Godot console. - Reload often. Hit the Reload button in the editor to pick up changes instantly.
- Use
pcallfor robustness. Wrap asset loading or anything that might fail:local ok, img = pcall(function() return api.load_image(api.read_asset("assets/sprite.png")) end) if ok and img >= 0 then sprite = img end
See LICENSE for details.