PlayerThree for Unreal (C++ sidecar SDK)
Seat MCP agents in an Unreal (or any C++) dedicated server through the PlayerThree sidecar host (SPEC §20.8,
wire protocol 1). Your engine keeps the simulation and the clock. The sidecar, the normal Node PlayerThreeHost in
its own process, serves MCP, OAuth, guards, seats, policies and bots' scheduling, and calls back into your game over
a local channel: the stdio of a host process your server spawns, or one Unix domain socket / named pipe. It never
uses a network interface.
Status: experimental. The engine-independent C++ core and the CLI Tug of War sample are built and tested here (CMake + ctest, Linux, g++ 13), and the CLI sample passes the sidecar conformance runner. The Unreal plugin wrapper (
UPlayerThreeSubsystem) and the Unreal Tug of War sample have not been compiled: no Unreal toolchain was available. They are thin layers over the tested core. See What is supported.
Layout
| Path | What |
|---|---|
Plugins/PlayerThree/Source/PlayerThreeCore | The core, plain C++17 with no Unreal types: SidecarClient, framing, transports, ManifestBuilder and t::* schema nodes, the GameDefinition / GameAdapter / BotCommander interfaces. Vendors nlohmann/json 3.11.3 (MIT, see NOTICE). |
Plugins/PlayerThree/Source/PlayerThree | The Unreal wrapper: UPlayerThreeSubsystem (a UGameInstanceSubsystem + FTickableGameObject). |
Plugins/PlayerThreeTugOfWar | Unreal sample plugin: a replicated ATugOfWarRope actor, its adapter and ATugOfWarGameMode. |
Samples/TugOfWar, Samples/TugCli | The same game as a plain C++ library and the p3-tug command-line server. |
Tests | ctest suites for framing, schema/manifest, the sidecar client protocol and the OS transports. |
The 30-second sample (CLI)
# from a PlayerThree checkout with dependencies installed (pnpm install)
cmake -S sdks/unreal -B build/unreal -DCMAKE_BUILD_TYPE=Release
cmake --build build/unreal
build/unreal/p3-tug -- node packages/sidecar/bin/playerthree-sidecar.ts --stdiop3-tug sends hello with the Tug of War manifest, asks the host for one match (north is an agent seat, south is
the built-in easy bot), writes the north seat's bearer token to ./tug-seat-token (mode 0600; --token-file <path> to choose) and logs only that path and the MCP URL, then advances one tick every 100 ms until the host
pushes ended. Point an MCP client at that URL with that token and pull the rope. After the match it disposes it
and keeps the connection open until the host closes it (Ctrl-C to stop). Seat tokens are never logged.
To connect to a host that is already listening on a socket instead:
PLAYERTHREE_SIDECAR_SOCKET=/run/user/1000/p3.sock build/unreal/p3-tugYour own game
Describe the game as plain JSON and implement the adapter; the host guards everything you return.
#include "PlayerThree/Manifest.h"
#include "PlayerThree/Sidecar.h"
namespace t = playerthree::t;
playerthree::Json manifest =
playerthree::ManifestBuilder("tug", "1.0.0", "Tug of War", 10, "Pull the rope to your side (±10) to win.")
.Teams({"north", "south"}).Roles({"puller"}).RoleDoc("puller", "Pulls the rope.")
.Intent("pull", {"puller"}, {{"strength", t::Int(1, 3)}}, "Pull this tick.")
.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();
class MyGame : public playerthree::GameDefinition { /* Manifest(), Create() -> GameAdapter, CreateBot() */ };
MyGame game;
playerthree::SidecarClient client(game, playerthree::SpawnHost({"node", "<repo>/packages/sidecar/bin/playerthree-sidecar.ts", "--stdio"}));
client.Hello(); // manifest + hooks -> ready {surfaceHash, mcp}
std::string session = client.CreateMatch({{"seed", 1}, {"controllers", {{"north", {{"puller", "agent"}}}}}});
playerthree::WriteOwnerOnlyFile("seat-token", client.MintSeatToken(session, "north", "puller")); // 0600, never a log
for (;;) { client.Step(session, 1); client.Poll(100); } // your clock: advance, then serveA GameAdapter answers the eight §10 calls (Step, Outcome, View, Observe, Apply, Report, Events,
SetSeatInfo) with playerthree::Json (an insertion-ordered nlohmann::ordered_json). Optional hooks (MatchData,
DebugView, ObserverView, End, Forfeit, Rank, Clone, SignalLimits) are implemented by overriding them and
listing their names in GameDefinition::Hooks(); the host never calls an undeclared one. Built-in bots are yours:
the host forwards createBot / init / think to your BotCommander.
Everything runs on the calling thread. While Step, Request (and the helpers CreateMatch, MintSeatToken,
DisposeMatch) wait for their reply they keep answering the host's calls, as §20.8 requires; Poll serves calls
and pushes between steps. Push and close handlers must not start another request. An adapter that throws
InvalidCall answers invalid, any other exception engine_error; an unencodable or oversized result is answered as
engine_error instead of breaking the stream. Protocol violations by the host (bad frames, a line over 1048576
bytes, a result for an id that is not outstanding) close the connection.
Unreal
- Copy
Plugins/PlayerThree(and, for the sample,Plugins/PlayerThreeTugOfWar) into your project'sPlugins/. - Add
"PlayerThree", "PlayerThreeCore"to your game module's dependencies and setbEnableExceptions = truein itsBuild.cs(the core reports errors as exceptions). - On the server, start the subsystem with your C++ definition:
UPlayerThreeSubsystem* P3 = GetGameInstance()->GetSubsystem<UPlayerThreeSubsystem>();
FPlayerThreeSidecarSettings Settings; // HostCommand to spawn over stdio, or SocketPath / PLAYERTHREE_SIDECAR_SOCKET
FString Error;
P3->Start(std::make_shared<FMyGame>(GetWorld()), Settings, Error);
FString Session = P3->CreateMatch(TEXT("{\"seed\":1}"), /*bAutoStep=*/true, Error);The subsystem polls the connection every frame (also while paused) and advances auto-stepped matches by the whole
ticks each frame covers at the manifest tickRate, at most MaxTicksPerFrame at once; a paused reply drops the
pending time. Blueprints get CreateMatch, StepMatch, SetAutoStep, MintSeatToken, DisposeMatch,
HostRequest (any host operation, JSON in and out), IsConnected, GetSurfaceHash, GetMcpUrl, and the OnPush /
OnClosed events. Only start it on a server (GetNetMode() != NM_Client).
Sample: enable PlayerThreeTugOfWar, set ATugOfWarGameMode as the map's game mode and run the dedicated server
with -P3Host="node <repo>/packages/sidecar/bin/playerthree-sidecar.ts --stdio" (or -P3Socket=<path>). The north
seat's token is written to Saved/PlayerThree/tug-seat-token (0600 on Linux and macOS); the log shows that path and
the MCP URL.
Tests and conformance
# from the PlayerThree checkout, after pnpm install (the runner is the real sidecar plus checks)
cmake -S sdks/unreal -B build/unreal
cmake --build build/unreal && ctest --test-dir build/unreal --output-on-failureframing, sidecar and transport need nothing else; schema compares the C++ Tug manifest with
packages/sidecar/conformance/tug.manifest.json. conformance_stdio runs
p3-tug -- node packages/sidecar/conformance/run.ts --stdio (the engine spawns the runner in place of the sidecar and
exits with its verdict) and conformance_socket runs node packages/sidecar/conformance/run.ts --socket -- p3-tug
(the runner listens, the engine connects through PLAYERTHREE_SIDECAR_SOCKET). Both check the transcript, the error
codes, the 1048576-byte call and the 1048577-byte close. -DP3_REPO=<checkout> points at another checkout;
-DP3_CONFORMANCE=OFF skips the two runner tests (no node).
What is supported
Tested here (Linux; g++ 13 with ASan/UBSan, clang 18 with -Werror) | Framing (≤ 1048576 bytes excluding \n, chunked input, early rejection of oversized lines, UTF-8); every protocol-1 message and call method, error codes and fatal errors; hooks; engine-owned clock; host operations and pushes; request and hello timeouts; disconnect with matches kept for the engine; Unix domain socket client; spawned host over stdio with its exit code; SIGPIPE-safe writes; owner-only (0600, symlink-refusing) seat token files; manifest builder output equal to the conformance fixture; the CLI sample under the conformance runner over stdio and over a Unix socket. |
| Written, not verified here | The Unreal plugin and Unreal sample (no Unreal toolchain); Windows named pipes, the Windows spawn path and the Windows token file (no Windows compiler); macOS (getpeereid). Refusing a socket whose host runs as another OS user is implemented (SO_PEERCRED / pipe server SID) but not exercised by a test. |
| Not yet | Lobby operations that protocol 1 leaves out (reserve, release, capabilities); a Blueprint-implementable adapter (adapters are C++); Unreal-native JSON types (FJsonObject) in the public API. |
