-
Notifications
You must be signed in to change notification settings - Fork 9
Plugin API
Plugins add new gameplay to Fio — new placeable entity types, their I/O, and
runtime behaviour — without editing the core editor or engine. Drop a
package into the plugins/ directory and it is discovered automatically at
startup. The same plugin runs in the editor's Play mode, the standalone
.fiopak desktop player, and the Android build.
This page is the reference for plugin authors. For a guided introduction see the
in-repo plugins/README.md;
for the fully-documented source of every class and method see
plugins/api.py.
API version: 1.3.0 (
plugins.api.API_VERSION). Everything on this page ships today. A plugin can declare the minimum API it needs viaapi_version; the loader refuses a plugin that needs a newer host, with a clear message, instead of failing inside a hook — see API versioning & dependencies.
The big idea. Beyond the curated surface (entities, I/O, lifecycle hooks), a plugin gets a host — one object that reaches the whole engine — and an event bus the engine emits into. New behaviour is added by listening and reacting, not by editing the engine. New engine signals are a one-line
emit(); plugins can subscribe to events that don't exist yet. This is the intended path for all future extension.
- Concepts at a glance
- Lifecycle
- Extending the engine: host & events
- Event catalog
- Render hooks
- Swappable renderers
- Editor-UI extensions
- Performance & the kill-switch
- Quick start: a complete plugin
- API reference
- Runtime services
- The HUD
- The global key/value store
- Property schemas
- Reading held keys
- API versioning & dependencies
- Reacting to enable/disable
- Entity classes
- The entity
typecontract - Enabling & disabling plugins
- Packaging & distribution
- Running outside the editor
- Testing a plugin
- Design principles
- Roadmap / proposed enhancements
| Term | What it is |
|---|---|
| Plugin | A Python sub-package of plugins/ exposing a FioPlugin instance as module-level PLUGIN (or a get_plugin() factory). |
| Entity | An ordinary Thing/Model subclass the plugin registers so it can be placed, serialized, edited and rendered like any built-in entity. |
| I/O | The Source-style input/output logic system. A plugin declares the inputs and outputs its entities expose, and registers handlers for the inputs. |
| Manager | The process-wide PluginManager singleton that discovers, loads, gates and dispatches plugins. |
| Host object | The PluginHost handed to each plugin's connect() — one reach into the whole engine plus its event bus. |
| Events | Named signals the engine emits (play_start, player_damage, …). Plugins subscribe via host.on(...). See the catalog. |
| Play host | Whatever drives the play loop — the engine's LogicThread (editor) or PlayerPluginHost (standalone player). Both call the same manager. |
Everything a plugin touches at gameplay time hangs off one logic object:
logic.things (the scene entities), logic.player (camera/eye/angle/pitch),
logic.io_manager (fire outputs / register inputs) and
logic.current_hud_message (a single HUD prompt line).
load_plugins() (editor + engine + player, once)
│
▼
register(EditorAPI) ── entity types, I/O defs, menu entries [UI-free]
│
play ──► register_runtime(RuntimeAPI) ── I/O input handlers
│
on_play_start(logic) ── build per-session state
│
on_tick(logic, ctx) ── every tick, after core interactions
│
on_play_stop(logic) ── restore anything you mutated
-
registerruns once per process, in the editor and headless engine/ player contexts, so it must never import PyQt or OpenGL. -
register_runtimeruns once when a play session's logic thread is built. Every loaded plugin attaches; each handler self-gates on the plugin's liveenabledflag, so a plugin switched on later (e.g. auto-enabled by a level) has working inputs with no re-attach. -
on_play_start/on_tick/on_play_stopare gated and cached: a plugin that doesn't override a hook costs nothing, and the per-tick dispatch list is rebuilt only when the enabled set changes. A map whose active plugins don't tick pays almost nothing per frame.
Every call into plugin code is wrapped by the manager: a plugin that raises during registration or a hook logs a traceback to the debug console instead of crashing the editor or the play session.
The lifecycle hooks above are a fixed set. The host is the open-ended one.
When a play session starts, every plugin's connect(host) hook runs with a
PluginHost — a single object that reaches the whole engine and its event
stream. This is how you extend Fio in ways this API never anticipated, without
editing the engine.
class Scoring(FioPlugin):
name = "scoring"
api_version = "1.2.0"
def connect(self, host):
self._host = host
host.on("play_start", self._reset)
host.on("pickup_collected", self._score)
host.on("player_death", self._game_over)
def _reset(self, ev): self._points = 0
def _score(self, ev): self._points += ev.value # ev fields: ev.value, ev.item_type…
def _game_over(self, ev): ev.logic.current_hud_message = f"Dead. Score {self._points}"The host (plugins.host.PluginHost) gives you:
| Reach | What |
|---|---|
host.on(event, fn, priority=0) / host.off
|
Subscribe/unsubscribe. Handlers gate on the plugin's enabled flag automatically. |
host.emit(event, **data) |
Emit your own event — other plugins can listen. |
host.scene, host.player, host.io, host.globals
|
The live entity list, player, IOManager, and cross-level store. |
host.game_state, host.monster_ai, host.terrain, host.editor
|
Named subsystem handles (return None if a given host lacks them). |
host.get("a.b.c") and host.<anything>
|
Generic reach — any attribute the engine object exposes, including parts added later. This is the future-proof escape hatch. |
host.provide(name, obj) / host.service(name)
|
A cross-plugin service registry — one plugin publishes, others consume. |
host.wrap(obj, "method", wrapper) |
Guarded monkey-patch for the rare case you must intercept an engine call; falls back to the original if the wrapper raises. |
host.register_renderer(name, cls) |
Add a swappable renderer — see Swappable renderers. |
Events carry a payload reachable as ev.field, ev["field"] or
ev.get("field"); ev.logic is always the session object; ev.stop() halts
propagation to lower-priority handlers. Handlers are called highest-priority
first, and — like everything in the system — a handler that raises is logged and
skipped, never taking down the tick or another plugin.
Why this makes the engine stable. Adding a new extension point later is one
line in the engine — self._plugin_emit("my_new_event", ...) — and nothing else
changes: the bus early-outs when no one listens, and plugins can already
subscribe to "my_new_event" before it's ever emitted. Reaching a new subsystem
needs no engine change at all, because host.get(...)/attribute pass-through
already sees it.
Emitted by the engine host each play session (the player host emits the lifecycle subset). All are optional to handle; payload fields are listed.
| Event | When | Payload (besides logic) |
|---|---|---|
play_start |
Entering play mode | — |
play_stop |
Leaving play mode | — |
tick |
Every play tick |
ctx (a TickContext), delta, use_pressed
|
player_spawn |
Player start fires |
start (the PlayerStart entity) |
player_damage |
Player takes damage |
damage, health
|
player_death |
Health reaches 0 | — |
player_shoot |
A firing weapon shoots | weapon |
pickup_collected |
A pickup is collected |
pickup, item_type, value
|
door_open |
A door starts opening |
door (brush), door_idx
|
trigger_enter |
Player enters a trigger |
trigger (brush), action, trigger_id
|
trigger_exit |
Player leaves a trigger |
trigger, trigger_id
|
portal_transit |
Player passes through a portal |
portal_from, portal_to
|
noise |
An audible event is emitted |
pos, source, loudness
|
entity_spawned |
A plugin spawns an entity |
entity, by (plugin name) |
render.pre_scene |
Each frame, before the 3D world is drawn |
viewport, renderer, projection, view, camera_pos, play_mode
|
render.post_scene |
Each frame, after 3D, before the 2D overlay | same as render.pre_scene
|
render.overlay |
Each frame, during the 2D overlay pass |
viewport, painter (a QPainter), width, height, play_mode
|
Plugins may emit and observe their own event names too — the bus doesn't
restrict names. The render.* events are covered in Render hooks;
they only fire in the editor viewport, and — like every emit point — cost a
single dictionary lookup on frames where no plugin is subscribed.
Three per-frame events let a plugin draw without owning the pipeline. They fire from the viewport's paint loop:
| Event | Phase | Use it for |
|---|---|---|
render.pre_scene |
Before the 3D world | set up an extra pass, draw behind the scene |
render.post_scene |
After 3D, still in the GL context | custom 3D overlays, debug geometry, extra passes |
render.overlay |
The 2D overlay pass (a live QPainter) |
HUD graphics, reticles, menus, meters |
def connect(self, host):
host.on("render.overlay", self._draw)
def _draw(self, ev):
p = ev.painter # a QPainter, already open
p.drawText(20, ev.height - 20, f"Score: {self._points}")pre_scene/post_scene hand you ev.renderer, ev.projection, ev.view and
ev.camera_pos so you can issue your own GL draws through the renderer.
Cost when unused is one dict lookup. The paint loop guards each emit with
has_listeners(...), so a frame with no render subscriber builds no payload and
calls nothing — see Performance. These events
fire in the editor viewport only (the standalone player's renderer doesn't
emit them yet).
Fio picks its renderer from a class registry, so a plugin can register a whole new renderer — a deferred pipeline, a stylised one — and have it appear as a selectable render mode:
def register(self, api):
from mypkg.deferred import DeferredRenderer
api.register_renderer("Deferred", DeferredRenderer)register_renderer(name, cls) returns True when registered, False in a
headless/player context with no viewport. The plumbing is small; the contract
is the work: cls must implement the interface the viewport drives —
constructed as cls(load_texture, grid_size, world_size, config) and exposing
render_scene, draw_models, render_shadow_maps, set_sprite_textures,
set_instance_textures, a lod_manager, and cleanup, among the draw_*
passes. Once registered, the existing renderer-switch machinery instantiates and
runs it like the built-in Forward renderer.
Beyond a plugin entity's own property schema, a plugin can extend the editor's property panel for any entity:
def register(self, api):
# Extra fields appended to an entity's panel — built-in entities included.
api.register_extra_fields("monster", [
prop("aggro", "int", default=5, min=0, max=10, help="Chase aggression"),
])
# A whole custom tab. factory(thing) -> a QWidget. Omit entity_type for all.
api.register_extra_fields # (see above)
api.register_property_tab("Notes", build_notes_tab, entity_type="monster")-
register_extra_fields(type, specs)— the specs (samePropertySpecas schemas) are appended after an entity's stock rows, so they never disturb built-in widgets; they render with typed controls and tooltips. -
register_property_tab(label, factory, entity_type=None)— adds a tab to the panel;factory(thing)returns its widget. Scoped to one type, or all entities whenentity_typeis omitted.
Both are installed as guarded editor patches — a factory that raises is logged and skipped, never breaking the panel. (The top-level Plugins menu and the 2D-view placement submenu are wired the same way.)
The extension surface is built to cost ~nothing when idle — a hard requirement, since the engine's hot paths are hand-optimised.
- Nothing is added to a vectorised/culling loop. Gameplay emits sit at discrete events (damage, pickup, portal, …), not inside per-frame math.
-
Idle emits are one dict lookup.
emit()early-outs when an event has no subscribers; the per-framerender.*andtickemits are additionally guarded byhas_listeners(...)so no payload is even built. -
The per-frame tick is gated. The engine calls
plugins.wants_tick()— an O(1) cached check (~65 ns) — and skips the tick call and its argument packing entirely when no plugin ticks or listens. Reading held keys (a lock + copy) is deferred and happens only on frames a plugin actually consumes them. -
Hard kill-switch. Launch with
FIO_NO_PLUGINS=1and the plugin system never loads, attaches or binds — every per-frame guard short-circuits onplugins is Nonefor literally zero overhead. (FIO_DISABLED_PLUGINS=a,bdisables named plugins;FIO_PLUGIN_DEBUG=1surfaces load logging.)
Net: with no plugins enabled the per-frame cost is a single cached boolean check;
with FIO_NO_PLUGINS it is nothing at all.
# plugins/coins/plugin.py
from plugins.api import FioPlugin, io_def
try:
from editor.things import Thing # editor / engine context
except Exception:
from plugins.entitybase import Thing # standalone player (no PyQt)
class Coin(Thing):
pixmap_path = "assets/sprites/pickup.png"
def __init__(self, pos=None, properties=None):
super().__init__(pos, properties)
self.properties['type'] = 'coin' # the I/O + serialization key
self.properties.setdefault('value', 1)
class CoinsPlugin(FioPlugin):
name = "coins"
version = "1.0.0"
description = "Collectible coins with a running score."
category = "Pickups"
# -- load time (UI-free) --------------------------------------------
def register(self, api):
api.register_entity(Coin, menu_label="Coin")
api.register_io('coin',
inputs=[io_def('Collect', "Force-collect this coin")],
outputs=[io_def('OnCollected', "Fired when collected", "int")])
# -- runtime attach -------------------------------------------------
def register_runtime(self, api):
def collect(entity, param, logic):
entity.properties['collected'] = True
api.register_input_handler('coin', 'collect', collect)
# -- play lifecycle -------------------------------------------------
def on_play_start(self, logic):
logic._coin_score = 0
def on_tick(self, logic, ctx):
... # detect pickup, fire 'OnCollected', bump logic._coin_score# plugins/coins/__init__.py
from .plugin import CoinsPlugin
PLUGIN = CoinsPlugin()Restart the editor and Coin appears under Plugins ▸ coins in the menu bar and the 2D-view right-click menu, with an auto-generated Properties panel and an I/O tab — no core edits required.
Base class for every plugin. Subclass it, set the metadata attributes, override the hooks you need.
Metadata attributes
| Attribute | Default | Meaning |
|---|---|---|
name |
"unnamed" |
Short unique id; shown in logs and the Plugins menu. |
version |
"0.0.0" |
Human-readable version string. |
description |
"" |
One-line description shown in tooling / the About dialog. |
category |
"Plugins" |
Default editor category / menu grouping for this plugin's entities. |
enabled |
True |
Live on/off flag. A disabled plugin stays loaded but is skipped for runtime attach and lifecycle dispatch. Set False to ship disabled-by-default (auto-enabled when a level uses it — see below). |
api_version |
"1.0.0" |
Minimum plugin-API version this plugin needs. The loader skips a plugin whose api_version is newer than the host's API_VERSION. |
requires |
[] |
Names (or package names) of other plugins this one depends on; a missing one disables this plugin with a logged reason. |
Hooks (all optional; override only what you need)
| Hook | When | Notes |
|---|---|---|
register(api: EditorAPI) |
Once, at load, in every process | UI-free. Declare entities, I/O, property schemas, palette entries. |
describe_properties() -> dict |
Once, right after register
|
Optional {entity_type: [PropertySpec, …]} — see Property schemas. |
register_runtime(api: RuntimeAPI) |
Once per logic thread | Register I/O input handlers; api also exposes the runtime services. |
connect(host) |
Once per session, all plugins | Subscribe to engine events, reach any subsystem, publish services — the open-ended extension point. |
on_play_start(logic) |
Entering play mode | Build per-session state (usually stashed on logic). |
on_tick(logic, ctx: TickContext) |
Every play tick | Runs after core gameplay handling. |
on_play_stop(logic) |
Leaving play mode | Restore anything you mutated so the edited map is unchanged. |
on_enabled_changed(enabled) |
On a live toggle | Acquire/release resources when the plugin is switched on or off. |
menu_entries() -> list[(label, cls)] |
At load | Bespoke placement entries; most plugins use register_entity instead. |
Handed to register(). Runs in both the editor and headless engine/player, so
it must stay UI-free.
register_entity(cls, category=None, menu_label=None, placeable=True) -> clsRegister a Thing subclass as a placeable entity. Records the entity's owner
(so the packager and player host can map a map's type string back to the
owning plugin and its class), adds it to the editor palette (ENTITY_TYPES /
ENTITY_CATEGORIES) when the editor is present, and — if placeable — adds a
Plugins ▸ <plugin> menu entry. Returns cls, so it also works as a
decorator. The editor-palette step is skipped quietly in the PyQt-free player.
register_io(entity_type, inputs, outputs)Declare the I/O for an entity type. inputs/outputs are lists of
io_def results; None entries (produced when the I/O system is
unavailable in a headless context) are filtered out.
register_properties(entity_type, specs)Declare a typed property schema for a plugin entity type.
register_extra_fields(entity_type, specs) # append fields to ANY entity
register_property_tab(label, factory, entity_type=None) # add a panel tab
register_renderer(name, cls) # add a swappable rendererEditor-UI extensions and swappable renderers.
global_get(key, default=None, store="plugins")
global_set(key, value, store="plugins")
globals # -> the GlobalStoreRead/write the cross-level key/value store.
log(message)Informational log line — silent unless FIO_PLUGIN_DEBUG is set.
Handed to register_runtime(). Exposes .logic and .io_manager, the I/O
registration methods below, and a runtime services layer.
register_input_handler(entity_type, input_name, handler)Register handler(entity, parameter, logic) for an entity input. The handler is
attached once but self-gates on the plugin's live enabled state — it runs
only while the plugin is on. input_name is matched case-insensitively against
the names you declared with register_io.
fire_output(entity, output_name, value=None)Fire an output from entity through the I/O system, triggering any connections
the level author wired in the editor.
Scene & player services (see Runtime services):
entities_of_type, things_near, raycast_from_crosshair, player_eye,
player_forward, spawn, despawn, and global_get/global_set.
log(message)Informational log line (as above).
The per-tick state passed to on_tick.
| Field | Type | Meaning |
|---|---|---|
delta |
float |
Seconds since the previous tick. |
use_pressed |
bool |
Edge-triggered "use/interact" key for this tick — treat True as a single press. |
interaction_consumed |
bool |
True if the core already set a HUD prompt / consumed the use press this tick (door, pickup, level-changer). Avoid clobbering the HUD unless you own a target under the crosshair. |
keys |
frozenset |
Held-key set (Qt key codes on the engine host). Prefer key_down. |
logic |
object | The play session's logic object (set by the manager), so the HUD helpers can reach it. |
Methods
| Method | Purpose |
|---|---|
key_down(name) |
True if a key is held — by name ('w', 'space', 'shift') or raw code. See Reading held keys. |
set_prompt(text, priority=0) |
Show a contextual HUD line this tick, respecting priority and the core's claim. See The HUD. |
toast(text, seconds=2.0) |
Show a timed message that persists across ticks until it expires. |
io_def(name, description="", param_type="") -> IODef | NoneConvenience builder for an editor.io_system.IODef, so plugins describe I/O with
plain args instead of importing io_system. param_type is a hint string
("int", "string", …) for outputs that carry a value. Returns None in
headless contexts where the I/O system is unavailable; the manager tolerates and
filters those.
RuntimeAPI (the object passed to register_runtime, held as api) carries a
small services layer so plugins don't each re-implement crosshair ray-casting,
entity queries and vector math. Because api is created per logic thread, keep
a reference to it (e.g. on your session object) if you want the services during
on_tick.
api.entities_of_type("coin") # all scene entities of a type
api.things_near(pos, radius, type=None) # entities within radius of a point
api.raycast_from_crosshair(reach=160.0, aim_dot=0.86,
type_name=None, predicate=None) # entity under the reticle
api.player_eye() # (x, y, z) eye position
api.player_forward() # look direction including pitch
api.spawn(cls, pos=None, properties=None) # add an entity to the live scene
api.despawn(entity) # remove itraycast_from_crosshair is the query most interaction plugins need — it returns
the nearest entity within reach whose direction from the eye is inside the
aim_dot cone (1.0 = dead-centre, lower = wider), optionally filtered by type
or a predicate(entity). It replaces the hand-rolled "what am I looking at"
loop (compare Tidy's _object_in_view).
All services read plain-Python geometry, so they work identically in the editor and the dependency-light player.
Instead of writing logic.current_hud_message directly (and manually checking
ctx.interaction_consumed so you don't stomp the core's prompt), use the two
TickContext helpers:
def on_tick(self, logic, ctx):
target = self._api.raycast_from_crosshair(type_name="coin")
if target is not None:
ctx.set_prompt("[E] Collect", priority=1) # loses to nothing higher; skipped if core claimed the line
if ctx.use_pressed:
self._collect(target)
ctx.toast("Coin collected!", seconds=1.5) # shown for 1.5s, no re-set needed-
set_prompt(text, priority=0)writes the HUD line for this tick only if nothing higher-priority already claimed it — including the core (priority 0won't overrideinteraction_consumed). The highest-priority caller in a tick wins. -
toast(text, seconds)shows a message that persists across ticks until it expires, so you fire it once on an event rather than every frame. A toast is only displayed on ticks where no prompt claimed the line.
The engine's cross-level key/value storage (the same mechanism map
LogicKeyValueStore entities use, persisting across level loads within a
session) is available to plugins:
api.global_set("coins", 12) # store "plugins" by default
n = int(api.global_get("coins", 0))
api.global_set("solved", "yes", store="quest") # share a named map storeValues are stored as strings, matching the map store. Pass a store name a
map's LogicKeyValueStore uses to read/write the same values — letting plugin
state and level logic share persistent flags, scores and unlocks. In the
dependency-light player (no editor package) this transparently falls back to a
process-local store with the same API.
By default the editor infers a property's widget from its stored value's Python type (bool → checkbox, int → spin box, else a text field). Declaring a schema lets you get enum dropdowns, ranged spin boxes, and labelled/tool-tipped fields instead — and lets tooling coerce/validate values.
from plugins.api import prop # terse PropertySpec constructor
class MyPlugin(FioPlugin):
def describe_properties(self):
return {
"coin": [
prop("value", "int", default=1, min=1, max=100,
help="Score awarded when collected"),
prop("kind", "enum", choices=["gold", "silver", "bronze"],
default="gold", label="Coin type"),
prop("shiny", "bool", default=True),
],
}Equivalent to calling api.register_properties("coin", [...]) inside
register, or setting a PROPERTY_SCHEMA class attribute on the entity. Spec
types: int, float, bool, string, enum, vec3, asset. The schema is
additive — properties without a spec still render generically, so a partial
schema never hides a field, and existing plugins that declare none are
unaffected.
ctx.key_down(name) tests the held-key set without any host-specific key
plumbing:
def on_tick(self, logic, ctx):
if ctx.key_down("shift"):
self._sprint()
if ctx.key_down("w") and ctx.key_down("space"):
...name may be a single character ('w' → its ASCII/Qt code), a named special
key ('space', 'shift', 'ctrl', 'alt', 'tab', 'up'/'down'/'left'/
'right', 'escape', 'return'/'enter'), or a raw integer code. ctx.keys
is the raw frozenset if you need it. The engine host populates this from its live
key state; a host that has no held-key set (some player inputs) leaves it empty,
so treat held-key gameplay as an enhancement over the always-available
use_pressed.
Declare the minimum API your plugin needs and any sibling plugins it depends on:
class MyPlugin(FioPlugin):
api_version = "1.1.0" # needs the runtime services / HUD / schema API
requires = ["tidy"] # won't run without the tidy plugin loaded- If
api_versionis newer than the host'splugins.api.API_VERSION, the loader skips the plugin with a clear message instead of letting it fail inside a hook. Plugins that don't set it default to"1.0.0"and always load. - If a name in
requiresisn't loaded, the plugin is disabled (kept loaded so its entities stay known, but inert) and the reason is logged.
Override on_enabled_changed to react when your plugin is toggled — from the
Plugins menu, or auto-enabled/disabled by a level load:
def on_enabled_changed(self, enabled):
if not enabled:
self._release_resources()It fires only on a change, not for the initial state.
Plugin entities are ordinary Thing/Model subclasses, which is why the
property panel, I/O editor, serializer, LogicSpawner and 3D-model pipeline all
work on them for free.
- In the editor / engine, subclass
editor.things.Thing(sprite/logic entity) oreditor.things.Model(carries 3D geometry). - In the standalone player the editor package is absent (no PyQt), so import
falls back to the tiny, dependency-free
plugins/entitybase.pyThing/Model. Thetry/exceptimport shown in the quick start is the standard pattern.
Want 3D geometry in play mode? Subclass Model (as Tidy's TidyObject
does) or set a model_path property — any Thing with a model_path is drawn
by the existing model pipeline. Things without one are editor-only sprites.
properties['type'] is the single string that ties everything together. It must
be identical across:
- the value you set in the entity's
__init__, - the
entity_typeyou pass toregister_io, - the
entity_typeyou pass toregister_input_handler, - the
typefield written into the map JSON.
If you subclass Thing directly the base class defaults type to the lowercased
class name, but set it explicitly to be safe. The manager normalises type
strings the same way editor.things.from_dict does — lowercased, underscores
stripped — when it maps a map entity back to its owning plugin and class.
Three independent mechanisms, from most dynamic to most permanent:
-
Disabled-by-default + auto-enable on load. A plugin can set
enabled = Falseto ship inert, so ordinary maps never pay for gameplay they don't use. When a level whosethingsreference the plugin's entity types is loaded, the manager turns it on automatically (auto_enable_for_map). Clearing the scene (File ▸ New, or loading a map that doesn't use it) reverts a level-driven auto-enable (disable_auto_enabled). This is a runtime, per-session flip: it never rewrites the persisted disabled list, and a plugin you enabled by hand is never auto-disabled underneath you. The bundled Tidy plugin ships this way. -
Per plugin, in the editor. Toggle Enabled in the Plugins menu. The choice is saved to
settings.iniunder[Plugins] disabledand restored next launch. Entity registration isn't undone live, so a re-enable is instant while a full unload happens on restart. -
At startup, globally. Set
FIO_DISABLED_PLUGINSto a comma-separated list of plugin/package names so they never load:FIO_DISABLED_PLUGINS=tidy python main.py
Loading is silent by default. Set FIO_PLUGIN_DEBUG=1 to see informational
load/registration messages (errors are always shown).
.fiopak exports are plugin-aware. On export the exporter scans the bundled
maps, works out which plugins their entities come from, and injects those
plugins — code and assets — plus the plugin-system core into the archive,
recording them in metadata.json under "plugins". The package is then
self-contained and loads on another machine.
- Plugin assets keep their repo-relative paths (e.g.
plugins/tidy/assets/tidy_object.obj), so a map'smodel_pathresolves straight out of the package with no rewriting. - Packages that use no plugin entities are unaffected — the step is a no-op.
- The player side reads the dependency:
FioPackage.required_pluginsreads the manifest, andplugins.packaging.load_package_plugins(root)loads the bundled plugins from an extracted package.
The mechanics live in
plugins/packaging.py
(augment_fiopak, load_package_plugins).
Plugin gameplay runs in both hosts:
-
Editor Play mode —
engine.logic_thread.LogicThreaddispatches the plugin lifecycle/tick natively. -
Standalone
.fiopakplayer (player/, incl. Android) —player.plugin_host.PlayerPluginHostloads the package's plugins, builds entity instances from the map, and drives the same lifecycle/tick from the player's frame loop against a camera→player bridge (USE = interact).
To make this work everywhere the plugin runtime must be dependency-free: no
PyGLM (use plain-Python vector math) and no PyQt. That is why entities fall back
to plugins/entitybase.py when the editor package is absent, and why the Tidy
runtime hand-rolls its vector helpers instead of importing glm.
The player's renderer is still bringing up map-model drawing, so plugin gameplay runs (state, HUD text) ahead of the models being visible. The host exposes
thingsandhud_messagefor the renderer to consume once it draws dynamic models.
Plugin tests run headless — no display or OpenGL required. The Tidy plugin's
suite is the reference pattern (it blocks editor, PyQt and glm imports to
prove the runtime is dependency-free):
QT_QPA_PLATFORM=offscreen python plugins/tidy/tests/test_smoke.py # runtime + integration
QT_QPA_PLATFORM=offscreen python plugins/tidy/tests/test_packaging.py # .fiopak bundling
python plugins/tidy/tests/test_player.py # player path (editor/PyQt/glm blocked)Keep these in mind when writing a plugin — they mirror how the core is built:
- Loose coupling. The core calls into the manager; plugins never import editor internals except through the API objects. Import editor/engine modules lazily inside methods so importing your plugin never drags in PyQt/OpenGL.
-
Fail safe. Assume every hook is wrapped — but still keep
register()side-effect-light so a partial failure leaves a sane state. -
UI-free at load.
register()runs in headless/engine/player contexts. Never touch Qt or OpenGL there. -
Do per-tick work in
on_tick. Usectx.use_pressedfor single presses and respectctx.interaction_consumedbefore writing the HUD. -
Restore what you mutate. If you move entities during play, put them back in
on_play_stopso the edited map is unchanged (seeTidySession.stop).
Every extension is additive and opt-in, so plugins written against an earlier API keep working unchanged.
API 1.3 — reaching the renderer & the editor UI
-
Render hooks (
render.pre_scene/post_scene/overlay) — draw without owning the pipeline — Render hooks. -
Swappable renderers —
register_renderer(name, cls)ships a whole renderer as a plugin — Swappable renderers. -
Editor-UI extensions —
register_extra_fields(fields on any entity) andregister_property_tab— Editor-UI extensions. -
Kill-switch —
FIO_NO_PLUGINS=1and a cached per-frame gate — Performance.
API 1.2 — the open-ended surface
-
Host + event bus —
connect(host),host.on/emit, the whole engine reachable, a service registry andwrap()— Extending the engine and the event catalog. This is the path for all future extension.
API 1.1 — curated conveniences
-
TickContext.keyspopulated +key_down()— Reading held keys. -
Runtime services on
RuntimeAPI(queries, raycast, player geometry, spawn/despawn) — Runtime services. -
Structured HUD —
ctx.set_prompt()/ctx.toast()— The HUD. - Global key/value store access — The global key/value store.
-
Property schemas —
describe_properties()/register_properties()— Property schemas. -
API-version & dependency gating —
api_version/requires— API versioning & dependencies. -
Enable/disable callback —
on_enabled_changed()— Reacting to enable/disable.
Still proposed, not yet shipped:
Discovery currently scans only the repo's plugins/ directory (drop a package
in and it's found). Adding a user-plugins directory (e.g. ~/.fio/plugins)
and/or importlib.metadata entry-point discovery would let users install
third-party plugins without placing files inside the app tree. Deferred by
design for now — the plugins/ drop-in folder stays the supported path.