PlayerThree for Godot

A pure-GDScript Godot 4 addon that seats MCP agents in your Godot dedicated server. It spawns the PlayerThree sidecar host next to the server and speaks the SPEC §20.8 (a) sidecar protocol (protocol 1) with it. The host serves MCP, OAuth, guards, mandates and sessions. Your game keeps the simulation and the clock.

Godot server ──stdin/stdout NDJSON──▶ playerthree-sidecar (Node) ──MCP──▶ agents
  your sim, adapter, bots                PlayerThreeHost: guards, seats, policies

Status: pre-release. You need a PlayerThree checkout with packages/sidecar, plus Node 22 or later. Nothing is on npm or the Godot Asset Library yet.

Install

  1. Copy addons/playerthree/ into your project's addons/. The addon needs Godot 4.5 or later (abstract classes); it is tested on 4.7.2.
  2. Optionally enable the plugin (Project → Project Settings → Plugins). It registers the playerthree/sidecar/program and playerthree/sidecar/arguments project settings, which PlayerThreeSidecar uses when its own program and arguments are empty.
  3. Point the node at the host: node <checkout>/packages/sidecar/bin/playerthree-sidecar.ts --stdio [--port 8787].

The 30-second sample

The Tug of War sample (samples/tug_of_war/) is the README's Node example, written natively in GDScript. It has two teams, one puller role, a rope at −10…10, and pull intents. Run it headless as a dedicated server from this directory:

godot --headless --path . --import            # once: builds the class cache
godot --headless --path .                     # spawns ../../packages/sidecar, runs one match

The server creates a match with an MCP agent seat on north and the built-in easy bot on south. It writes the north seat token to user://tug-seat-token, a file only your OS user can read (mode 0600, removed when the host connection closes; a killed server leaves the already-revoked token behind). It logs that file's path and the MCP URL, never the token itself, so you can point a harness at it. It runs the engine clock at 10 ticks/s and logs the rope once per game second. Until an agent plays north, the absent seat falls back to the built-in bot, which pulls as hard as south's, so the rope stays near 0. When the match ends, the server disposes it and exits with the host's exit code. Sample options go after --: --node=/path/to/node and --sidecar=/path/to/playerthree-sidecar.ts. Host options go after a second --, for example godot --headless --path . -- -- --port 0.

The whole game is three small scripts:

# tug_game.gd: the definition: manifest + factories
extends PlayerThreeGame

func manifest() -> Dictionary:
	var S := PlayerThreeSchema
	var m := PlayerThreeManifest.new()
	m.id = "tug"; m.version = "1.0.0"; m.title = "Tug of War"; m.tick_rate = 10
	m.doc = "Pull the rope to your side (±10) to win."
	m.teams = S.enumeration(["north", "south"])
	m.roles = S.enumeration(["puller"])
	m.role_doc("puller", "Pulls the rope.")
	m.intent("pull", ["puller"], {"strength": S.integer(1, 3)}, "Pull this tick.")
	m.observation = S.obj({"tick": S.integer(0, 100000), "rope": S.integer(-10, 10, "+ is north")})
	# … view, report, event, directive, errors, opponents, control
	return m.to_dict()

func create_match(_options: Dictionary) -> PlayerThreeAdapter: return TugMatch.new()
func create_bot(_seat: Dictionary, _opponent: String, _options: Variant) -> PlayerThreeBot: return TugBot.new()
# tug_match.gd: one running match (SPEC §10 adapter)
extends PlayerThreeAdapter
var rope := 0
func step() -> void: tick += 1
func outcome(team: String) -> String:
	if absi(rope) < 10: return "running"
	return "win" if (rope > 0) == (team == "north") else "loss"
func observe(_seat: Dictionary, _since: int, _detail: Array) -> Variant: return {"tick": tick, "rope": rope}
func apply(seat: Dictionary, intent: Dictionary, _context: Variant) -> Dictionary:
	rope = clampi(rope + (intent.strength if seat.team == "north" else -intent.strength), -10, 10)
	return {"ok": true}
