Skip to content

Extension API (api_version 1) ​

An extension is a folder (in development) or a .pck (in distribution) with a manifest, an optional server script, an optional client script, content, assets and config. Extensions register into core's registries and subscribe to its event bus. They never patch core and never reach into each other except through declared dependencies.

This document is the contract. If something here changes in a breaking way, the API version goes up; your manifest's api_version lists the versions your extension supports, and a server refuses to load an extension that does not list its own.

Your first extension in five minutes ​

New here? Your first extension walks through this example with the shipped server, from an empty folder to a shareable .pck.

worlds/test/packs/hello/
  extension.json
  config.schema.json
  server.gd
  README.md

extension.json:

json
{
  "id": "hello",
  "name": "Hello",
  "version": "1.0.0",
  "api_version": [1],
  "author": "you",
  "description": "Greets players.",
  "server": "server.gd",
  "config": "config.schema.json",
  "client_code": false,
  "permissions": ["commands", "store"]
}

config.schema.json (standard JSON Schema; the launcher renders a form from it):

json
{
  "title": "Hello",
  "type": "object",
  "properties": {
    "greeting": {"type": "string", "title": "Greeting", "default": "Welcome, {name}!", "x-live": true}
  }
}

server.gd:

gdscript
extends ServerExtension

func on_register(reg: Registrar) -> void:
	reg.command("hello", "player", _cmd_hello, "Say hello.")

func on_ready(_ctx: Dictionary) -> void:
	on("character:enter_world", func(p: Dictionary) -> void:
		notify(p["peer_id"], str(cfg("greeting")).replace("{name}", p["entity"].name)))

func _cmd_hello(ctx: Dictionary, _args: PackedStringArray) -> String:
	var n := int(store_get("count", 0)) + 1
	store_set("count", n)
	return "Hello! Used %d times." % n

