Designing modular plugins
How to cut a feature into plugins that stay small, replaceable and safe to combine. It
applies to SDK 2.4.0 (release candidate 1) and plugin ABI 1.1; every API name below is
in sco_api.h or a host-service header, and the rules are the
ones the host already enforces (API v1, plugin.ini,
plugin rules). This page adds the design reasoning and a
worked pair of plugins, counter_service and
counter_panel.
Contents
Section titled “Contents”- The principles in one list
- One feature per plugin
- Share through a versioned service table
- Use another plugin’s service
- Grow and version your own table
- Declare dependencies with
requires - Capabilities and missing services are normal results
- Threads, events and callbacks
- Clean unload
- Public SDK only
- Content-only changes: data packs
- Settings and storage
- Naming
- Splitting a feature into plugins
- Testing
- Anti-patterns
- The example pair
The principles in one list
Section titled “The principles in one list”- One plugin owns one feature and its state.
- Plugins share state and behaviour only through a service table published with
provide_serviceunder<plugin id>.<name>, or through commands. - A table starts with
uint32_t size, grows only at its end, and carries a(major << 16) | minorversion. Consumers check the size covers each member. - A hard dependency goes in
plugin.inirequires; the host orders loads and refuses cycles and missing providers before any code runs. SCO_UNAVAILABLEandSCO_NOT_FOUNDare answers, not errors. Checkhas()andquery_serviceresults and say why a feature is off.- Callbacks are short and run on the game thread; work you start, you stop.
- Everything you registered is withdrawn at unload; release what you own yourself.
- Only public SDK headers and services. No raw game memory.
- Content without code is a data pack.
One feature per plugin
Section titled “One feature per plugin”A plugin is the unit that the player enables, disables (an empty disabled file in
its folder) and updates, and the unit the host blames for a fault. Keep it to one
feature with one owner for its state:
- The plugin that owns a piece of state is the only one that writes it. Others ask the owner through its service or its commands.
- Commands are the human-facing surface (
<id>.<action>, shown in the product’s menu, callable withinvoke); a service table is the plugin-facing surface. A feature usually offers both:counter_serviceregisterscounter_service.valuefor people and publishescounter_service.counterfor plugins. - If two parts of a plugin could sensibly be switched off independently, or one part is useful to other plugins without the rest, they are two plugins.
Share through a versioned service table
Section titled “Share through a versioned service table”A service is a plain-C table of function pointers one plugin publishes for others to call directly (API v1: Services).
typedef struct counter_service_v1 { uint32_t size; /* sizeof(counter_service_v1) as the provider built it */ uint32_t _pad0; sco_result (*add)(int64_t delta, int64_t* out_value); sco_result (*get)(int64_t* out_value);} counter_service_v1;
static const counter_service_v1 table = { sizeof(counter_service_v1), 0, counter_add, counter_get };
if (api->size >= offsetof(sco_api, provide_service) + sizeof(api->provide_service) && api->provide_service) r = api->provide_service(self, "counter_service.counter", 0x00010000, &table);- Name:
<plugin id>or<plugin id>.<name>,[a-z0-9_.], 1-63 characters.provide_servicerefuses a name outside your id or one already published (SCO_BAD_ARG). Because the prefix names the provider, consumers can depend on it inplugin.inibefore any code runs. - Put the contract in a header that consumers copy: the table type, the name, the
version constant, and for each member its thread, its arguments, its results and who
owns any buffer.
counter_service.his a complete small example; the contract catalogue shows the same fields for the host’s own tables. - Plain C across the boundary: no C++ types, exceptions or varargs, fixed-width
integers, explicit padding, caller-owned buffers with a size handshake
(
SCO_TOO_MANYand the size needed), assco_api.hdoes. - Ids, never pointers into the game. A table that deals with game objects takes
and returns opaque
uint64_tids and resolves them on every call (why). - Validate arguments in the provider. A fault inside your table is blamed on you, including one caused by a bad pointer a caller passed in.
- Keep the table and everything behind it alive. Publish a
static consttable. Once any plugin has looked it up, the host refuses to unload or reload your plugin for the rest of the session (sco.reloadanswersprovides a service in use; restart the game to reload it), so a new build of a provider needs a game restart (hot reload). - Bytes instead of a table: for a call whose input and output are a byte buffer
(a list, a transform),
register_raw/invoke_rawgive the same naming with a bytes-in, bytes-out handler on the game thread (raw handlers).
Language layers: C++ sco::sdk::Provide, ServiceRef<T>, HasMember and
ServiceVersion (C++ guide); C# Provide, Query and
ServiceRef.Covers (C# guide). Lua plugins can use host services
but cannot publish native service tables (Lua guide).
Use another plugin’s service
Section titled “Use another plugin’s service”const void* found = NULL;const counter_service_v1* c = NULL;int64_t now = 0;if (api->size >= offsetof(sco_api, query_service) + sizeof(api->query_service) && api->query_service && api->query_service("counter_service.counter", 0x00010000, &found) == SCO_OK) c = (const counter_service_v1*)found;if (c && c->size >= offsetof(counter_service_v1, add) + sizeof(c->add) && c->add) c->add(1, &now);- Query when you need it.
query_serviceworks from any thread; a table you keep is a borrowed pointer, not a lease. The first successful lookup hands the provider’s table out, and from then on the host keeps that provider loaded for the session (sco.reloadrefuses it), so look it up in the command or callback that uses it, not insco_plugin_load“just to check”:counter_panelqueries only whenbumporshowruns. Never call a peer’s table from yoursco_plugin_unload, during shutdown or after its provider faulted (native trust). - Check every member before the call: the table is not NULL, its
sizecovers the whole member (offsetof + sizeof) and the member is not NULL. Covering one member says nothing about a later one.COUNTER_SERVICE_HAS(table, member)wraps it. - Read the result:
SCO_OK(same major, at least as new),SCO_UNAVAILABLE(another major, or older than you asked),SCO_NOT_FOUND(nobody publishes it).out_vtableis NULL unlessSCO_OK. - Follow the provider’s thread rules. Any-thread lookup does not make every member any-thread.
Grow and version your own table
Section titled “Grow and version your own table”- Append only within a major. A new member goes after the last one,
sizegrows with it, and the minor goes up: 1.0 to 1.1. Nothing is removed, renamed, retyped or reordered. A 1.0 consumer keeps working against a 1.1 provider, and a 1.1 consumer sees fromsizethat a 1.0 provider lacks the new member. - Ask for the oldest version you need. A consumer that only calls 1.0 members asks
for
0x00010000; asking for 1.1 shuts out 1.0 providers for no reason. - A break is a new major. A name holds one table and one version at a time, and a
consumer asking for 1.x gets
SCO_UNAVAILABLEfrom a 2.0 table. To keep old consumers working through a break, publish the new major under a second name (for examplecounter_service.counter2) beside the 1.x table. - Pin your layout the way sco-core pins its own tables (
tests/abi_*.c): static asserts onsizeofand eachoffsetof, so an accidental change fails the build.counter_service.hpinscounter_service_v1that way, in C and in C++. - The plugin’s own
versioninplugin.iniis for people; the service version is the contract. Change them independently.
Declare dependencies with requires
Section titled “Declare dependencies with requires”requires = counter_service.counterrequires lists up to 16 capabilities, services or plugin ids the plugin cannot run
without (plugin.ini, loader):
- Ordering: a plugin loads after every plugin its
requiresnames, by id or by one of its services, and unloads before them.counter_panelsorts beforecounter_servicebut loads after it. - Refusal instead of a broken start: a cycle (
requires cycle: a -> b -> a), a dotted name nobody can provide (requires service x.y: no plugin provides it), or a provider that is refused, disabled or fails to load refuses the dependent with the reason inmod.log. Nothing waits or hangs. - There is no
provideskey. The order guarantees the provider loaded first, not that it published the name; checkquery_service’s result anyway. - Optional dependencies stay out of
requires. Then nothing orders the provider before you, so look the service up when you use it (in a command or callback), not only insco_plugin_load, and offer the feature only while it is there. - Host and game services (
sco.storage,sco.ui,game.vehicles) and capabilities are published before plugins load, when the host and game build offer them, and need no ordering; name them inrequiresonly if the plugin is useless without them (it is then refused where they are missing).
Capabilities and missing services are normal results
Section titled “Capabilities and missing services are normal results”The same plugin runs on hosts with different services and game builds with different capabilities. Design for absence:
has("teleport")answers whether a capability is ready on this game build. Check it before offering a feature and say why it is off (hellologs it ongame.ready).- A command can name its capability in
sco_command.capability; the host then answersSCO_UNAVAILABLEfor it without calling your code. - When a service is
SCO_NOT_FOUNDorSCO_UNAVAILABLE, reply with the reason and returnSCO_UNAVAILABLE(ascounter_panel.bumpdoes), or grey the feature out. Don’t treat it as a failure, don’t retry in a loop, and never call a NULL member. - Every
sco_apifunction added in 1.1 (provide_servicethroughregister_raw) is behind anapi->sizecheck.sco-plugin-check --short-tablesserves a 1.0 table and fails any call to a 1.1 member made without that check, and any plugin whosesco_plugin_loadreturns an error. Degrade under it instead of declining: a consumer answersunavailableand names the host as the reason (counter_panelrepliesthis host has no query_service (sco_api 1.0)); a provider logs why, publishes nothing and keeps what still works (counter_servicekeeps itscounter_service.valuecommand). Both then pass the two checker runs that asco-new-plugin.pyproject’schecktarget makes.api = 1.1inplugin.inistill keeps a real 1.0 host from loading either plugin at all.
Threads, events and callbacks
Section titled “Threads, events and callbacks”- Commands,
tickcallbacks,run_on_game_threadtasks andinvokecompletions run on the game’s main thread (threading).tickarrives about 10 times a second: slow work there stalls the game. run_on_game_thread,subscribe,unsubscribe,status,log,register_command,invoke,provide_serviceandquery_servicework from any thread. A worker thread you start hands results to the game thread withrun_on_game_thread.- React to events instead of polling:
game.ready,tick,game.exit(best effort: never rely on it to save data),settings.changed, and the events of the services you use (net.state,net.peer, the game pack’sgame.*events).sco_apihas no event one plugin raises for another, so between plugins work on demand: call the provider when you need a value. If a consumer that runs every tick anyway must notice changes, a generation number in the provider’s table is cheaper to compare than re-reading all of its state. - Free a subscription’s
ctxonly when no call can still be running (freeing ctx).
Clean unload
Section titled “Clean unload”On ordinary unload, ordinary failed-load cleanup and non-retained crash cleanup, the host
withdraws everything the plugin registered with it: commands, subscriptions, queued tasks
and invokes, raw handlers, services, and what it registered with host services (UI tabs,
hotkeys, widget panels, settings). A provider whose table was handed out and then faults
is different: it becomes faulted, keeps its state and needs a game restart
(crash containment). What the host cannot see is yours
to release:
- Stop and join any thread you started before your state is destroyed; the host does not track unmanaged threads.
- Free memory you allocated, close handles you opened, and
release_servicea service you withdraw early. - Don’t call other plugins’ tables from
sco_plugin_unload: dependents unload first, but a peer’s table is not yours to use during teardown. - Keep nothing important only in memory. A reload or restart loses it; persist through
sco.storage.
Public SDK only
Section titled “Public SDK only”Use the root ABI (sco_api.h), the public service headers (sco_storage.h,
sco_settings.h, sco_widgets.h, and the game-pack headers in the game extension), the
scosdk/ C++ wrappers, the C# layer or Lua’s sco table. Don’t read or patch game
memory, hook game functions or include the host’s internal include/sco/ headers: a
plugin that needs a game feature uses the game-pack service for it, or asks for one.
The full list of what a plugin may and may not do is plugin rules,
and it is offline only.
Content-only changes: data packs
Section titled “Content-only changes: data packs”A plugin whose change is content (missions, rules, scripts, lists, DataCore overrides
in datacore/*.toml) is a data pack: kind = data, no entry, no code
(data packs). It needs no compiler, runs no code, and can be
required by other plugins like any plugin id. Keep content in a pack and the code that
uses it in a separate native or Lua plugin.
Settings and storage
Section titled “Settings and storage”- Settings the player changes go in a
[settings]section ofplugin.ini(speed = int default 5 min 1 max 10 label "Walk speed"). Read them throughsco.settings(get_int(self, "speed", &v)and friends) and subscribe tosettings.changed; the host validates and keeps the values (settings). - Storage is per plugin:
sco.storagegives each plugin its own SQLite database, key-value and SQL, that no other plugin can open (storage). Share data with other plugins through your service, not through files.
Naming
Section titled “Naming”- The plugin id: 1-31 of
a-z 0-9 _, equal to the folder name and, for a native plugin, tosco_plugin_info.name.sco,host,menuandgameare reserved. - Commands
<id>.<action>(counter_panel.bump), services<id>or<id>.<name>(counter_service.counter), raw handlers<id>.<name>, network channels<id>.<name>(you pass the full name). For an IPC channel you pass only<name>(1-31 ofa-z 0-9 _) and the host adds your id (sco_ipc.h). The host prefixes log and status lines with the id. - Name the table type after the service and its major (
counter_service_v1) and the version constants after the minor (COUNTER_SERVICE_VERSION_1_0).
Splitting a feature into plugins
Section titled “Splitting a feature into plugins”Take a waypoint feature: saved locations, a menu to manage them, and travel to one.
- Find the state and its owner. The list of waypoints is state: one plugin,
waypoints, owns it, stores it in its ownsco.storagedatabase and publisheswaypoints.list1.0 (add, remove, count, copy one by index into a caller buffer). - Find the faces. A menu tab (
sco.uiorsco.widgets) is a consumer:waypoints_panel,requires = waypoints.list. - Find the game-dependent part. Travel depends on a game capability; a third
plugin, or the panel itself, uses the game-pack service for it, gated on
has(), so the list still works where travel is unavailable. - Find the content. A default set of locations is a data pack the owner reads.
- Check the arrows. Dependencies point from faces and extras to the owner, never back, and never in a circle.
Testing
Section titled “Testing”sco-plugin-check <folder>(in the SDK’sbin/) readsplugin.iniwith the host’s rules, loads a native plugin against a stand-in host, firesgame.readyand a few ticks, runs every command without arguments (--invoke NAME ARG...for others) and unloads it.--cap NAMEmakeshas(NAME)answer 1 (for a capability, or a host or game service yourrequiresnames);--short-tablesserves a 1.0sco_api. It checks one folder at a time and does not connect one plugin’s service to another, so a consumer checked alone sees its provider as missing: a good test of its degraded path. Run it both ways, as is and with--short-tables(thechecktarget of asdk/tools/sco-new-plugin.pyproject does). Both example plugins pass both runs, and the SDK’s archive test asserts their replies.- Several plugins together: sco-core’s host simulator,
sco-host-sim(in the repository and the core source kit), loads a whole plugin folder through the real neutral host, withrequiresordering, and can invoke one command (building and testing). The CTest casehost_sim_examplesruns it over the example pair. - The scuba-engine studio, a separate public project, loads a built DLL in its synthetic host to invoke commands and exercise widgets (developer workflow).
- None of these is the game. A checker, simulator or studio pass is not an in-game result; say “not run in game” until it has been.
Anti-patterns
Section titled “Anti-patterns”- God plugin: one plugin with many unrelated features. It can’t be disabled in part, one fault takes everything with it, and nobody else can reuse its pieces.
- Private cross-plugin pointers: handing another plugin a pointer to your globals, a shared struct outside a versioned table, or an exported DLL symbol. There is no size, version or lifetime contract, and the host can’t order or withdraw it.
- Shared files as an interface: two plugins reading and writing one file. Use a service; each plugin’s storage is its own.
- Polling instead of events: re-reading a setting, a capability or a peer’s whole
state every tick when
settings.changed,game.readyor a generation number says when something changed. - Assuming a service exists: calling through a table without checking
query_service’s result, the table’ssizeand the member, or calling a 1.1 member ofsco_apiwithout theapi->sizecheck. - Keeping a table forever: caching a peer’s table across a reload or a fault, or using it during shutdown.
- Breaking a table in place: reordering or retyping members under the same major.
- Raw game memory: offsets, patches or hooks in a plugin instead of a public service.
The example pair
Section titled “The example pair”counter_service owns a counter and publishes
counter_service.counter 1.0 (add, get) with a size-first table and an api->size
check before provide_service (on a 1.0 sco_api it publishes nothing and keeps its
command); it also registers the command counter_service.value, and its header pins the
table layout with static asserts.
counter_panel owns nothing: it says
requires = counter_service.counter, looks the table up only when a command runs (never
at load, so the provider stays reloadable until the feature is used), checks each
member’s extent and answers SCO_UNAVAILABLE with a reason when the service is missing.
It shows a required dependency that still degrades: requires orders the load, but a
stand-in host, or a provider that published nothing, can leave the service absent.
Both are plain C against the neutral headers only. Both ship in the core SDK, are built
and checked one folder at a time by the SDK’s archive test, and are loaded together by
CTest host_sim_examples, where counter_panel.bump answers counter is 1 through the
provider’s table. Not run in game.