Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Writing a plugin

This page is for people who want to write a new plugin for Open Play: it shows the parts of a plugin, a small working example, the safe order to follow when you raise a version, and the common mistakes taken from real incidents. To use a plugin, see plugins overview, calling plugins, and bindings.

Who this page is for

Warning

Hosted studios cannot upload plugin code today. Plugins are Rust code compiled into the Open Play server and are considered trusted code. Sandboxed execution, resource metering, and signed distribution do not exist yet. This guide is for teams that run Open Play themselves (self-hosted) and for Open Play contributors.

If you cannot compile code on your own server, you connect your game logic today in these ways: game rules in the panel, webhooks, or your own server + a server key (endpoint reference).

Anatomy of a plugin

A plugin is a single Rust crate (for example crx-plugin-arena, crx-plugin-work-items, crx-plugin-package-ride). Its parts:

PartWhat it doesWhere it is defined
ManifestThe plugin’s identity (PluginId), version (PluginVersion), host API major, the plugins it provides and requires, its permissions (PluginPermission), configuration schema, messages, migration checksum, and realtime modulesOpenPlayPlugin::manifest
Typed messagesCommand/query input and output; each has one direction (CommandInput, CommandOutput, QueryInput, QueryOutput, realtime input/state, event) and is a strict serde typestructs that implement PluginMessage
CommandA state-changing, durable, replay-safe operation. The decision function is pure: no I/O, clock, or randomnessTypedCommand::decide, OpenPlayPlugin::register
QueryA read-only read; only the input/output codecs are registeredOpenPlayPlugin::register_queries
Artifact (schema document)The plugin.schema.json that client SDKs and bindings read: schemas, the operation table, modules, schemaSha256OpenPlayPlugin::artifact, PluginArtifact::from_manifest
Realtime module (optional)A server-authoritative live input/state reducerOpenPlayPlugin::register_realtime, TypedRealtimeModule
DispatcherWhere a command actually writes to the database and a query reads; it uses the server’s shared transaction boundary (PluginUnitOfWork)PluginCommandDispatcher, PluginQueryDispatcher, PluginCommandResultLookup
Panel surface (optional)Declarative admin screens in the panel, custom components, a game-rule catalog, a leaderboard declarationadmin_surface, ui_components, game_rule_catalog, leaderboards

The split of responsibility matters: the server (host) does idempotency, the optimistic lock, the single database transaction, event and outbox writes, the receipt, and the access check. The plugin only supplies the messages, the pure decision, and the plan of records to write (PluginMutationPlan). A durable result (payment, XP, items, rewards) always goes through the command path; realtime state is only for speed and cannot produce an economic effect.

A small example: “hello counter”

The example below follows the pattern of the smallest real plugins (crx-plugin-arena, crx-plugin-scene): one command (increment) and one query (getCounter). The code excerpts are taken byte for byte from the files under docs/developer/examples/rust/ in the repository, and a test in the crx-plugin-arena crate really compiles them and registers the plugin, so the code on this page cannot go stale.

The three parts live in the same module (a use in one is visible in the others).

1. Messages

Every message ID starts with the plugin ID (the hello-counter. prefix; otherwise registration is rejected). Use PluginI64 for int64 values: on the wire they travel as decimal text (numbers and encoding). deny_unknown_fields rejects unknown fields.

