Editor bridge handlers for running a local multiplayer test from the editor. It resolves sbox.exe, can create a lobby, spawn real sbox.exe clients with -joinlocal, track their PIDs, wait for them to join the host, report status, and kill tracked clients (optionally disconnect host or kill stray sbox processes).
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
// ═══════════════════════════════════════════════════════════════════════════
// Multiplayer test harness — host a lobby on the RUNNING game and boot real
// sbox.exe clients that auto-join it. Implements the engine's official test
// path (networking/testing-multiplayer.md: "Join via new instance" /
// `connect local`) as bridge tools:
//
// start_multiplayer_test host (CreateLobby if needed) + spawn N clients (-joinlocal)
// multiplayer_test_status read-only: networking + connections + client PIDs + join-wait
// stop_multiplayer_test kill tracked clients (+ optional disconnect / stray sweep)
//
// THE PROVEN RECIPE (live-verified 2026-07-13, bridge-driven e2e):
// • Play mode does NOT host by default: Networking.IsActive == false until someone
// calls Sandbox.Networking.CreateLobby(). The PARAMETERLESS static overload works;
// the configured overload takes Sandbox.Network.LobbyConfig (NOT Sandbox.LobbyConfig
// — that namespace fails to compile). LobbyConfig verified via reflection probe
// 2026-07-13: struct { MaxPlayers:int, Name:string, Privacy:LobbyPrivacy,
// Hidden/DestroyWhenHostLeaves/AutoSwitchToBestHost:bool }.
// • Second client: launch sbox.exe with -joinlocal → boots a REAL game client in
// ~80 seconds that auto-joins the local session. (+instanceid N also exists for
// numbering — untested, not passed.) The exe is NEVER hardcoded: the editor IS
// sbox-dev.exe in the install folder, so Path.GetDirectoryName(Environment.ProcessPath)
// finds sbox.exe next to it; fallback = the same Steam libraryfolders.vdf scan the
// MCP server's log locator uses (sbox-mcp-server/src/tools/diagnostics.ts).
// • Success signal, host-side: Sandbox.Connection.All.Count goes 1 → N+1
// (verified 1→2; the guest joined with the Steam guest account name).
// • Local transport is Sandbox.Network.TcpChannel — killing a client mid-connection
// leaves benign TcpChannel.SendThread traces in the log (not an error).
// • MEMORY: one sbox.exe client used ~4.5 GB RAM → default clients=1, hard cap 2.
// • Networking.MaxPlayers is READ-ONLY (set via LobbyConfig only); Networking.IsHost
// can throw with no session — always guard behind Networking.IsActive.
//
// LIMITS (honesty): the bridge cannot see or drive the CLIENT instance — there is no
// bridge inside it. All assertions are host-side (Connection.All, [Sync] state via
// inspect_networked_object, get_runtime_property); a human can eyeball the client window.
//
// Frame-job pattern (statics + [EditorEvent.Frame] ticker) mirrors PlaytestHandler.cs —
// the house pattern for play-mode frame jobs. System.Diagnostics / System.IO types are
// fully qualified (GameplayRecorderHandlers.cs precedent — `using Editor;` is in scope,
// bare names risk the FileSystem ambiguity class).
//
// Registration (MyEditorMenu.cs RegisterHandlers):
// Register( "start_multiplayer_test", () => new StartMultiplayerTestHandler() );
// Register( "multiplayer_test_status", () => new MultiplayerTestStatusHandler() );
// Register( "stop_multiplayer_test", () => new StopMultiplayerTestHandler() );
// Scene-mutating: NONE — all three must stay callable during play mode (the session
// only exists there; record_playtest / record_gameplay_clip precedent). They touch
// processes and the network session, never the scene file.
// ═══════════════════════════════════════════════════════════════════════════
internal static class MultiplayerTestRunner
{
internal class ClientProc
{
public int Pid;
public DateTime StartedUtc;
public System.Diagnostics.Process Process; // handle kept so stop can Kill(entireProcessTree:true)
}
// ── Registry of spawned sbox.exe clients (persists across calls; cleared by stop) ──
private static readonly List<ClientProc> _clients = new();
private static readonly object _lock = new();
// ── Join-wait job (one at a time, ticked by the editor frame loop) ──
private static string _jobId;
private static string _joinState = "none"; // none | pending | complete | timeout
private static string _joinNote;
private static int _expected;
private static DateTime _waitStartUtc;
private static DateTime _deadlineUtc;
private static double _elapsedAtEnd;
internal static List<ClientProc> Snapshot() { lock ( _lock ) { return _clients.ToList(); } }
internal static int AliveCount() { lock ( _lock ) { return _clients.Count( IsAlive ); } }
internal static void Track( ClientProc c ) { lock ( _lock ) { _clients.Add( c ); } }
internal static void Clear()
{
lock ( _lock )
{
_clients.Clear();
_jobId = null;
_joinState = "none";
_joinNote = null;
_expected = 0;
}
}
internal static bool IsAlive( ClientProc c )
{
try { if ( c.Process != null ) return !c.Process.HasExited; } catch { }
try { System.Diagnostics.Process.GetProcessById( c.Pid ); return true; }
catch { return false; }
}
internal static string BeginJoinWait( int expected, int waitSeconds )
{
lock ( _lock )
{
_jobId = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
_joinState = "pending";
_joinNote = null;
_expected = expected;
_waitStartUtc = DateTime.UtcNow;
_deadlineUtc = _waitStartUtc.AddSeconds( waitSeconds );
_elapsedAtEnd = 0;
return _jobId;
}
}
internal static object JoinWaitStatus( int liveCount )
{
lock ( _lock )
{
if ( _joinState == "none" )
return new { state = "none" };
return new
{
state = _joinState,
jobId = _jobId,
expectedConnections = _expected,
currentConnections = liveCount,
elapsedSeconds = _joinState == "pending"
? System.Math.Round( ( DateTime.UtcNow - _waitStartUtc ).TotalSeconds, 1 )
: _elapsedAtEnd,
note = _joinNote,
};
}
}
static void EndJoinWait( string state, string note )
{
lock ( _lock )
{
_joinState = state;
_joinNote = note;
_elapsedAtEnd = System.Math.Round( ( DateTime.UtcNow - _waitStartUtc ).TotalSeconds, 1 );
}
}
[EditorEvent.Frame]
public static void OnFrame()
{
string state; int expected; DateTime deadline;
lock ( _lock ) { state = _joinState; expected = _expected; deadline = _deadlineUtc; }
if ( state != "pending" ) return;
try
{
if ( !Game.IsPlaying )
{
// The host session dies with play mode; spawned clients keep running
// (they're separate processes) — stop_multiplayer_test cleans them up.
EndJoinWait( "timeout", "play mode ended before all clients joined — spawned clients are still running, stop_multiplayer_test kills them" );
return;
}
int count = 0;
try { if ( Networking.IsActive ) count = Connection.All.Count; } catch { }
if ( count >= expected )
{
EndJoinWait( "complete", $"reached {count}/{expected} connections" );
return;
}
if ( DateTime.UtcNow > deadline )
EndJoinWait( "timeout", $"only {count}/{expected} connections when the wait expired — a client boots in ~80 s; keep polling multiplayer_test_status (the join can still land) or restart with a longer waitForJoinSeconds" );
}
catch ( Exception ex )
{
// Never let the ticker throw (it'd spam every editor frame).
EndJoinWait( "timeout", $"join-wait error: {ex.Message}" );
}
}
/// <summary>
/// Resolve the sbox.exe game client without hardcoding an install path.
/// 1) Next to the running editor process (the editor IS sbox-dev.exe in the install dir).
/// 2) Steam library scan: parse steamapps/libraryfolders.vdf in the default Steam roots,
/// then check steamapps/common/sbox/sbox.exe in each library (same discovery the MCP
/// server's log locator uses).
/// </summary>
internal static string ResolveSboxExe( out string how )
{
how = null;
try
{
var dir = System.IO.Path.GetDirectoryName( System.Environment.ProcessPath );
if ( !string.IsNullOrEmpty( dir ) )
{
var exe = System.IO.Path.Combine( dir, "sbox.exe" );
if ( System.IO.File.Exists( exe ) )
{
how = "next to the running editor process";
return exe;
}
}
}
catch { }
try
{
var roots = new[]
{
@"C:\Program Files (x86)\Steam",
@"C:\Program Files\Steam",
};
var libs = new List<string>();
foreach ( var root in roots )
{
var vdf = System.IO.Path.Combine( root, "steamapps", "libraryfolders.vdf" );
if ( !System.IO.File.Exists( vdf ) ) continue;
libs.Add( root );
var txt = System.IO.File.ReadAllText( vdf );
foreach ( System.Text.RegularExpressions.Match m in
System.Text.RegularExpressions.Regex.Matches( txt, "\"path\"\\s+\"([^\"]+)\"" ) )
{
libs.Add( m.Groups[1].Value.Replace( "\\\\", "\\" ) );
}
}
foreach ( var lib in libs )
{
var exe = System.IO.Path.Combine( lib, "steamapps", "common", "sbox", "sbox.exe" );
if ( System.IO.File.Exists( exe ) )
{
how = "Steam library scan (libraryfolders.vdf)";
return exe;
}
}
}
catch { }
return null;
}
}
/// <summary>
/// start_multiplayer_test — host a lobby on the RUNNING game (Networking.CreateLobby if
/// no session is active) and spawn N real sbox.exe clients with -joinlocal that boot
/// (~80 s each) and auto-join. Requires play mode. Params:
/// clients : optional int, how many client instances (default 1, hard cap 2
/// total tracked alive — one client uses ~4.5 GB RAM)
/// maxPlayers : optional int lobby capacity (default 4; only applied when THIS
/// call creates the lobby, via LobbyConfig — Networking.MaxPlayers
/// is read-only)
/// waitForJoinSeconds : optional int (default 120, clamped 0-600). > 0 starts an async
/// frame-loop job that polls Connection.All.Count until it reaches
/// expectedConnections or times out — poll multiplayer_test_status.
/// 0 = return right after spawning.
/// Returns { hosting, isHost, lobbyCreated, maxPlayers, spawned:[pids], sboxExe,
/// resolvedVia, expectedConnections, jobId?, note } immediately.
/// </summary>
public class StartMultiplayerTestHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
if ( !Game.IsPlaying )
return Task.FromResult<object>( new { error = "start_multiplayer_test requires play mode — call start_play first" } );
int clients = 1;
if ( p.TryGetProperty( "clients", out var cEl ) && cEl.TryGetInt32( out var ci ) )
clients = System.Math.Clamp( ci, 1, 2 );
int maxPlayers = 4;
if ( p.TryGetProperty( "maxPlayers", out var mpEl ) && mpEl.TryGetInt32( out var mp ) )
maxPlayers = System.Math.Clamp( mp, 2, 64 );
int waitForJoinSeconds = 120;
if ( p.TryGetProperty( "waitForJoinSeconds", out var wEl ) && wEl.TryGetInt32( out var wf ) )
waitForJoinSeconds = System.Math.Clamp( wf, 0, 600 );
int aliveAlready = MultiplayerTestRunner.AliveCount();
if ( aliveAlready + clients > 2 )
return Task.FromResult<object>( new { error = $"client cap: {aliveAlready} test client(s) already alive and each uses ~4.5 GB RAM — the harness caps at 2 total. stop_multiplayer_test first, or ask for fewer clients." } );
// 1) Host if nobody is. Play mode does NOT host by default: Networking.IsActive
// stays false until someone calls CreateLobby (proven live 2026-07-13).
bool lobbyCreated = false;
bool wasActive = false;
try { wasActive = Networking.IsActive; } catch { }
if ( !wasActive )
{
try
{
if ( maxPlayers == 4 )
{
// Parameterless overload — the exact live-proven path.
Networking.CreateLobby();
}
else
{
// Configured overload: Sandbox.Network.LobbyConfig (struct; MaxPlayers is
// only settable here — Networking.MaxPlayers is read-only). Compile-verified;
// the parameterless call above is the path proven end-to-end.
Networking.CreateLobby( new Sandbox.Network.LobbyConfig
{
MaxPlayers = maxPlayers,
Name = "Claude Bridge multiplayer test",
} );
}
lobbyCreated = true;
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"CreateLobby failed: {ex.Message}" } );
}
}
// 2) Resolve sbox.exe — never hardcoded (editor dir first, then Steam libraries).
var exe = MultiplayerTestRunner.ResolveSboxExe( out var how );
if ( exe == null )
return Task.FromResult<object>( new { error = "could not find sbox.exe — looked next to the running editor process, then in every Steam library from libraryfolders.vdf. Is this a non-Steam install?" } );
// 3) Spawn N REAL game clients that auto-join the local session (editor is
// unsandboxed — Process.Start is fine here).
var spawned = new List<int>();
var spawnErrors = new List<string>();
for ( int i = 0; i < clients; i++ )
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = exe,
Arguments = "-joinlocal",
WorkingDirectory = System.IO.Path.GetDirectoryName( exe ),
UseShellExecute = false,
};
var proc = System.Diagnostics.Process.Start( psi );
if ( proc == null )
{
spawnErrors.Add( $"client {i}: Process.Start returned null" );
continue;
}
MultiplayerTestRunner.Track( new MultiplayerTestRunner.ClientProc
{
Pid = proc.Id,
StartedUtc = DateTime.UtcNow,
Process = proc,
} );
spawned.Add( proc.Id );
}
catch ( Exception ex )
{
spawnErrors.Add( $"client {i}: {ex.Message}" );
}
}
if ( spawned.Count == 0 )
return Task.FromResult<object>( new { error = $"no client spawned: {string.Join( "; ", spawnErrors )}" } );
// Host + every tracked alive client (includes clients from a previous call).
int expected = 1 + MultiplayerTestRunner.AliveCount();
string jobId = null;
if ( waitForJoinSeconds > 0 )
jobId = MultiplayerTestRunner.BeginJoinWait( expected, waitForJoinSeconds );
bool hosting = false, isHost = false;
try { hosting = Networking.IsActive; isHost = hosting && Networking.IsHost; } catch { }
int liveMaxPlayers = 0;
try { liveMaxPlayers = Networking.MaxPlayers; } catch { }
return Task.FromResult<object>( new
{
hosting,
isHost,
lobbyCreated,
maxPlayers = liveMaxPlayers,
spawned = spawned.ToArray(),
spawnErrors = spawnErrors.Count > 0 ? spawnErrors.ToArray() : null,
sboxExe = exe,
resolvedVia = how,
expectedConnections = expected,
jobId,
waitForJoinSeconds,
note = "Each client is a REAL sbox.exe instance: ~80 s to boot + auto-join, ~4.5 GB RAM. "
+ ( jobId != null
? "Poll multiplayer_test_status until joinWait.state='complete' (connectionCount reaches expectedConnections). "
: "waitForJoinSeconds=0 — poll multiplayer_test_status yourself until connectionCount reaches expectedConnections. " )
+ ( lobbyCreated && !hosting ? "Lobby creation may settle asynchronously — trust multiplayer_test_status over this snapshot. " : "" )
+ "Then drive the HOST player (drive_player/playtest) and assert replication host-side (inspect_networked_object / get_runtime_property) — the bridge cannot see inside the client window. stop_multiplayer_test cleans up.",
} );
}
}
/// <summary>
/// multiplayer_test_status — read-only snapshot of the multiplayer test harness:
/// networking state, host-side connections, tracked client processes, and the join-wait
/// job. Works whether or not a test is running (empty registry = honest empty state).
/// Returns { networkingActive, isHost, connectionCount, connections:[{name,id,isHost}],
/// clients:[{pid,alive,uptimeSeconds}], joinWait:{state,...}, playing }.
/// No params.
/// </summary>
public class MultiplayerTestStatusHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
bool active = false, isHost = false;
try { active = Networking.IsActive; } catch { }
try { isHost = active && Networking.IsHost; } catch { } // IsHost can throw with no session
int connectionCount = 0;
var connections = new List<object>();
if ( active )
{
try
{
foreach ( var c in Connection.All )
{
if ( c is null ) continue; // Connection has NO IsValid — null-check only
string name = null; string id = null; bool h = false;
try { name = string.IsNullOrEmpty( c.DisplayName ) ? c.Name : c.DisplayName; } catch { }
try { id = c.Id.ToString(); } catch { }
try { h = c.IsHost; } catch { }
connections.Add( new { name, id, isHost = h } );
}
connectionCount = connections.Count;
}
catch { }
}
var clients = MultiplayerTestRunner.Snapshot().Select( c => (object) new
{
pid = c.Pid,
alive = MultiplayerTestRunner.IsAlive( c ),
uptimeSeconds = System.Math.Round( ( DateTime.UtcNow - c.StartedUtc ).TotalSeconds, 1 ),
} ).ToList();
return Task.FromResult<object>( new
{
networkingActive = active,
isHost,
connectionCount,
connections,
clients,
joinWait = MultiplayerTestRunner.JoinWaitStatus( connectionCount ),
playing = Game.IsPlaying,
} );
}
}
/// <summary>
/// stop_multiplayer_test — kill every tracked sbox.exe test client (process tree) and
/// clear the registry. Params:
/// disconnect : optional bool (default false) — also Networking.Disconnect() the host
/// session on the running game
/// alsoStrays : optional bool (default false) — safety net: kill ANY process named
/// 'sbox' that isn't the editor (sbox-dev) or our own process
/// Returns { stopped, clients:[{pid,killed,note?}], disconnected, strays, note }.
/// A PID may already be dead (client crashed / closed by hand) — reported per-PID, not
/// an error. Killing a connected client leaves benign TcpChannel.SendThread traces in
/// the editor log (not an error). Safe to call with nothing running.
/// </summary>
public class StopMultiplayerTestHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
bool disconnect = p.TryGetProperty( "disconnect", out var dEl ) && dEl.ValueKind == JsonValueKind.True;
bool alsoStrays = p.TryGetProperty( "alsoStrays", out var sEl ) && sEl.ValueKind == JsonValueKind.True;
var results = new List<object>();
foreach ( var c in MultiplayerTestRunner.Snapshot() )
{
try
{
if ( c.Process != null && !c.Process.HasExited )
{
c.Process.Kill( entireProcessTree: true );
results.Add( new { pid = c.Pid, killed = true } );
}
else
{
results.Add( new { pid = c.Pid, killed = false, note = "already dead" } );
}
}
catch ( Exception ex )
{
results.Add( new { pid = c.Pid, killed = false, note = ex.Message } );
}
}
MultiplayerTestRunner.Clear();
object disconnected = null;
if ( disconnect )
{
try
{
bool active = false;
try { active = Networking.IsActive; } catch { }
if ( active )
{
Networking.Disconnect();
disconnected = true;
}
else
{
disconnected = false; // nothing to disconnect
}
}
catch ( Exception ex )
{
disconnected = $"Disconnect failed: {ex.Message}";
}
}
var strays = new List<object>();
if ( alsoStrays )
{
int ownPid = System.Environment.ProcessId;
try
{
// GetProcessesByName matches the process name EXACTLY: "sbox" = sbox.exe game
// clients only; the editor is "sbox-dev". Belt-and-braces guards anyway —
// never kill the editor or ourselves.
foreach ( var proc in System.Diagnostics.Process.GetProcessesByName( "sbox" ) )
{
try
{
if ( proc.Id == ownPid ) continue;
if ( proc.ProcessName.Contains( "dev", StringComparison.OrdinalIgnoreCase ) ) continue;
proc.Kill( entireProcessTree: true );
strays.Add( new { pid = proc.Id, killed = true } );
}
catch ( Exception ex )
{
strays.Add( new { pid = proc.Id, killed = false, note = ex.Message } );
}
}
}
catch ( Exception ex )
{
strays.Add( new { error = $"stray scan failed: {ex.Message}" } );
}
}
return Task.FromResult<object>( new
{
stopped = true,
clients = results,
disconnected,
strays = alsoStrays ? strays : null,
note = "Tracked client processes killed, registry cleared."
+ ( disconnect ? " Host session disconnect attempted (see 'disconnected')." : " Host session left as-is — pass disconnect:true to Networking.Disconnect() it." )
+ " Killing a connected client leaves benign TcpChannel.SendThread traces in the log.",
} );
}
}