Policy ABI

A policy is agent-written code that maps one observation to a list of intents, at tick rate, with no LLM in the loop. Two artifact formats are accepted (manifest.control.policy.runtimes):

RuntimeArtifactEngine
jsJavaScript source text (classic script)QuickJS (bellard 2025-09-13) compiled to WebAssembly
wasmA core WebAssembly module, any languageV8's WebAssembly engine

Both follow the same lifecycle:

  1. Load (loadPolicy(runtime, code, limits, { seed, matchData })): size check, compile, ABI checks, instantiate, run top-level code / the wasm start function, then init(matchData) (JS) or init(ptr,len) (WASM) if present.
  2. Call (instance.call(observation)): the observation (the game's manifest.observation, already fogged and schema-checked) goes in as JSON; an array of intents comes out. State persists between calls of one instance (one match).
  3. Dispose. Instances never share state.

The output is untrusted: the host validates every intent exactly like an intent from a live agent (schema, role, controller, rate limit).

Before activation, the host probes an artifact on the actual seat's guarded current observation and bootstrap. Any runtime error, invalid schema/role intent or exceeded intent budget rejects the activation. Only after the probe succeeds does the host create a fresh live instance with clean state, calling init once again on that new instance; the probe's globals and counters never become live state. A controller or assignment change during validation prevents installation. Retired library/pool probes finish as explicit cancelled activation failures, dispose their VMs, and cannot overwrite a newer assignment's readiness; direct deploy calls report the cancellation to their awaiting caller as forbidden.

Saved commander library versions and pool entries retain the host-authored surfaceHash from deployment, beside artifactHash (SHA-256 of the module bytes: the composed JS source as UTF-8, or the decoded wasm module). Every library seating, pool activation and rollback compares the surface hash to the current surface and repeats the seat probe. Mismatches report stale_policy; failed probes report policy_failed; neither can satisfy readiness. A successful redeployment clears the failure. Memory, JSON and Postgres stores keep the hash with the artifact: JSON artifact files are <version>.json / <policyId>.json holding runtime, code, surface hash and artifact hash; PostgreSQL stores a required surface_hash column and derives artifactHash from the stored code. A database created by an older pre-release schema refuses to open (obsolete pre-release schema; explicit operator reset required); playerthree db reset drops and re-creates its tables, after which policies are redeployed against the current host.

Commander versions have host-recorded role provenance and independent policy.activeByRole selections. Deploying or rolling back one role never changes another role's selection; version numbers remain unique within the commander and retention is bounded to ten versions per role. Library restoration always uses a fresh VM and policy_status awaits its completion. Activation failure is exposed as activationError (stale_policy, policy_failed, or needs_role_deploy), not a transient or misleading absent policy. The store migrates old single-role records once. Old multi-role versions without role provenance are retained as role:null, never guessed or automatically replayed on every role; redeploy for each intended role. This does not change agent readiness: direct control remains valid without an installed policy.

Runtime stats distinguish successfulCalls from attempted calls, and consecutiveFailures from historical errors/timeouts. Successful empty intent lists count as decisions; successful calls reset the streak. lastError remains historical after recovery. Failed sandbox restarts count as failures, not successful decisions.

Match bootstrap

Match data is a separate initialization payload, never part of think's observation, including its first call. Games declare manifest.matchData as a closed t.obj schema and implement adapter.matchData(seat). The method is required when the schema exists. The host snapshots and validates the seat-visible result with the outbound guard before every fresh consumer, including deployment probes (lobby and match), library/pool activation, practice, benchmarks and crash restarts.

loadPolicy accepts optional opts.matchData: a JSON-compatible value, snapshotted at load time. Its serialized UTF-8 JSON is independently capped at 128 KiB (MATCH_DATA_MAX_BYTES, exported from playerthree). Invalid JSON input fails with bad_output; oversized input fails with too_large, without echoing data. Host guard failures use the matchData channel and fail closed; over-bound bootstrap yields too_long. No observation, artifact, output or memory limit is increased. Omission initializes with JSON null.

JavaScript's optional synchronous init(matchData) receives the parsed snapshot once per instance. WASM's optional init(ptr:i32,len:i32)->() receives the UTF-8 JSON in an alloc-provided, checked memory region, once after module startup. The old zero-argument WASM init signature is rejected. Initialization is subject to the ordinary load budget. Policies retain what they need in their own instance state.

Built-in bots may implement synchronous init(matchData); the host calls it once before their first think, including fallback bots and bot-level/controller recreation. Direct game-owned bot drivers must do the same. Agents receive the same guarded data as the match-data briefing topic (game_briefing {topic:"match-data"}), only in phase match; in the lobby that topic documents the fields only. All ordinary and stream observations remain dynamic-only. The game owns bootstrap field names and fog restrictions; there is no generic rules field, reconstruction, observation snapshot option or second think argument.

Observation detail

Games that declare an observationScope (spec §3) render each observation with a detail: the sections shown in full, the others as the game's compact summaries. A policy's observations use its role's default sections unless the artifact declares the sections it reads, once, as the string "all" or a list of section names (the game's observationScope.sections, see the core briefing's observationScope and topic observation):

  • js: a global named detail — var, let, const or a globalThis property — read once after top-level code and before init. Example: const detail = ['surface'];
  • wasm: one custom section named playerthree.detail whose bytes are the UTF-8 JSON value (e.g. ["surface"]), read before the module is instantiated. Rust: #[unsafe(link_section = "playerthree.detail")] #[used] static DETAIL: [u8; 11] = *b"[\"surface\"]";

The host resolves the declaration when it loads the artifact (deploy probe, activation, practice and restarts): every think of that instance then receives observations at the role's defaults united with the declared sections. A declaration that is not JSON data, not "all" or a list of unique section-name strings, names a section the game does not declare, or (wasm) comes in more than one section fails loading with bad_output. Built-in bots always see every section. Declaring more detail costs parse time on every call: read summaries where they suffice.

Team signals

In games declaring team signals (spec §22) every observation, the policy's included, has a host-owned signals: { inbox, sent } member: typed requests teammates sent this seat and the ones it sent, with their id, from, to, tick, expires, state, by (the responding role, once one answered) and payload. A policy answers with signal_update intents (ack, decline, done, fail; cancel for its own) and asks with signal intents, in the same intent array as everything else and under the same maxIntentsPerCall. Refusals count in intentsRejected as signal:<code>. Nothing in a signal is text.

Includes

A JS artifact may list include: ["<skill>/<path>", …] (≤ 16 distinct served skill .js files, e.g. a game's policy library) on policy_deploy and practice_run.artifact. The host composes included contents joined by "\n", then "\n", then code (exported as composePolicy(skills, code, include?)). Unknown, duplicate or non-.js entries, or include with wasm, are invalid; the size limit, probe, storage, versioning, publishing and hashing all use the composed source.

Isolation

  • Every instance runs in its own worker thread with its own engine instance (one QuickJS runtime + context, or one wasm instance). The host talks to it over shared memory; the policy has no other channel.
  • Hard kill. A call that overruns its budget is stopped wherever it is (tight loops, long native operations such as a big Array.prototype.sort, wasm loops): QuickJS is interrupted, and a host-side watchdog terminates the thread at 4 × msPerCall + 150 ms after the request if nothing else has. The instance is then dead.
  • No host capabilities. No file system, network, clock, timers, environment, randomness source, module loader or host objects. The only way out is the return value and log.
  • Policy stdout/stderr never reach the host's stdio.
  • Own-text rule. The only free text a policy produces that reaches an agent is its own log lines and engine messages about its own artifact (compile errors, exceptions, traps) — both authored by the same principal.

Limits

From PolicyLimits (the game's manifest):

LimitMeaning
runtimesArtifact formats the game accepts; any other is runtime_not_allowed.
hzCalls per second of game time: think runs every max(1, round(tickRate / hz)) ticks, the cadence built-in bots use too.
msPerCallCPU-time budget per call: CPU time of the sandbox's own thread, measured from the moment it picks the call up. Time the thread spends descheduled by the OS (a loaded machine), thread wake-up latency and host event-loop stalls never count, so whether an artifact fits its budget does not depend on machine load. The clock is the OS per-thread CPU clock (process.threadCpuUsage(), getrusage(RUSAGE_THREAD)); Linux advances it at scheduler ticks and context switches, so a reading can trail the true value by about one tick (4 ms at HZ=250). Separately, a wall-clock hang guard bounds work the thread cannot interrupt or a thread starved of CPU: 4 × msPerCall + 100 ms on the sandbox thread. Either overrun → timeout, instance dead. PolicyCallResult.cpuMs reports the budgeted CPU time, PolicyCallResult.ms the sandbox-side wall time.
memoryBytesjs: QuickJS heap. The engine's linear memory is capped at its fixed baseline (static data + 5 MiB native stack) + memoryBytes; any single allocation larger than memoryBytes is refused outright. Exhaustion → memory_limit, instance dead. wasm: the declared maxima of all memories together must be ≤ floor(memoryBytes / 65536) pages (checked at load).
maxArtifactBytesUTF-8 bytes of JS source (the composed source when include is used), or bytes of the wasm module.
maxIntentsPerCallExtra intents are dropped and counted in PolicyCallResult.dropped.
maxLogLinesLines kept per call; further log calls in that call are ignored.
maxLogLineBytesEach line is truncated to this many UTF-8 bytes, never splitting a character.

Fixed by PlayerThree:

LimitValue
Load budget (top-level code / start function + init)10 × msPerCall of sandbox-thread CPU time; hang guard 4 × load budget + 100 ms wall
Output size (UTF-8 JSON)1 MiB
Match bootstrap UTF-8 JSON, independently of observation input128 KiB
Output nesting depth (both runtimes, checked by the host on the parsed output)32
Engine/exception message length1 KiB
QuickJS stack (js)256 KiB (deeper recursion throws InternalError: stack overflow, catchable)
Table entries, all tables together (wasm)65,536
Host watchdog (thread terminated)Hang guard + 50 ms from sending the request: 4 × msPerCall + 150 ms per call, 4 × load budget + 150 ms for load

Errors

PolicyError.code (control.ts):

CodeWhenInstance
runtime_not_allowedRuntime not in limits.runtimes—
too_largeArtifact over maxArtifactBytes, or match bootstrap JSON over 128 KiB; on a call, an observation larger than the 256 MiB call channel—
compileJS syntax error (message: SyntaxError: … (policy.js:line:col)), or source not valid UTF-8—
bad_moduleInvalid wasm binary; 64-bit or shared memory, 64-bit table; custom page sizes; GC struct/array types—
imports_not_allowedAny import other than env.log (i32, i32) -> () (WASI included), imported memory—
missing_exportJS: no global think. wasm: missing memory/alloc/think, or a wrong signature—
memory_limitJS heap exhausted; wasm memory/table without a maximum or over budget (at load)dead
timeoutCall or load over budgetdead
threwJS exception (Name: message (policy.js:line:col)), wasm trap (RuntimeError: unreachable, …)usable*
bad_outputNot an array, not JSON, not UTF-8, out of bounds, too large, nested too deep; at load: invalid initialization JSON/region, or a malformed or unknown detail declarationusable after a call; no instance on load failure

* An engine-level crash (e.g. native stack exhaustion inside QuickJS itself) is reported as threw and kills the instance, since the engine's state can no longer be trusted. A dead instance answers every further call with the error that killed it. Log lines written before a non-fatal failure are returned with it (logs); lines logged during load are discarded.

Determinism

Same artifact + same seed + same match data + same observation sequence ⇒ the same outputs and logs, on the same PlayerThree version. This is what makes practice runs and replays reproducible.

  • js: Math.random() is an sfc32 generator seeded from seed (default 0). Date.now() returns 0 and new Date() is the epoch; explicit dates (new Date(ms), Date.UTC, Date.parse) work normally. Local-time getters follow the host's time zone — use the UTC getters. Promise jobs run after think returns, in order, inside the same budget; they cannot change the returned value.
  • wasm: the module receives no entropy source and no clock. Remaining nondeterminism is what the WebAssembly spec leaves to the engine: NaN bit patterns and relaxed-SIMD results — avoid depending on them.

JavaScript ABI

The artifact is a classic script (no import/export; module syntax is a compile error, and import() always rejects). Everything is a global:

function think(obs) -> intent[]   required; obs is the parsed observation
function init(matchData)          optional; parsed bootstrap, or null, once at load
detail                            optional global: observation sections read in full ("all" or a list)
log(...args)                      provided; appends one log line
  • think must return an array, synchronously. Its contents must be JSON data: null, booleans, finite numbers, strings, arrays and plain objects (prototype Object.prototype or null). undefined, functions, symbols, BigInt, NaN/Infinity, class instances and cycles are bad_output.
  • log: strings are written verbatim; other values as JSON.stringify (falling back to String), separated by spaces.
  • Available: the ECMAScript standard library (JSON, Math, Map/Set, typed arrays, RegExp, Proxy, Promise, …). Not available: console, require, process, fetch, setTimeout/setInterval, performance, WebAssembly, the QuickJS std/os modules, and any other host object.

Minimal example (intent kinds and observation fields come from the game's manifest; this one attacks the weakest visible enemy):

let lastTarget = null;

function think(obs) {
  if (obs.enemies.length === 0) return [{ kind: 'hold' }];
  let target = obs.enemies[0];
  for (const e of obs.enemies) if (e.hp < target.hp) target = e;
  if (target.id !== lastTarget) log('new target', target.id, 'hp', target.hp);
  lastTarget = target.id;
  return obs.units.map((u) => ({ kind: 'attack', unit: u.id, target: target.id }));
}

WebAssembly ABI

A core module (\0asm version 1), any source language.

Exports

NameKind / type
memorymemoryDefined by the module (not imported), 32-bit, unshared, standard 64 KiB pages, must declare a maximum.
alloc(len: i32) -> i32Returns a pointer to len writable bytes for bootstrap or observation JSON.
think(ptr: i32, len: i32) -> i64Returns (outPtr << 32) | outLen of the output.
init(ptr: i32, len: i32) -> ()Optional; receives bootstrap JSON once after instantiation (after the start function), before think.

An optional custom section playerthree.detail declares the observation detail (above, Observation detail).

Imports. Exactly one is permitted, and it is optional:

NameType
env.log(ptr: i32, len: i32) -> ()Appends one log line: the bytes [ptr, ptr+len) as UTF-8 (invalid sequences become U+FFFD).

Every other import — WASI, other env functions, imported memories, tables, globals, tags — is imports_not_allowed.

Initialization. If init is exported, the host encodes the bootstrap snapshot (or null) as UTF-8 JSON, calls alloc(len), checks the returned region and copies the bytes there, then calls init(ptr,len). Keep any needed state separately before reusing that region in later allocations. An invalid region fails loading with bad_output; a trap fails loading with threw. A zero-argument init is a signature error, not a compatibility mode.

Each call

  1. The host serialises the observation to UTF-8 JSON (len bytes) and calls alloc(len).
  2. It checks [ptr, ptr+len) lies inside memory and copies the observation there.
  3. It calls think(ptr, len) and splits the i64 (as unsigned): outPtr = r >> 32, outLen = r & 0xffffffff.
  4. [outPtr, outPtr+outLen) must lie inside memory, be ≤ 1 MiB and hold a UTF-8 JSON array nested at most 32 levels; it is copied out immediately, so the buffer may be reused on the next call.

alloc is called on every call; memory management is the module's business (a bump arena reset per call is fine). Pointers are unsigned. Traps (unreachable, out-of-bounds access, stack exhaustion, …) and a log with an out-of-bounds range are reported as threw; the instance stays usable with whatever state the trap left.

Static rules (checked at load, before any code runs)

  • Every memory declares a maximum, and the maxima together are ≤ floor(memoryBytes / 65536) pages. memory.grow past the maximum returns -1.
  • Every table declares a maximum, 65,536 entries in total (tables live on the engine heap).
  • No GC struct/array types (they would allocate on the engine heap without a static bound).
  • No shared memories (there are no threads to share with, and memory.atomic.wait would let a call idle instead of compute).

Minimal WAT

(module
  (import "env" "log" (func $log (param i32 i32)))
  (memory (export "memory") 2 16)                ;; at most 16 pages = 1 MiB
  (data (i32.const 0) "[{\"kind\":\"hold\"}]")   ;; 17 bytes of output
  (data (i32.const 32) "holding")

  ;; The observation goes at 64 KiB; grow memory to fit it.
  (func (export "alloc") (param $len i32) (result i32)
    (local $pages i32)
    (local.set $pages
      (i32.shr_u (i32.add (local.get $len) (i32.const 131071)) (i32.const 16)))
    (if (i32.gt_u (local.get $pages) (memory.size))
      (then (drop (memory.grow (i32.sub (local.get $pages) (memory.size))))))
    (i32.const 65536))

  (func (export "think") (param $ptr i32) (param $len i32) (result i64)
    (call $log (i32.const 32) (i32.const 7))
    ;; (outPtr << 32) | outLen
    (i64.or (i64.shl (i64.const 0) (i64.const 32)) (i64.const 17))))

If the observation does not fit, memory.grow fails, alloc still returns 65536, and the host reports bad_output (alloc returned a region outside memory).

Rust (no_std, wasm32-unknown-unknown)

Cargo.toml:

[lib]
crate-type = ["cdylib"]

[profile.release]
panic = "abort"
opt-level = "s"
lto = true

src/lib.rs (Rust 1.82 or newer, any edition):

#![no_std]

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    core::arch::wasm32::unreachable()
}

#[link(wasm_import_module = "env")]
unsafe extern "C" {
    fn log(ptr: *const u8, len: usize);
}

const INPUT_CAP: usize = 256 * 1024;
static mut INPUT: [u8; INPUT_CAP] = [0; INPUT_CAP];
static mut OUTPUT: [u8; 1024] = [0; 1024];
static mut CALLS: u32 = 0;

/// The host asks for `len` bytes and copies the observation (UTF-8 JSON) there.
#[unsafe(no_mangle)]
pub extern "C" fn alloc(len: usize) -> *mut u8 {
    if len > INPUT_CAP {
        core::arch::wasm32::unreachable(); // trap → `threw`
    }
    unsafe { core::ptr::addr_of_mut!(INPUT) as *mut u8 }
}

/// Returns `(ptr << 32) | len` of a UTF-8 JSON array of intents.
#[unsafe(no_mangle)]
pub extern "C" fn think(ptr: *const u8, len: usize) -> u64 {
    let obs = unsafe { core::slice::from_raw_parts(ptr, len) };
    let units = obs.windows(5).filter(|w| *w == b"\"id\":").count() as u32;
    let calls = unsafe {
        CALLS += 1; // state persists between calls of one instance
        CALLS
    };
    let msg = b"counted units";
    unsafe { log(msg.as_ptr(), msg.len()) };

    let mut out = Out { buf: unsafe { &mut *core::ptr::addr_of_mut!(OUTPUT) }, len: 0 };
    out.put(b"[{\"kind\":\"hold\",\"units\":");
    out.num(units);
    out.put(b",\"calls\":");
    out.num(calls);
    out.put(b"}]");
    ((out.buf.as_ptr() as usize as u64) << 32) | out.len as u64
}

struct Out {
    buf: &'static mut [u8],
    len: usize,
}

impl Out {
    fn put(&mut self, s: &[u8]) {
        self.buf[self.len..self.len + s.len()].copy_from_slice(s);
        self.len += s.len();
    }
    fn num(&mut self, mut v: u32) {
        let mut digits = [0u8; 10];
        let mut i = digits.len();
        loop {
            i -= 1;
            digits[i] = b'0' + (v % 10) as u8;
            v /= 10;
            if v == 0 {
                break;
            }
        }
        self.put(&digits[i..]);
    }
}

Build — the linker must declare the memory maximum, and Rust's default 1 MiB stack is far more than a policy needs:

RUSTFLAGS="-C link-arg=--max-memory=4194304 -C link-arg=-zstack-size=65536" \
  cargo build --release --target wasm32-unknown-unknown

--max-memory is in bytes (a multiple of 65,536, here 64 pages) and must fit memoryBytes. wasm-ld exports the memory as memory and gives the function table a fixed maximum by default (do not pass --growable-table); the __data_end/__heap_base exports it adds are harmless. Any toolchain that links with wasm-ld (C, C++, Zig) uses the same --max-memory flag.

Performance

Per-call cost with a ~50 KB observation (560 units), measured by the test suite on a desktop CPU, host-observed time including serialisation and the cross-thread round trip (PolicyCallResult.ms reports only the sandbox-side part):

RuntimeMeanNotes
wasm~0.4 msFixed overhead (shared-memory round trip) is ~0.05 ms; the rest is JSON serialisation and the policy's own scan.
js~3–5 msDominated by QuickJS parsing the observation (~1.5 ms per 50 KB) and the interpreter.

Keep observations compact and msPerCall comfortably above these numbers; a JS policy that must re-parse a large observation every tick is better written for wasm.