#![allow(unused)]
fn main() {
// Mesajlar: her biri tek bir yönü olan, katı (deny_unknown_fields) serde tipleridir.
use crx_plugin::{MessageDirection, PluginI64, PluginMessage};
use serde::{Deserialize, Serialize};

pub const PLUGIN_ID: &str = "hello-counter";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IncrementInput {
    pub by: i32,
}
impl PluginMessage for IncrementInput {
    const MESSAGE_ID: &'static str = "hello-counter.increment.input";
    const SCHEMA_VERSION: u32 = 1;
    const DIRECTION: MessageDirection = MessageDirection::CommandInput;
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IncrementOutput {
    pub value: PluginI64, // int64 tel üzerinde ondalık metindir
}
impl PluginMessage for IncrementOutput {
    const MESSAGE_ID: &'static str = "hello-counter.increment.output";
    const SCHEMA_VERSION: u32 = 1;
    const DIRECTION: MessageDirection = MessageDirection::CommandOutput;
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetInput {}
impl PluginMessage for GetInput {
    const MESSAGE_ID: &'static str = "hello-counter.get.input";
    const SCHEMA_VERSION: u32 = 1;
    const DIRECTION: MessageDirection = MessageDirection::QueryInput;
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetOutput {
    pub value: PluginI64,
}
impl PluginMessage for GetOutput {
    const MESSAGE_ID: &'static str = "hello-counter.get.output";
    const SCHEMA_VERSION: u32 = 1;
    const DIRECTION: MessageDirection = MessageDirection::QueryOutput;
}
}

2. The plugin: manifest, command, query

The command’s decision function decide is not async and does no I/O; it only validates the input and produces the result. If you need time and a seed, the dispatcher takes them from the host and writes them to the event (eval_now_ms, eval_seed); decide never uses SystemTime or rand.

#![allow(unused)]
fn main() {
// Eklenti: manifest + komut/sorgu kaydı. Kalıcı yazım burada değil, dispatcher'ın mutasyon planındadır.
use crx_application::{
    plugin_platform::{OpenPlayPlugin, PluginRegistryError},
    plugin_runtime::{PluginRegistrar, PluginRuntimeError, TypedCommand},
};
use crx_plugin::{ManifestError, PluginArtifact, PluginId, PluginManifest, PluginPermission, PluginVersion};
use serde_json::json;

pub struct IncrementCommand;
impl TypedCommand for IncrementCommand {
    type Input = IncrementInput;
    type Output = IncrementOutput;

    // Saf ve eşzamansız olmayan karar: yalnız doğrular, I/O yapmaz.
    fn decide(&self, input: &Self::Input) -> Result<Self::Output, PluginRuntimeError> {
        if !(1..=100).contains(&input.by) {
            return Err(PluginRuntimeError::HandlerRejected);
        }
        Ok(IncrementOutput { value: PluginI64::new(i64::from(input.by)) })
    }
}

pub struct HelloCounterPlugin;

fn plugin_id() -> PluginId {
    PluginId::try_new(PLUGIN_ID).expect("static plugin id")
}

impl OpenPlayPlugin for HelloCounterPlugin {
    fn manifest(&self) -> Result<PluginManifest, ManifestError> {
        PluginManifest::new(
            plugin_id(),
            PluginVersion::new(1, 0, 0),
            1,                       // host API major
            vec![plugin_id()],       // provides
            vec![],                  // requires
            vec![PluginPermission::Outbox],
            json!({"type":"object","additionalProperties":false,"properties":{}}),
            vec![
                IncrementInput::descriptor().expect("descriptor"),
                IncrementOutput::descriptor().expect("descriptor"),
                GetInput::descriptor().expect("descriptor"),
                GetOutput::descriptor().expect("descriptor"),
            ],
            "0".repeat(64),          // migration checksum (64 onaltılık karakter)
            vec![],                  // realtime modülleri
        )
    }

    fn register(&self, registrar: &mut PluginRegistrar) -> Result<(), PluginRuntimeError> {
        registrar.register_command(IncrementCommand)?;
        Ok(())
    }

    fn register_queries(&self, registrar: &mut PluginRegistrar) -> Result<(), PluginRuntimeError> {
        registrar.register_query::<GetInput, GetOutput>()?;
        Ok(())
    }

    fn artifact(&self) -> Result<Option<PluginArtifact>, PluginRegistryError> {
        Ok(Some(plugin_artifact()?))
    }
}
}

3. The artifact

The artifact must tie every message in the manifest to an operation or a module; an extra or missing message is an error at registration. Commands require idempotency_required: true, a positive receipt retention, and a retry policy other than Read; for queries the opposite holds.

#![allow(unused)]
fn main() {
// Artifact: SDK'nın ve bağlamaların okuduğu şema belgesi; manifestle birebir eşleşmek zorundadır.
use std::collections::BTreeMap;

use crx_plugin::{
    MessageId, PluginAccess, PluginArtifactSpec, PluginHttpMethod, PluginJournalPolicy,
    PluginOperationDefinition, PluginOperationKind, PluginRetryPolicy,
};

fn operation(
    name: &str,
    kind: PluginOperationKind,
    request: (&str, &str),  // (şema adı, mesaj kimliği)
    response: (&str, &str),
) -> PluginOperationDefinition {
    let is_command = kind == PluginOperationKind::Command;
    let segment = if is_command { "commands" } else { "queries" };
    PluginOperationDefinition {
        name: name.to_owned(),
        kind,
        path: format!("/v1/plugins/{PLUGIN_ID}/{segment}/{}", request.1),
        method: if is_command { PluginHttpMethod::Post } else { PluginHttpMethod::Get },
        request_schema: request.0.to_owned(),
        response_schema: response.0.to_owned(),
        schema_version: 1,
        retry: if is_command { PluginRetryPolicy::SameKeyCommand } else { PluginRetryPolicy::Read },
        journal: if is_command { PluginJournalPolicy::Full } else { PluginJournalPolicy::None },
        resource: None,
        result_path: is_command
            .then(|| format!("/v1/plugins/{PLUGIN_ID}/commands/{}/result", request.1)),
        success: Some(200),
        access: PluginAccess::Player,
        request_message_type: MessageId::try_new(request.1).expect("message id"),
        response_message_type: MessageId::try_new(response.1).expect("message id"),
        receipt_retention_seconds: is_command.then_some(86_400),
        idempotency_required: is_command,
        deprecated: None,
    }
}

pub fn plugin_artifact() -> Result<PluginArtifact, PluginRegistryError> {
    let value = json!({
        "type":"object","additionalProperties":false,
        "properties":{"value":{"type":"integer","format":"int64"}},
        "required":["value"]
    });
    let schemas = BTreeMap::from([
        ("IncrementInput".to_owned(), json!({
            "type":"object","additionalProperties":false,
            "properties":{"by":{"type":"integer","format":"int32","minimum":1,"maximum":100}},
            "required":["by"]
        })),
        ("IncrementOutput".to_owned(), value.clone()),
        ("GetInput".to_owned(), json!({"type":"object","additionalProperties":false,"properties":{}})),
        ("GetOutput".to_owned(), value),
    ]);
    let spec = PluginArtifactSpec {
        schemas,
        commands: BTreeMap::from([("increment".to_owned(), operation(
            "increment",
            PluginOperationKind::Command,
            ("IncrementInput", IncrementInput::MESSAGE_ID),
            ("IncrementOutput", IncrementOutput::MESSAGE_ID),
        ))]),
        queries: BTreeMap::from([("getCounter".to_owned(), operation(
            "getCounter",
            PluginOperationKind::Query,
            ("GetInput", GetInput::MESSAGE_ID),
            ("GetOutput", GetOutput::MESSAGE_ID),
        ))]),
        modules: BTreeMap::new(),
    };
    let manifest = HelloCounterPlugin.manifest().map_err(PluginRegistryError::Manifest)?;
    Ok(PluginArtifact::from_manifest(&manifest, spec)?)
}
}

What the example does not do: durable writes

IncrementCommand::decide only validates the input. The code that really increments the counter lives in the dispatcher. A dispatcher does the following (real example: crx-plugin-work-items, prepare_create and prepare_claim):

  1. It authorizes the call with PluginRegistry::prepare_command, strictly decodes the input, and computes the fingerprint.
  2. It reads the target record (PluginRecordQuery) and checks its version for the optimistic lock.
  3. It builds a PluginMutationPlan: the records to write (PluginRecordWrite, with expected version and indexes) and the events to append (PluginEventAppend).
  4. It applies the plan with PluginUnitOfWork in a single database transaction; the result returns only after the commit (or from the receipt of the same key).

Plugins write their records to the shared plugin_records (and index, event, receipt) tables; a dedicated SQL table per plugin is mostly not needed. If custom SQL is needed, it goes in an additive migration in the plugin’s own migration namespace.

Note

Today command routing on the server is done with per-plugin dispatchers wired at the composition root (bin/crx-api). A new plugin that has commands also needs its own dispatcher and this wiring; a manifest + register alone is not enough. Plugins that only have a realtime module (crx-plugin-arena, crx-plugin-scene) do not have this step.

Registering with the server

The plugin crate is added to the dependencies of bin/crx-api (and bin/crx-worker if it has scheduler or outbox work) and handed to the registry at startup:

#![allow(unused)]
fn main() {
let mut registry = PluginRegistryBuilder::new();
registry.register_plugin(HelloCounterPlugin)?;
let registry = registry.freeze()?;
}

freeze consumes the registry and makes it immutable. A duplicate plugin/message/module, a missing or incompatible dependency, an unsupported host API major, a mixed codec, and a namespace violation stop startup; the error shows up right after the build, not at runtime.

Artifact, pins, and the release lock

schemaSha256 is the canonical JSON digest of the artifact’s schemas. If the schemas change, the digest changes; if only the version text changes, it does not. Clients pin this digest in their bindings (bindings).

In the repository, three places are updated from the same source in the same change:

  1. artifacts/plugins/<plugin>.plugin.schema.json: exported from Rust (PluginArtifact::to_pretty_json; example: export_arena_artifact in crx-plugin-arena).
  2. artifacts/plugins/release-lock.json: per plugin, the artifact path, the generated binding path, the exact version, schemaSha256, the protocol major, the codec profile, and the module IDs.
  3. The generated client binding: never edited by hand; regenerated with the generator.

A test verifies that “the JSON in the repository is an exact copy of what Rust produces” (checked_in_artifacts_are_exact_rust_exports, crx-plugin-arena). A pin mismatch blocks the release; no compatibility layer that accepts the old digest is added. Regenerate.

Generating a binding for game clients

A game developer takes the plugin’s artifact, generates their own binding, and pins the digest deliberately:

npx crx plugins generate \
  --input hello-counter.plugin.schema.json \
  --schema-sha256 <the schemaSha256 inside the artifact> \
  --output src/generated/hello-counter.ts

In CI, --check verifies the file is up to date. Details and the Unity limit: bindings. A ready Unity binding exists today only for the package-ride plugin.

Versioning and safe release

The environment’s plugin grant must match the exact version. If a new plugin version is running on the server while the environment’s grant still points to the old one, plugin and realtime calls are rejected in that environment (403 forbidden; the schema endpoint returns 404). This is a real incident: raising the version and forgetting to switch the grant to the new version.

Order for raising a version:

  1. Gates: the plugin’s tests, the artifact exact-match test, and cargo xtask check must be green; if there is a migration, up + down must work.
  2. Drain: move the environment’s plugin grant from Enabled to Draining. New commands and inputs are rejected immediately. Wait at least the drain minimum: the longest realtime state retention in the manifest + 15 s; 15 s for a plugin with no realtime module.
  3. Binary: deploy the new server image (with the migration, if any); migrations must be additive.
  4. Switch the grant to the new version: in the panel, Switch to version (Plugins). If you skip this, the new binary rejects plugin calls.
  5. Client: ship the game client with the new binding and (if needed) SDK.
  6. Verify: watch command results, receipt replays, and the count of rejected calls.

Rollback rules: if the plugin has not yet produced writes in the new version, go Draining → the previous compatible binary and grant. After writes have been produced, do not blindly roll the binary back: stop writes with ReadOnly or Disabled, verify state from receipts and event records, and ship a forward-fix version.

Enabling per environment

Being compiled into the server is not enough; every environment needs a separate grant row. In the panel, you bind the plugin to the game on the Plugins screen; the states are Enabled, ReadOnly, Draining, Disabled (what they mean: plugins overview). If a plugin permission depends on another product module (for example the leaderboard permission on the Competition module), the transition returns 409 plugin_requires_module while that module is off. For a canary, first set ReadOnly in a separate test environment, then Enabled, and observe.

Panel integration

A plugin offering screens in the panel is optional and needs no plugin-specific panel code:

  • admin_surface: a declarative admin surface. It binds only to operator operations with server access and is validated against the manifest and artifact at registration. The surface is not part of the artifact: it is not shown to players or the SDK, and it does not change schemaSha256, the version, or the grant.
  • ui_components: single-file HTML components the plugin embeds into the build with include_str! (they run in an isolated frame). The digest in the declaration is computed from the asset; a mismatch is rejected at registration.
  • game_rule_catalog: game-rule values adjustable from the panel (game rules).
  • leaderboards: a declaration of the tables the plugin writes scores to in the core leaderboard (needs PluginPermission::Leaderboard).

Registering a surface requires the plugin to have an artifact; a surface without an artifact is rejected at registration.

Tests

Add at least:

  • Manifest ↔ artifact exact match and schema digest drift (PluginArtifact::validate_against).
  • Whether the artifact JSON in the repository and the release-lock.json row equal the Rust output.
  • The int64 codec, bounded input (length, range), and rejected boundary values.
  • Several plugins in one frozen registry (PluginRegistryBuilder; example: crx-plugin-arena/tests/two_game_contract.rs).
  • For durable commands, a transaction test with real PostgreSQL: a single application when the same command repeats, version conflict, receipt (example: crx-infra-postgres/tests/plugin_uow_d1_integration.rs).

At close, run cargo xtask check and the full gate with isolated dependencies; a --lib run alone is not full proof.

Checklist

  • The plugin ID is lowercase with hyphens; every message has the plugin. prefix.
  • All input/output types use deny_unknown_fields; limits are explicit in the schema; PluginI64 for int64.
  • The decision function is pure (no I/O, clock, or randomness); the durable result is in the dispatcher’s plan.
  • Commands are idempotent with receipt retention defined; queries are read-only with explicit access (player, server, public).
  • The artifact, the JSON in the repository, release-lock.json, and the generated binding were updated in the same change.
  • Registration was done in bin/crx-api (and bin/crx-worker if needed); startup passes freeze.
  • The release order was followed: drain, binary, switch the grant to the new version, client.
  • The environment grant exists and its state is right.

Common mistakes

MistakeSymptomThe right way
Raised the version, did not switch the grantPlugin and realtime calls get 403 forbidden, the schema endpoint 404Drain, binary, then Switch to version
Changed a schema, did not refresh the artifact/pinThe artifact exact-match test is red; schemaSha256 mismatch in the clientRegenerate the artifact from Rust; update the release lock and binding in the same change
Resent a command with an uncertain outcome using a new keyThe same operation is applied twiceThe same Idempotency-Key and the same body; if unsure, use the result query first (calling)
Defined int64 as a JSON numberLoss on large values or a codec errorPluginI64; decimal text on the wire
Clock, randomness, or network inside decideReplay gives a different resultTime and seed come from the host context; the durable effect is in the dispatcher plan
An economic result in a realtime reducerThe result is lost if Redis is lostDurable results go through the command path
An extra or missing message in the artifactStartup freeze/registration errorDerive the manifest and artifact from the same Rust source
A client API key for a command401A player session or a server key
Added a command without wiring a dispatcherThe command cannot be routedWrite a dispatcher and wire it at the composition root

Known limits

  • There is no third-party plugin upload, sandboxed execution, or signed distribution.
  • There is no separate SDK for writing a plugin out of tree; a plugin is a crate in the Open Play repository tree.
  • There is no command that generates a new plugin skeleton (scaffold); copy the pattern from the existing small plugins.
  • For plugins with commands, the dispatcher wiring is done by hand.

Next step