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:
| Part | What it does | Where it is defined |
|---|---|---|
| Manifest | The 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 modules | OpenPlayPlugin::manifest |
| Typed messages | Command/query input and output; each has one direction (CommandInput, CommandOutput, QueryInput, QueryOutput, realtime input/state, event) and is a strict serde type | structs that implement PluginMessage |
| Command | A state-changing, durable, replay-safe operation. The decision function is pure: no I/O, clock, or randomness | TypedCommand::decide, OpenPlayPlugin::register |
| Query | A read-only read; only the input/output codecs are registered | OpenPlayPlugin::register_queries |
| Artifact (schema document) | The plugin.schema.json that client SDKs and bindings read: schemas, the operation table, modules, schemaSha256 | OpenPlayPlugin::artifact, PluginArtifact::from_manifest |
| Realtime module (optional) | A server-authoritative live input/state reducer | OpenPlayPlugin::register_realtime, TypedRealtimeModule |
| Dispatcher | Where 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 declaration | admin_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):
- It authorizes the call with
PluginRegistry::prepare_command, strictly decodes the input, and computes the fingerprint. - It reads the target record (
PluginRecordQuery) and checks its version for the optimistic lock. - It builds a
PluginMutationPlan: the records to write (PluginRecordWrite, with expected version and indexes) and the events to append (PluginEventAppend). - It applies the plan with
PluginUnitOfWorkin 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 +registeralone 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:
artifacts/plugins/<plugin>.plugin.schema.json: exported from Rust (PluginArtifact::to_pretty_json; example:export_arena_artifactincrx-plugin-arena).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.- 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:
- Gates: the plugin’s tests, the artifact exact-match test, and
cargo xtask checkmust be green; if there is a migration, up + down must work. - Drain: move the environment’s plugin grant from
EnabledtoDraining. 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. - Binary: deploy the new server image (with the migration, if any); migrations must be additive.
- Switch the grant to the new version: in the panel, Switch to version (Plugins). If you skip this, the new binary rejects plugin calls.
- Client: ship the game client with the new binding and (if needed) SDK.
- 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 withserveraccess 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 changeschemaSha256, the version, or the grant.ui_components: single-file HTML components the plugin embeds into the build withinclude_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 (needsPluginPermission::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.jsonrow equal the Rust output. - The
int64codec, 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;PluginI64forint64. - 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(andbin/crx-workerif needed); startup passesfreeze. - 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
| Mistake | Symptom | The right way |
|---|---|---|
| Raised the version, did not switch the grant | Plugin and realtime calls get 403 forbidden, the schema endpoint 404 | Drain, binary, then Switch to version |
| Changed a schema, did not refresh the artifact/pin | The artifact exact-match test is red; schemaSha256 mismatch in the client | Regenerate the artifact from Rust; update the release lock and binding in the same change |
| Resent a command with an uncertain outcome using a new key | The same operation is applied twice | The same Idempotency-Key and the same body; if unsure, use the result query first (calling) |
Defined int64 as a JSON number | Loss on large values or a codec error | PluginI64; decimal text on the wire |
Clock, randomness, or network inside decide | Replay gives a different result | Time and seed come from the host context; the durable effect is in the dispatcher plan |
| An economic result in a realtime reducer | The result is lost if Redis is lost | Durable results go through the command path |
| An extra or missing message in the artifact | Startup freeze/registration error | Derive the manifest and artifact from the same Rust source |
| A client API key for a command | 401 | A player session or a server key |
| Added a command without wiring a dispatcher | The command cannot be routed | Write 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
- To call a plugin, see calling plugins.
- For a client binding and pinning the schema version, see bindings.
- To enable it in an environment and switch versions, see panel: Plugins.