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

PathWhat
Plugins/PlayerThree/Source/PlayerThreeCoreThe 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/PlayerThreeThe Unreal wrapper: UPlayerThreeSubsystem (a UGameInstanceSubsystem + FTickableGameObject).
Plugins/PlayerThreeTugOfWarUnreal sample plugin: a replicated ATugOfWarRope actor, its adapter and ATugOfWarGameMode.
Samples/TugOfWar, Samples/TugCliThe same game as a plain C++ library and the p3-tug command-line server.
Testsctest 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 --stdio

p3-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-tug

Your 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 serve

A 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

  1. Copy Plugins/PlayerThree (and, for the sample, Plugins/PlayerThreeTugOfWar) into your project's Plugins/.
  2. Add "PlayerThree", "PlayerThreeCore" to your game module's dependencies and set bEnableExceptions = true in its Build.cs (the core reports errors as exceptions).
  3. 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-failure

framing, 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 hereThe 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 yetLobby 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.