PlayerThree for Unity and .NET
Seat MCP agents, policies and bots in a Unity (or any C#) dedicated server. Your server keeps the simulation and the clock; the PlayerThree sidecar host runs next to it as a separate process and serves MCP, OAuth, guards, scopes, mandates, presence, policies and sessions (SPEC §20.8). The two speak newline-delimited JSON over the sidecar's stdio or an owner-only local socket, never a network interface.
sdks/unity/
gg.playerthree.sidecar/ Unity package (UPM)
Runtime/Core/ engine-independent core, netstandard2.1 (also the .NET library's sources)
Runtime/Unity/ PlayerThreeSidecarRunner MonoBehaviour
Samples~/TugOfWar/ Tug of War: the game (shared with the console sample) and the scene script
dotnet/
PlayerThree.Sidecar/ the core as a netstandard2.1 library
PlayerThree.Sidecar.Tests/ xunit tests: JSON, framing, manifest JSON, the client against a scripted host
TugOfWar.Console/ Tug of War as a plain .NET console serverInstall
Unity 2021.3+ (API compatibility level .NET Standard 2.1): Package Manager → Add package from git URL…
https://github.com/fraywing/player-three.git?path=/sdks/unity/gg.playerthree.sidecarthen import the Tug of War sample from the package's page. The sidecar host needs Node 24+ and a checkout of this
repository (nothing is on npm yet); by default the runner spawns
node ../player-three/packages/sidecar/bin/playerthree-sidecar.ts --stdio, relative to the Unity project folder.
.NET (any netstandard2.1 runtime): reference dotnet/PlayerThree.Sidecar/PlayerThree.Sidecar.csproj. No NuGet
dependencies; the core carries its own small JSON reader and writer.
The 30-second sample
pnpm install # once, at the repository root
cd sdks/unity/dotnet
dotnet run --project TugOfWar.Console # spawns the sidecar, starts a match, prints the agent's connect lineIt prints the MCP endpoint and a claude mcp add … line that reads the north seat's token from an owner-only file
(mode 0600, ~/.local/share/playerthree/tug-seat-token on Linux); the token itself is never printed or logged. South is
the built-in easy bot. The engine advances one tick every 100 ms until a side drags the rope to ±10. Until an agent
connects, the absent north seat falls back to the same bot, so the rope holds at 0 and the match runs until you stop
it (Ctrl-C).
The whole game is Samples~/TugOfWar/TugOfWar.cs:
var manifest = new ManifestBuilder("tug", "1.0.0", "Tug of War")
.TickRate(10).Doc("Pull the rope to your side (±10) to win.")
.Teams("north", "south").Roles("puller").RoleDoc("puller", "Pulls the rope.")
.Intent("pull", new[] { "puller" }, "Pull this tick.", ("strength", T.Int(1, 3)))
.Observation(T.Obj(("tick", T.Int(0, 100000)), ("rope", T.Int(-10, 10, "+ is north"))))
.View(T.Obj(("rope", T.Int(-10, 10))))
.Report(T.Obj(("outcome", T.Enum("win", "loss", "draw", "running"))))
.Event(T.Obj(("tick", T.Int(0, 100000)), ("kind", T.Enum("slip"))))
.Directive(T.Obj()).Errors("tired").Opponents("easy").IntentRate(4, 4)
.Build(); // the same plain JSON as the TypeScript defineManifest
public sealed class TugMatch : IAdapter // observe / apply / view / report / events / step / outcome / setSeatInfo
{
public int Tick { get; private set; }
public int Rope { get; private set; }
public void Step() => Tick++;
public ApplyResult Apply(Seat seat, JsonValue intent, IntentContext context)
{
var s = intent["strength"].AsInt();
Rope = Math.Max(-10, Math.Min(10, Rope + (seat.Team == "north" ? s : -s)));
return ApplyResult.Accepted;
}
// …
}
using var sidecar = PlayerThreeSidecar.Start(
SidecarTransport.Spawn("node", new[] { "packages/sidecar/bin/playerthree-sidecar.ts", "--stdio" }),
new SidecarOptions { Manifest = manifest, Game = new TugGame(), Hooks = HookSet.Of<TugMatch>() });
var session = sidecar.CreateMatch(JsonValue.Parse("{\"seed\":1,\"controllers\":{\"north\":{\"puller\":\"agent\"}}}"));
while (!sidecar.HasEnded(session)) { sidecar.Step(session, 1); sidecar.Pump(100); }In Unity, add TugOfWarServer (from the imported sample) to a GameObject in an empty scene and press Play: it adds a
PlayerThreeSidecarRunner, which answers host calls in Update and advances the clock from FixedUpdate at the
manifest's tickRate. The seat token goes to an owner-only file under Application.persistentDataPath; the log shows
only that path and the MCP URL, and the screen shows a connect line that reads the file.
How it works
PlayerThreeSidecar.Start(transport, options)sendshellowith the manifest and the declared hooks and waits forready(SurfaceHash,McpUrl). Transports:SidecarTransport.Spawn(the sidecar over stdio, the primary mode),SidecarTransport.Connect(path)(a host listening on a Unix domain socket or\\.\pipe\name),SidecarTransport.FromEnvironment()(PLAYERTHREE_SIDECAR_SOCKET),SidecarTransport.FromStreams.- Your game implements
IGame(Createa match,CreateBota built-in bot) andIAdapterper match (§10's eight calls). Optional hooks are interfaces —IMatchDataAdapter,IDebugViewAdapter,IObserverViewAdapter,IEndAdapter,IForfeitAdapter,IRankAdapter,ICloneAdapter— declared withHooks = HookSet.Of<TMatch>(). An adapter implementingIDisposableis disposed when the host releases its session. - Threading: a background thread reads the channel; host calls run only on the thread that called
Start, insidePump(),Pump(ms),Step(session, n)andRequest(...), so adapters touch engine state without locks. Calling those from another thread, or from inside an adapter call, throws.WorkAvailablefires on the reader thread when calls are waiting, for engines that schedulePump()on their own loop instead of polling. - Engine-owned clock:
Step(session, n)sendsadvance; while it is outstanding the SDK answers the host'sapply/step/observe… calls on your thread and returns the new tick.pausedandoverarrive asSidecarRequestException.Code. - Game-side operations:
CreateMatch,MintSeatToken,DisposeSession, andRequest(method, args…)for the rest (seats,cards,readiness,setController,setMandate,end,forfeit). Never log a seat token:SeatTokenFile.Write(path, token)puts it in a file only the current OS user can read (0600 on Unix, created empty and restricted before the token is written; on Windows choose a folder under the user profile). - Failures: an adapter exception answers
engine_error; malformed argumentsinvalid; unknown methods and sessionsunknown_method/unknown_session; a result over the 1048576-byte message limitengine_error. An oversized or malformed inbound line, an unexpected result id, a request timeout or the host closing the channel closes the connection:Closedfires on your thread, and the host has disposed every session — move agent and policy seats to your own bots.
Supported, and not yet
Sidecar wire protocol 1: hello/ready, calls, results, advance, host operations, pushes, fatal error | Yes |
| stdio (spawn the sidecar), Unix domain socket, Windows named pipe | Yes (named pipe untested here) |
| Typed manifest builder for the whole closed schema vocabulary, topics, skills, control modes | Yes |
| Optional hooks matchData, debugView, observerView, end, forfeit, rank, clone | Yes |
Team signals (manifest.signals, signalLimits) | Not yet |
Lobby profile reserve/release/capabilities | Not in protocol 1 |
Unity PlayerThreeSidecarRunner and the Tug scene script | Compiles only inside Unity; not verified in CI |
Develop
cd sdks/unity/dotnet
dotnet test PlayerThree.Sidecar.Tests # JSON, framing and limits, manifest JSON, client against a scripted host
node ../../../packages/sidecar/conformance/run.ts --socket -- dotnet run --project TugOfWar.Console
dotnet run --project TugOfWar.Console -- node ../../../packages/sidecar/conformance/run.ts --stdioThe conformance runner is the real sidecar plus checks of the Tug flow, error codes and size limits.
Licensed under the Apache License, Version 2.0 (see LICENSE).
