Skip to content

Plugin API

Vicious Squid edited this page Aug 6, 2026 · 3 revisions

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 via api_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.


Contents


Concepts at a glance

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).


Lifecycle

             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
  • register runs once per process, in the editor and headless engine/ player contexts, so it must never import PyQt or OpenGL.
  • register_runtime runs once when a play session's logic thread is built. Every loaded plugin attaches; each handler self-gates on the plugin's live enabled flag, 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_stop are 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.


Extending the engine: host & events

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.

Event catalog

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.


Render hooks

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).

Swappable renderers

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.

Editor-UI extensions

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 (same PropertySpec as 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 when entity_type is 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.)

Performance & the kill-switch

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-frame render.* and tick emits are additionally guarded by has_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=1 and the plugin system never loads, attaches or binds — every per-frame guard short-circuits on plugins is None for literally zero overhead. (FIO_DISABLED_PLUGINS=a,b disables named plugins; FIO_PLUGIN_DEBUG=1 surfaces 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.


Quick start: a complete plugin

# 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.


API reference

FioPlugin

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.

EditorAPI (load time)

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) -> cls

Register 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 renderer

Editor-UI extensions and swappable renderers.

global_get(key, default=None, store="plugins")
global_set(key, value, store="plugins")
globals   # -> the GlobalStore

Read/write the cross-level key/value store.

log(message)

Informational log line — silent unless FIO_PLUGIN_DEBUG is set.

RuntimeAPI (per play session)

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).

TickContext

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

io_def(name, description="", param_type="") -> IODef | None

Convenience 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.


Runtime services

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 it

raycast_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.

The HUD

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 0 won't override interaction_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 global key/value store

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 store

Values 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.

Property schemas

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.

Reading held keys

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.

API versioning & dependencies

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_version is newer than the host's plugins.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 requires isn't loaded, the plugin is disabled (kept loaded so its entities stay known, but inert) and the reason is logged.

Reacting to enable/disable

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.


Entity classes

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) or editor.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.py Thing/Model. The try/except import 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.


The entity type contract

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_type you pass to register_io,
  • the entity_type you pass to register_input_handler,
  • the type field 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.


Enabling & disabling plugins

Three independent mechanisms, from most dynamic to most permanent:

  1. Disabled-by-default + auto-enable on load. A plugin can set enabled = False to ship inert, so ordinary maps never pay for gameplay they don't use. When a level whose things reference 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.

  2. Per plugin, in the editor. Toggle Enabled in the Plugins menu. The choice is saved to settings.ini under [Plugins] disabled and restored next launch. Entity registration isn't undone live, so a re-enable is instant while a full unload happens on restart.

  3. At startup, globally. Set FIO_DISABLED_PLUGINS to 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).


Packaging & distribution

.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's model_path resolves 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_plugins reads the manifest, and plugins.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).


Running outside the editor

Plugin gameplay runs in both hosts:

  • Editor Play modeengine.logic_thread.LogicThread dispatches the plugin lifecycle/tick natively.
  • Standalone .fiopak player (player/, incl. Android) — player.plugin_host.PlayerPluginHost loads 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 things and hud_message for the renderer to consume once it draws dynamic models.


Testing a plugin

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)

Design principles

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. Use ctx.use_pressed for single presses and respect ctx.interaction_consumed before writing the HUD.
  • Restore what you mutate. If you move entities during play, put them back in on_play_stop so the edited map is unchanged (see TidySession.stop).

Shipped

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 renderersregister_renderer(name, cls) ships a whole renderer as a plugin — Swappable renderers.
  • Editor-UI extensionsregister_extra_fields (fields on any entity) and register_property_tabEditor-UI extensions.
  • Kill-switchFIO_NO_PLUGINS=1 and a cached per-frame gate — Performance.

API 1.2 — the open-ended surface

  • Host + event busconnect(host), host.on/emit, the whole engine reachable, a service registry and wrap()Extending the engine and the event catalog. This is the path for all future extension.

API 1.1 — curated conveniences

  1. TickContext.keys populated + key_down()Reading held keys.
  2. Runtime services on RuntimeAPI (queries, raycast, player geometry, spawn/despawn) — Runtime services.
  3. Structured HUDctx.set_prompt() / ctx.toast()The HUD.
  4. Global key/value store access — The global key/value store.
  5. Property schemasdescribe_properties() / register_properties()Property schemas.
  6. API-version & dependency gatingapi_version / requiresAPI versioning & dependencies.
  7. Enable/disable callbackon_enabled_changed()Reacting to enable/disable.

Roadmap / proposed enhancements

Still proposed, not yet shipped:

Wider plugin discovery

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.

Clone this wiki locally