# … view, report, events, set_seat_info
# main.gd: the server
var sidecar := PlayerThreeSidecar.new()
sidecar.arguments = ["../../packages/sidecar/bin/playerthree-sidecar.ts", "--stdio"]
add_child(sidecar)
sidecar.connected.connect(func(_hash, mcp_url):
	var session: String = sidecar.create_match({"controllers": {"north": {"puller": "agent"}, "south": {"puller": "bot"}}})["data"]["session"]
	var token: String = sidecar.request("mintSeatToken", [session, "north", "puller"])["data"]["token"]
	print(mcp_url, " token in ", _write_token(token)))  # an owner-only file; never log the token
sidecar.start(preload("tug_game.gd").new())

API

PieceWhat it is
PlayerThreeSidecar (Node)Spawns the host with OS.execute_with_pipe, sends hello, and answers every host call on the main thread. start(game), request(method, args) (host operations: createMatch, mintSeatToken, seats, cards, readiness, setController, setMandate, end, forfeit, dispose), advance(session, n), create_match(spec), run_clock/stop_clock, adapter(session), close(). Signals: connected, failed, session_created, session_disposed, advanced, pushed, match_ended, closed.
PlayerThreeGameAbstract definition: manifest(), hooks(), create_match(options), create_bot(seat, opponent, options).
PlayerThreeAdapterAbstract match: tick plus step, outcome, view, observe, apply, report, events, set_seat_info. Optional hooks are plain methods declared in hooks(): match_data, debug_view, observer_view, end, forfeit, rank, clone, signal_limits. An optional dispose() runs when the host releases the match.
PlayerThreeBotAbstract built-in commander: think(observation) -> Array, optional init(match_data). The host forwards bot seats and practice opponents to it.
PlayerThreeManifest, PlayerThreeSchemaBuild the manifest as plain JSON. The schema nodes are the Node SDK's t nodes; integer/number/boolean/enumeration/text stand in for t.int/num/bool/enum/str, whose names GDScript reserves. check() lists missing or mistyped members; the host runs the full defineManifest checks at hello.
PlayerThreeFramingThe §20.8 framing: NDJSON lines of at most 1048576 bytes (excluding \n), with UTF-8 and JSON validation and strict JSON encoding (no NaN, infinity, objects or non-string keys).

Clock and threading. The engine owns the clock. With advance_in_physics (the default), every session started through create_match advances from _physics_process at the manifest's tickRate. The node catches up at most one second of ticks per frame; paused and over replies are honoured. advance and request block the main thread until the host replies, and answer the host's calls meanwhile, so each tick runs synchronously as SPEC §10 requires. Calls that arrive between ticks (an agent's observe, say) are answered from _process. Signals are emitted deferred, so their handlers can call request. Inside an adapter call, request returns busy.

Why stdio, not a socket. Godot 4 has no Unix-domain-socket or named-pipe client, and loopback TCP would break §20.8's "never on a network interface". So the node always spawns the host and uses its stdin/stdout. The pipes stay blocking: a non-blocking execute_with_pipe pipe silently drops the unwritten tail of a large write. One thread reads the host's stdout, another forwards its stderr log lines to Godot's stderr, and writes block until the host has read them.

Values. Everything crossing the wire is plain JSON. A seat is {"team", "role"}, an intent is a Dictionary with kind, and integral numbers arrive as int. The node answers protocol violations, unreadable lines and oversized lines with the §20.8 fatal error (bad_frame, too_large) and closes. When a match returns a non-JSON value, a null where a value is required, or a step that does not advance tick, the call fails with engine_error.

Tests and conformance

godot --headless --path . --script res://tests/run_tests.gd        # framing, schema/manifest JSON, dispatch, pipes
godot --headless --path . -- --conformance                         # the sample against the conformance runner

--conformance spawns packages/sidecar/conformance/run.ts --stdio in place of the sidecar. The runner is the real host plus the transcript, error-case and size-limit checks, and the sample exits with the runner's verdict.

Supported, and not yet

  • Supported: Linux x86_64 (tested headless on Godot 4.7.2). The match adapter, every optional hook, built-in bots, every protocol-1 host operation, engine-driven ticking, fatal-error handling and the 1 MiB frame limit.
  • Not verified: macOS and Windows. The code paths are the same OS.execute_with_pipe pipes, but they have not been run there.
  • Not yet: connecting to a host started separately over its --socket (Godot has no client for it); Web exports (no processes); the lobby-profile operations reserve, release and capabilities, which protocol 1 does not define.

Licensed under the Apache License, Version 2.0, like the rest of PlayerThree.