Enable it with --enable hello when you start the server (or toggle it in the launcher's Extensions tab), and restart the server after every change.

Manifest fields ​

FieldRequiredMeaning
idyes^[a-z][a-z0-9_-]{1,31}$. Must equal the folder name. Official ids are plain nouns; the marketplace shows them under the run. prefix.
name, version, api_versionyesSemver string; api_version is an array of supported API integers.
author, official, descriptionnoShown in the launcher and the marketplace.
dependsno{ "chat": "^1.0.0" }. Semver ranges: exact, ^, ~, >=/<, *, ||.
optionalnoSame shape. Orders load after them when present; version mismatch is a warning.
conflictsnoArray of ids that cannot be enabled together with this one.
server, clientnoScript paths relative to the folder. server.gd must extends ServerExtension, client.gd must extends ClientExtension.
confignoPath to the config schema.
client_codenofalse means the client half is data and scenes only (safe from untrusted authors).
permissionsnoDeclarative list shown before install (store, net, commands, ui.side_panel, ...). Not enforced in v1.

Lifecycle ​

Both halves get the same hooks in this order. Core runs them for every enabled extension in dependency order.

  1. discover: manifests read and validated.
  2. resolve: dependency graph built, versions checked, cycles and conflicts detected. Errors are plain sentences the launcher shows ("Guilds needs Chat turned on.").
  3. load: scripts instantiated as nodes under Ext, content/ registered with the content loader, assets/ registered with the style resolver.
  4. on_register(reg: Registrar): add to registries. Nothing else may happen here.
  5. SQL migrations in migrations/*.sql run (server only), then on_migrate() for code migrations.
  6. Content loaded and validated (all kinds are known by now).
  7. on_ready(ctx): subscribe to events, read config, start timers, spawn entities (zones are loaded by now). ctx has world_dir (server) and is_server. Core emits world:ready {zones} right after every extension is ready.
  8. on_config_changed(key, value): a host changed an x-live value at runtime.
  9. on_shutdown(): flush and release. Core then removes your event handlers, registry entries and commands.

Both halves, one registration. Only the server half runs server.gd and only the client runs client.gd. Registrations both sides need (content kinds the client should validate, registries the client renders, like chat channels) go in a shared.gd with a static func register(reg) that both on_register hooks call:

gdscript
# shared.gd
static func register(reg: Registrar) -> void:
	reg.registry("greetings", {"type": "object", "properties": {"text": {"type": "string"}}})

# server.gd and client.gd
const Shared := preload("shared.gd")
func on_register(reg: Registrar) -> void:
	Shared.register(reg)

Use preload with a path relative to your script rather than class_name: classes inside a pack are not registered globally when the pack is mounted at runtime.

Base class helpers ​

ServerExtension (and ClientExtension where it makes sense):

HelperDoes
cfg(key, fallback)Config.value(id, key): effective config value (set value, else schema default).
store_get(key, fallback) / store_set(key, value)Namespaced kv. Values round-trip through JSON, so integers come back as floats.
store_get_for(character_id, key) / store_set_for(...)Per-character kv.
query(sql, params)SQL restricted to your ext_<id>_* tables (see Persistence). table("x") gives the prefixed name.
on(event, handler, priority)Events.on with your id as owner (auto-removed on shutdown).
emit(event, payload)Emits <id>:<event>.
send_to(peer, msg, payload), send_all, send_zone(zone_id, ...)Server to client, msg prefixed with <id>. when it has no dot.
send(msg, payload)Client to server.
notify(peer, text, kind)A notice toast on the client (info, warn, error, system, announce).
log_info/warn/error(msg)Through Log with your id.
asset(rel) (client)Style.res("<id>/<rel>").
local_path(rel), scene(rel)Paths inside your folder.

Registrar ​

Everything is namespaced automatically: registry ids become <id>:<name>, net messages become <id>.<name>.

CallRegisters
reg.entity_type(name, {sprite, components, solid, speed, always_relevant, interactable})A spawnable type. sprite is a library path like characters/monsters/goblin.
reg.component(name, {schema, replicate})A component entities can carry. Only registered components replicate (default replicate: true).
reg.stat(name, {base, min, max})A stat for StatBlock.
reg.content_kind(kind, schema, registry)A folder name under content/ (yours and everyone else's) with its JSON Schema and, optionally, the registry entries are copied into.
reg.registry(name, entry_schema)A custom registry other extensions can write into with reg.entry(...).
reg.interaction(name, target_type, handler, label, range_tiles)handler(ctx) with ctx = {peer_id, actor, target, interaction} when a player interacts with an entity of that type in range.
reg.command(name, min_role, handler, help, usage)Slash command. handler(ctx, args) -> String. Roles: player < mod < admin < owner.
reg.net(name, direction, reliable, handler, schema, opts)See Networking.
reg.ui_slot(name, slot, scene_path, {toggle_key, title, icon, start_visible})A client panel mounted in a HUD slot.
reg.persist_type(name)An owner type for inventories / kv (e.g. guild).

Core registries you can read with Reg.lookup(registry, id) / Reg.all(registry): entity_types, components, stats, items, abilities, zones, interactions, commands, ui_slots, net_messages, persist_types, content_kinds.

Events ​

Events.on(name, handler, priority, owner); handlers run highest priority first; a handler may set payload.cancelled = true. Core emits:

EventPayloadSide
player:login / player:logout{peer_id, account}server
character:create{account_id, name, appearance, clean, cancelled, error}; set cancelled/error to refuse, edit clean to add fieldsserver
character:created, character:deleted, character:save{character} / {entity}server
character:enter_world / character:leave_world{entity, peer_id} (+ character on enter)server
character:enter_zone / character:leave_zone{entity, zone, from} / {entity, zone, to}server
entity:spawn / entity:despawn{entity}both
entity:interact{actor, target, peer_id, handled}server
entity:component{entity, component, value}client
tick{tick, dt} at 20 Hzserver
world:save, world:shutdown{tick, saved} / {}server
command:ran{name, ctx, args}server
npcs:talk{npc, actor, peer_id, def, handled} (set handled to replace the dialogue tree)server
editor:content{kind, registry, id} (client: a definition pushed by the server after an editor save was applied to the registry)client
scripts:error{script, line, message} (see Run Script; extensions add verbs with Ext.instance("scripts").verb(...))server
client:entered_world, client:target, client:click_world{} / {entity} / {pos, button, handled} (left on the ground starts click-to-move unless a handler sets handled)client
client:click_entity{entity, button, shift, handled} (any mouse button on an entity; client:target follows for left; set handled so the screen does not walk over and interact)client
client:hover`{entitynull, cursor}(setcursorto anInput.CursorShape` to change the pointer)
client:move_input{dir} every frame while no movement key is held; set dir 0..7 to steer the player directly, or goal (world position) to have the screen path-find there with Pathfinderclient

Your own events are <id>:<name>; document them in your README. Their names and payloads are part of your public API and follow your semver.

Networking ​

gdscript
reg.net("say", Net.Direction.CLIENT_TO_SERVER, true, _on_say,
	{"type": "object", "required": ["text"], "properties": {"text": {"type": "string", "maxLength": 240}}, "additionalProperties": false},
	{"rate": 4, "max_bytes": 512})
  • Handler signature on both sides: func(sender_peer: int, payload: Dictionary). On the client sender_peer is always 1 (the server).
  • Directions: CLIENT_TO_SERVER, SERVER_TO_CLIENT, BOTH.
  • Before a client-to-server handler runs, core enforces: payload size cap (default 4 KB, you may only lower it, never below 256 bytes), peer state (default: the player must be in the world; min_state can be Net.PeerState.AUTHED), a per-peer token bucket (rate per second, default 20), and your JSON Schema. Failures are dropped and counted; 20 strikes disconnects the peer.
  • Your handler must still re-check authority: look up the sender's entity with World.peer_entity(sender), check ownership with Auth.owns(...), range with Auth.in_range(a, b, tiles), roles with Auth.peer_has_role(peer, "mod"). Never trust ids the client sends to be the client's own.
  • Message ids are negotiated at handshake; adding an extension never changes core's RPCs.
  • Payloads are dictionaries serialized with var_to_bytes (no Objects). Keep them small; the snapshot channel is 10 Hz unreliable, everything else is reliable.

World and entities ​

Server:

  • World.spawn(type, zone_id, pos, props) -> Entity; props may carry name, appearance, components, data (server-private), speed, solid, always_relevant.
  • World.despawn(id), World.get_entity(id), World.peer_entity(peer), World.entities_in_zone(zone), World.players_in_zone(zone), World.nearby(entity, tiles, type), World.find_player(name), World.entities_of_type(type).
  • entity.set_component(name, value) marks it dirty; registered components replicate to interested clients at the next tick. entity.data never leaves the server.
  • World.teleport(entity, zone_id, pos), World.move_to(entity, pos), World.set_anim(entity, "attack").
  • Positions are pixels (32 px tiles), entity.tile() gives the tile. Facing is Entity.Facing (S, SE, E, NE, N, NW, W, SW).
  • Movement is server-authoritative. Extensions move non-player entities with move_to; players move themselves and the server validates every step.

Client:

  • World.entities mirrors what the server replicated; World.local_entity() is you.
  • World.view_of(id) is the EntityView node (add health bars, emotes as children).
  • Client extension code is presentation only. If it tries to change gameplay state, the server ignores it.

Persistence ​

  • Simple: store_get/store_set (kv) and the _for per-character variants.
  • Structured: ship migrations/001_init.sql, 002_...sql. Table names must start with ext_<id>_. Core runs pending migrations at boot in dependency order inside a transaction.
  • query(sql, params) parses the table names in your SQL and refuses anything outside your prefix. This is a guard against accidents, not a sandbox.
  • Inventories: Inventory.load_db(owner_type, owner_id, slot_count) / inv.save_db(). Register custom owner types with reg.persist_type.
  • Disabling an extension keeps its data. The launcher's "purge extension data" calls Store.purge(id).

Config ​

  • config.schema.json is JSON Schema with title, description, default, enum, minimum, maximum, x-live. The launcher renders a form; values live in worlds/<name>/config/<id>.json.
  • cfg("key") returns the set value or the schema default.
  • x-live: true keys are applied at runtime through on_config_changed; the rest show a restart badge in the launcher.
  • x-client: true keys are sent to clients in the manifest so the client half's cfg() sees the host's value (minimap fog, chat bubbles). Everything else stays on the server.

Content ​

Put JSON in content/<kind>/*.json. The id is <your-id>:<file basename> unless the file carries an id. Hosts override by dropping a file with the same id into worlds/<name>/content/<kind>/. Core kinds: zones, items. Register your own with reg.content_kind. See content.md for the zone format.

Art is referenced by library path, never by Godot resource path: characters/monsters/goblin, items/weapons_armor#12, props/village#3. Load it with Style.character(path), Style.icon(ref), Style.res("<owner>/<rel>").

UI (client half) ​

  • Slots: top_left, top_right, bottom_bar, side_panel, center, modal.
  • reg.ui_slot("bag", "side_panel", "res://extensions/inventory/scenes/bag.gd", {"toggle_key": "B", "title": "Bag"}) mounts the scene (or a script extending Control) hidden; the key toggles it. Or call UI.register_panel(...) / UI.mount(slot, node) yourself in on_ready.
  • Text fields must call UI.set_text_focus(true/false) on focus change so WASD stops moving the player while typing.
  • Use theme type variations (TitleLabel, MutedLabel, LightLabel, NameLabel, Slot) and never hardcode colors; style packs re-theme everything.
  • Generic panels you can reuse from res://core/ui/panels/: inventory_grid.gd, hotbar.gd, dialog_panel.gd, list_panel.gd.

Checklist before you share ​

  1. Write config.schema.json first, even if it has one field.
  2. server.gd: registrations in on_register, subscriptions in on_ready.
  3. A client.gd and scenes only if the feature needs UI or effects. Set client_code honestly.
  4. Every client-to-server message has a schema, a rate, and a handler that re-checks authority.
  5. mmorun-server --validate <folder> reports no errors.
  6. Load it into a clean world with only its dependencies enabled, and join with a client.
  7. README.md: what it does, events it emits, config keys, dependencies.
  8. Bump version, then mmorun-server --pack <folder>.

What players are asked ​

Players' clients read a pack before mounting it. Every file must sit under extensions/<id>/, and only the file types --pack writes are allowed (no .remap, no binary resources). A pack that contains any script or scene (.gd, .tscn, .tres), even server-only ones, is shown to each player before it downloads: its name, version, author and whether Run Market signed it. They choose to trust it, join without it, or disconnect. So:

  • Keep server-only code out of what clients need where you can, and say in your README what the client part does.
  • A pack that is data only (content JSON, art, sounds) downloads without asking.
  • Publishing on Run Market is what turns "not from Run Market (unverified)" into a signed name players can recognise.

Naming note ​

GDScript reserves get/set on every Object, so the store, config and registry readers are Store.get_value/set_value, Config.value and Reg.lookup.

MMO Run: host your own pixel-art MMO.