Appearance
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.mdextension.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." % nEnable 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
| Field | Required | Meaning |
|---|---|---|
id | yes | ^[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_version | yes | Semver string; api_version is an array of supported API integers. |
author, official, description | no | Shown in the launcher and the marketplace. |
depends | no | { "chat": "^1.0.0" }. Semver ranges: exact, ^, ~, >=/<, *, ||. |
optional | no | Same shape. Orders load after them when present; version mismatch is a warning. |
conflicts | no | Array of ids that cannot be enabled together with this one. |
server, client | no | Script paths relative to the folder. server.gd must extends ServerExtension, client.gd must extends ClientExtension. |
config | no | Path to the config schema. |
client_code | no | false means the client half is data and scenes only (safe from untrusted authors). |
permissions | no | Declarative 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.
- discover: manifests read and validated.
- resolve: dependency graph built, versions checked, cycles and conflicts detected. Errors are plain sentences the launcher shows ("Guilds needs Chat turned on.").
- load: scripts instantiated as nodes under
Ext,content/registered with the content loader,assets/registered with the style resolver. on_register(reg: Registrar): add to registries. Nothing else may happen here.- SQL migrations in
migrations/*.sqlrun (server only), thenon_migrate()for code migrations. - Content loaded and validated (all kinds are known by now).
on_ready(ctx): subscribe to events, read config, start timers, spawn entities (zones are loaded by now).ctxhasworld_dir(server) andis_server. Core emitsworld:ready {zones}right after every extension is ready.on_config_changed(key, value): a host changed anx-livevalue at runtime.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):
| Helper | Does |
|---|---|
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>.
| Call | Registers |
|---|---|
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:
| Event | Payload | Side |
|---|---|---|
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 fields | server |
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 Hz | server |
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 | `{entity | null, 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 Pathfinder | client |
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 clientsender_peeris 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_statecan beNet.PeerState.AUTHED), a per-peer token bucket (rateper 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 withAuth.owns(...), range withAuth.in_range(a, b, tiles), roles withAuth.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;propsmay carryname,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.datanever 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 isEntity.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.entitiesmirrors what the server replicated;World.local_entity()is you.World.view_of(id)is theEntityViewnode (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_forper-character variants. - Structured: ship
migrations/001_init.sql,002_...sql. Table names must start withext_<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 withreg.persist_type. - Disabling an extension keeps its data. The launcher's "purge extension data" calls
Store.purge(id).
Config
config.schema.jsonis JSON Schema withtitle,description,default,enum,minimum,maximum,x-live. The launcher renders a form; values live inworlds/<name>/config/<id>.json.cfg("key")returns the set value or the schema default.x-live: truekeys are applied at runtime throughon_config_changed; the rest show a restart badge in the launcher.x-client: truekeys are sent to clients in the manifest so the client half'scfg()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 callUI.register_panel(...)/UI.mount(slot, node)yourself inon_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
- Write
config.schema.jsonfirst, even if it has one field. server.gd: registrations inon_register, subscriptions inon_ready.- A
client.gdand scenes only if the feature needs UI or effects. Setclient_codehonestly. - Every client-to-server message has a schema, a
rate, and a handler that re-checks authority. mmorun-server --validate <folder>reports no errors.- Load it into a clean world with only its dependencies enabled, and join with a client.
README.md: what it does, events it emits, config keys, dependencies.- Bump
version, thenmmorun-server --pack <folder>.
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.