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, policiesStatus: 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
- Copy
addons/playerthree/into your project'saddons/. The addon needs Godot 4.5 or later (abstract classes); it is tested on 4.7.2. - Optionally enable the plugin (Project → Project Settings → Plugins). It registers the
playerthree/sidecar/programandplayerthree/sidecar/argumentsproject settings, whichPlayerThreeSidecaruses when its ownprogramandargumentsare empty. - 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 matchThe 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
| Piece | What 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. |
PlayerThreeGame | Abstract definition: manifest(), hooks(), create_match(options), create_bot(seat, opponent, options). |
PlayerThreeAdapter | Abstract 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. |
PlayerThreeBot | Abstract built-in commander: think(observation) -> Array, optional init(match_data). The host forwards bot seats and practice opponents to it. |
PlayerThreeManifest, PlayerThreeSchema | Build 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. |
PlayerThreeFraming | The §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_pipepipes, 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 operationsreserve,releaseandcapabilities, which protocol 1 does not define.
Licensed under the Apache License, Version 2.0, like the rest of PlayerThree.
