M1: add NGO + Multiplayer Services packages and prototype scripts (compiling)

Netcode for GameObjects 2.13.0 + Unity Multiplayer Services 2.2.4 (unified
Relay/Lobby SDK; supersedes deprecated standalone relay/lobby). Four M1
scripts, verified against live editor reflection and compiling clean:
- Net/NetworkBootstrap.cs  Relay host/join-by-code harness (IMGUI)
- Net/PlayerDriver.cs       replicated host-auth capsule (input->ServerRpc->host)
- Bot/TreadPart.cs          v0.1 tread feel: snap-to-cruise/lock-pivot/lean
- Bot/SeatManager.cs        left/right tread seat claim + leaver-safety

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-07-10 23:24:47 +01:00
parent 04333223f4
commit d723447e31
15 changed files with 1006 additions and 4 deletions

8
Assets/Scripts/Bot.meta Normal file
View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 913ee549699dc974a8fa30340fd726f8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,184 @@
using Unity.Netcode;
using UnityEngine;
namespace Jankenbots.Prototype
{
/// <summary>
/// SeatManager — decides WHICH player drives WHICH tread of the shared bot.
///
/// M1 coordination feel: two humans each pilot half of one janky body, so the
/// whole point is that a seat is a scarce, claimable slot. This lives on the
/// bot chassis GameObject (alongside the chassis Rigidbody / NetworkObject) and
/// is the single source of truth for seat ownership.
///
/// Model:
/// - Two seats, Left and Right.
/// - Each seat stores the ulong clientId of its pilot, or <see cref="NoPilot"/>
/// (== NEUTRAL / free) when nobody holds it.
/// - First player to ask claims Left, the next claims Right, everyone after
/// that gets nothing (a spectator for M1). Treads with no pilot stay NEUTRAL
/// and simply apply no force — the bot just doesn't move on that side.
///
/// Authority: server-only writes. Seat state is host-authoritative just like the
/// physics, so clients never guess — they read replicated NetworkVariables and
/// send a ServerRpc to *ask* for a seat.
///
/// TreadPart hooks into this via <see cref="OwnsSeat"/>: a TreadPart is tagged
/// Left or Right, and on the host it only honors input from the client that
/// currently owns that seat.
/// </summary>
[RequireComponent(typeof(NetworkObject))]
public class SeatManager : NetworkBehaviour
{
public enum Seat { Left, Right }
// Sentinel for "this seat is empty / NEUTRAL". Real clientIds are small
// ulongs starting at 0, so MaxValue is a safe "nobody" marker. We can't use
// -1 because clientId is unsigned.
public const ulong NoPilot = ulong.MaxValue;
// Replicated seat ownership. Server writes, everyone reads. Clients subscribe
// to OnValueChanged if they want to react (e.g. update the seat-claim UI);
// for M1 we just poll them in OnGUI, which is plenty.
public readonly NetworkVariable<ulong> LeftPilot =
new NetworkVariable<ulong>(NoPilot,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
public readonly NetworkVariable<ulong> RightPilot =
new NetworkVariable<ulong>(NoPilot,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
// ---------------------------------------------------------------------
// Leaver-safety wiring
// ---------------------------------------------------------------------
// If a pilot rage-quits (or their WiFi dies), their seat MUST free up so a
// remaining/new player can grab it — otherwise the bot is permanently
// half-dead. We hook client-disconnect on the server and clear any seat that
// pointed at the departing client.
public override void OnNetworkSpawn()
{
// Only the server owns seat state, so only the server needs the hook.
if (IsServer)
{
NetworkManager.Singleton.OnClientDisconnectCallback += HandleClientDisconnect;
}
}
public override void OnNetworkDespawn()
{
if (IsServer && NetworkManager.Singleton != null)
{
NetworkManager.Singleton.OnClientDisconnectCallback -= HandleClientDisconnect;
}
}
// Leaver-safety: free whichever seat the departing pilot held.
void HandleClientDisconnect(ulong clientId)
{
if (LeftPilot.Value == clientId) LeftPilot.Value = NoPilot;
if (RightPilot.Value == clientId) RightPilot.Value = NoPilot;
}
// ---------------------------------------------------------------------
// Claiming a seat
// ---------------------------------------------------------------------
// A client presses "join the bot"; we don't let it pick a side — first come
// gets Left, second gets Right. This keeps M1 dead simple and mirrors the
// "first two players claim left/right tread" scope.
/// <summary>
/// Client asks the host for a seat. RequireOwnership is false because the
/// asking client does NOT own the bot (the host does) — any client may ask.
/// The sender's clientId comes from RpcParams, so a client can't lie about
/// who it is.
/// </summary>
[Rpc(SendTo.Server, RequireOwnership = false)]
public void ClaimSeatRpc(RpcParams rpcParams = default)
{
ulong requester = rpcParams.Receive.SenderClientId;
// Already seated? Do nothing (idempotent — safe to spam the button).
if (LeftPilot.Value == requester || RightPilot.Value == requester)
return;
// Fill Left first, then Right. Anyone after that is a spectator for M1.
if (LeftPilot.Value == NoPilot)
LeftPilot.Value = requester;
else if (RightPilot.Value == NoPilot)
RightPilot.Value = requester;
// else: bot is full — silently ignore.
}
/// <summary>
/// Voluntarily give up your seat (frees it as NEUTRAL). Handy for testing and
/// for letting a player hop from Left to Right. Server-authoritative.
/// </summary>
[Rpc(SendTo.Server, RequireOwnership = false)]
public void ReleaseSeatRpc(RpcParams rpcParams = default)
{
ulong requester = rpcParams.Receive.SenderClientId;
if (LeftPilot.Value == requester) LeftPilot.Value = NoPilot;
if (RightPilot.Value == requester) RightPilot.Value = NoPilot;
}
// ---------------------------------------------------------------------
// Queries used by TreadPart (host-side input gating)
// ---------------------------------------------------------------------
/// <summary>Does <paramref name="clientId"/> currently pilot <paramref name="seat"/>?
/// TreadPart calls this on the host to decide whether to honor an input RPC.</summary>
public bool OwnsSeat(Seat seat, ulong clientId)
{
return PilotOf(seat) == clientId && clientId != NoPilot;
}
/// <summary>ClientId piloting a seat, or <see cref="NoPilot"/> if free.</summary>
public ulong PilotOf(Seat seat)
{
return seat == Seat.Left ? LeftPilot.Value : RightPilot.Value;
}
/// <summary>Which seat (if any) the local player holds — for local UI only.</summary>
Seat? LocalSeat()
{
if (NetworkManager.Singleton == null) return null;
ulong me = NetworkManager.Singleton.LocalClientId;
if (LeftPilot.Value == me) return Seat.Left;
if (RightPilot.Value == me) return Seat.Right;
return null;
}
// ---------------------------------------------------------------------
// Throwaway debug UI — good enough for playtesting with friends.
// ---------------------------------------------------------------------
void OnGUI()
{
if (!IsSpawned) return;
Seat? mine = LocalSeat();
string held = mine.HasValue ? mine.Value.ToString() : "none (spectator)";
const int w = 260, h = 78;
GUI.Box(new Rect(10, 10, w, h), "JANKENBOTS · seat");
GUI.Label(new Rect(20, 32, w - 20, 20), $"You hold: {held}");
GUI.Label(new Rect(20, 52, w - 20, 20),
$"Left: {SeatLabel(LeftPilot.Value)} Right: {SeatLabel(RightPilot.Value)}");
// Client-side ask/release buttons (they just fire the ServerRpc).
if (!mine.HasValue)
{
if (GUI.Button(new Rect(w - 70, 30, 55, 20), "Claim"))
ClaimSeatRpc();
}
else if (GUI.Button(new Rect(w - 70, 30, 55, 20), "Leave"))
{
ReleaseSeatRpc();
}
}
static string SeatLabel(ulong pilot) => pilot == NoPilot ? "free" : $"#{pilot}";
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3dd8122938f14804f9e4ef7e494fb2f6

View file

@ -0,0 +1,250 @@
using Unity.Netcode;
using UnityEngine;
namespace Jankenbots.Prototype
{
/// <summary>
/// JANKENBOTS M1 — ONE tread of the shared janky bot.
///
/// This is the HEART of the control-feel test. Everything here exists to answer
/// one question: is it FUN for two friends to each drive one tread of the same
/// clumsy body and try to make it go where they want together?
///
/// ARCHITECTURE (host-authoritative, no prediction — see the M1 cheat-sheet):
/// * There is ONE simulated Rigidbody: the CHASSIS. Both treads are just
/// force-emitters that push on that shared body. No tread has its own
/// Rigidbody. Differential drive "emerges" because the two treads apply
/// their forces at different WORLD POSITIONS (left vs right of centre).
/// * The owning pilot's client only READS input and ships {throttle, pivotHeld,
/// lean} to the host via an RPC. It applies NOTHING locally.
/// * The host (IsServer) caches the latest input per tread and applies ALL
/// forces in FixedUpdate. NGO's NetworkRigidbody/NetworkTransform on the
/// chassis replicates the resulting motion back to everyone.
///
/// FEEL MODEL v0.1 — three deliberately "janky" verbs, each a literal force:
/// (a) SNAP-TO-CRUISE THROTTLE — a tread does not have an analog gas pedal.
/// Push the stick fully forward and it commits to a fixed CRUISE force
/// (chunky, momentum-y, a bit out of your hands). Partial stick is
/// proportional so you CAN feather it, but the intent is "slam it to
/// cruise and live with the consequences" — that shared over-commitment
/// is where the comedy of coordination comes from.
/// (b) LOCK-PIVOT — hold a button and THIS tread plants itself as an anchor.
/// Its drive contribution is cancelled and we actively fight the chassis'
/// motion AT the tread's position, so the whole bot swings/rotates about
/// this tread like a pinned foot. Two pilots learn "you plant, I drive"
/// to turn on the spot.
/// (c) PASSIVE LEAN — this tall bot WANTS to tip over. Nudging the stick
/// sideways dumps a ballast torque along the drive axis to counter-roll,
/// a constant low-key balancing chore shared between pilots.
///
/// Tuning fields are public so we can dial the feel live in the inspector while
/// friends are playing. Numbers here are only sane starting points.
/// </summary>
[DisallowMultipleComponent]
public class TreadPart : NetworkBehaviour
{
// Which side of the bot this tread is. Purely descriptive for M1 (the actual
// left/right behaviour comes from where the tread SITS on the chassis, not
// from this enum) — but it's handy for seat-assignment logs and inspector
// sanity, and lets us bias per-side tuning later if we want asymmetry.
public enum Side { Left, Right }
[Header("Identity")]
[Tooltip("Which tread this is. Descriptive — real behaviour comes from world position on the chassis.")]
public Side side = Side.Left;
[Header("Shared body")]
[Tooltip("The ONE simulated chassis Rigidbody every tread pushes on. Leave empty to auto-find on a parent.")]
public Rigidbody chassis;
// ---- (a) SNAP-TO-CRUISE THROTTLE tuning ----------------------------------
[Header("(a) Throttle — snap-to-cruise")]
[Tooltip("Force (Newtons) applied at full-forward stick. This is the 'cruise' the tread snaps to. Bigger = the bot lurches harder and is twic­e as hard to coordinate.")]
public float cruiseForce = 1200f;
[Tooltip("Below this stick magnitude we treat throttle as zero — kills drift/noise so a resting stick doesn't creep the bot.")]
[Range(0f, 0.5f)]
public float throttleDeadzone = 0.08f;
// ---- (b) LOCK-PIVOT tuning -----------------------------------------------
[Header("(b) Lock-pivot — plant this tread as an anchor")]
[Tooltip("How hard the planted tread resists the chassis sliding at its position. Higher = a crisper, more locked pivot; too high = the whole bot snaps rigidly and feels un-janky.")]
public float pivotAnchorStrength = 2500f;
[Tooltip("Extra angular damping (torque opposing spin) while planting. Keeps the pivot from becoming a wild spin — the planted foot should feel 'stuck', not greasy.")]
public float pivotAngularResistance = 400f;
// ---- (c) PASSIVE LEAN tuning ---------------------------------------------
[Header("(c) Passive lean — anti-tip ballast")]
[Tooltip("Ballast torque (N·m) at full sideways stick, applied along the drive (forward) axis to counter-roll the tall bot. Tune vs how tippy the chassis is.")]
public float leanTorque = 800f;
[Tooltip("Below this sideways magnitude, no lean torque — resting stick = no ballast.")]
[Range(0f, 0.5f)]
public float leanDeadzone = 0.08f;
// --------------------------------------------------------------------------
// HOST-SIDE cached input. These are written ONLY by the RPC (which only runs
// on the server) and read ONLY in FixedUpdate (also gated to server). We never
// touch them on a non-owning client, so no sync primitive is needed — the
// authoritative simulation is entirely host-local.
// --------------------------------------------------------------------------
float _throttle; // 0..1, already deadzoned/clamped by the sender
bool _pivotHeld; // is the pilot planting this tread right now?
float _lean; // -1..1 sideways ballast request
void Awake()
{
// Convenience: if nobody wired the chassis in the inspector, grab the
// Rigidbody off a parent. All treads should end up pointing at the SAME
// chassis Rigidbody — that shared reference is what makes it one bot.
if (chassis == null)
chassis = GetComponentInParent<Rigidbody>();
}
// ==========================================================================
// CLIENT: read local input, ship it to the host. Nothing is applied locally.
// Runs every frame on the owning pilot only.
// ==========================================================================
void Update()
{
if (!IsOwner) return;
float throttle = ReadThrottle(); // 0..1
bool pivot = ReadPivotHeld();
float lean = ReadLean(); // -1..1
// One tiny packet per frame to the host. The host simulates; we watch the
// replicated chassis move. That round-trip "lag between my stick and the
// bot lurching" is itself part of the janky feel we're testing.
SubmitTreadInputRpc(throttle, pivot, lean);
}
/// <summary>
/// Owning client → host. Named *Rpc + [Rpc(SendTo.Server)] per NGO 2.x.
/// RequireOwnership stays true (default): only the pilot who owns this tread
/// may drive it. The host just caches; forces are applied in FixedUpdate.
/// </summary>
[Rpc(SendTo.Server)]
void SubmitTreadInputRpc(float throttle, bool pivotHeld, float lean, RpcParams _ = default)
{
_throttle = Mathf.Clamp01(throttle);
_pivotHeld = pivotHeld;
_lean = Mathf.Clamp(lean, -1f, 1f);
}
// ==========================================================================
// HOST ONLY: turn the cached input into literal forces on the SHARED chassis.
// This is the entire physics of the bot. Two TreadParts running this in the
// same FixedUpdate, pushing at their two different world positions, ARE the
// differential drive.
// ==========================================================================
void FixedUpdate()
{
if (!IsServer) return; // authority guard — clients never simulate
if (chassis == null) return;
Vector3 treadPos = transform.position; // where THIS tread pushes from
if (_pivotHeld)
{
// ---- (b) LOCK-PIVOT --------------------------------------------
// The pilot has planted this tread. We do NOT drive with it; instead
// we make the chassis behave as if it's pinned at this tread's
// position, so the OTHER tread's thrust swings the whole bot around
// this point like a pivoting foot.
//
// 1) Cancel the sideways/linear slip AT the tread position by pushing
// back against the local velocity there. GetPointVelocity gives the
// chassis' velocity at this world point (includes rotation), so
// opposing it plants the point in space.
Vector3 pointVel = chassis.GetPointVelocity(treadPos);
chassis.AddForceAtPosition(-pointVel * pivotAnchorStrength, treadPos, ForceMode.Force);
// 2) Bleed off raw spin so the pivot feels 'stuck', not greasy. This
// is a soft angular brake, NOT a hard lock — we still want jank.
chassis.AddTorque(-chassis.angularVelocity * pivotAngularResistance, ForceMode.Force);
// NOTE: no throttle drive while planting — a planted tread is an
// anchor, not a motor. (Lean is also skipped: you're busy pivoting.)
return;
}
// ---- (a) SNAP-TO-CRUISE THROTTLE -----------------------------------
// throttle is 0..1. Full stick == full cruiseForce (the "snap to cruise"
// commitment); partial stick scales it down so feathering is possible but
// not the point. Push along the tread's own forward so a mis-aligned /
// knocked-askew tread pushes the bot in a wonky direction — jank on
// purpose. Applied AT the tread's world position → left+right offset =
// differential drive (asymmetric throttle turns the bot).
if (_throttle > throttleDeadzone)
{
Vector3 drive = transform.forward * (_throttle * cruiseForce);
chassis.AddForceAtPosition(drive, treadPos, ForceMode.Force);
}
// ---- (c) PASSIVE LEAN — anti-tip ballast ---------------------------
// Sideways stick shovels ballast torque along the drive (forward) axis to
// counter-roll the top-heavy bot. It's a constant balancing chore the
// pilots share; it does NOT steer (that's the throttle differential).
if (Mathf.Abs(_lean) > leanDeadzone)
{
chassis.AddTorque(transform.forward * (_lean * leanTorque), ForceMode.Force);
}
}
// ==========================================================================
// INPUT READERS — placeholder wiring for M1. Swap for real Input System
// actions once seats are assigned; kept trivial so the physics is testable
// immediately. Only ever called on the owning client (inside Update's guard).
// ==========================================================================
/// <summary>0..1 throttle. Vertical axis, forward only (no reverse in v0.1).</summary>
float ReadThrottle()
{
// Forward-only: negative stick = 0 throttle (reverse is a later feel test).
float v = Mathf.Max(0f, Input.GetAxisRaw("Vertical"));
return v < throttleDeadzone ? 0f : v;
}
/// <summary>Is the plant-pivot button held?</summary>
bool ReadPivotHeld()
{
// Placeholder: left shift = plant. Real build: per-seat gamepad button.
return Input.GetKey(KeyCode.LeftShift);
}
/// <summary>-1..1 sideways ballast request.</summary>
float ReadLean()
{
float h = Input.GetAxisRaw("Horizontal");
return Mathf.Abs(h) < leanDeadzone ? 0f : h;
}
// ==========================================================================
// Seat lifecycle. The bot spawns owned by the server; the seat manager grants
// a tread to a pilot via NetworkObject.ChangeOwnership(clientId) (server-only,
// see cheat-sheet §8). These hooks just log so we can see claims land while
// testing with friends, and clear stale input if a pilot leaves.
// ==========================================================================
public override void OnGainedOwnership()
{
base.OnGainedOwnership();
if (IsOwner)
Debug.Log($"[TreadPart] {side} tread claimed by local pilot (client {OwnerClientId}).");
}
public override void OnLostOwnership()
{
base.OnLostOwnership();
// On the host, wipe cached input so an un-piloted tread goes limp instead
// of coasting on the last pilot's stick.
if (IsServer)
{
_throttle = 0f;
_pivotHeld = false;
_lean = 0f;
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a07e6377a09865c4e8c2b59a637e7dd1

8
Assets/Scripts/Net.meta Normal file
View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 737e8fe9fca19404bb92c886b857a5bf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,257 @@
// JANKENBOTS — M1 step-1 connectivity harness.
//
// This is THROWAWAY prototype code. Its only job is to get two friends into the
// same networked session with zero port-forwarding: one clicks Host (which spins
// up a Unity Relay allocation and prints a short join code), the other types that
// code and clicks Join. Everything is host-authoritative from here on out; this
// file just establishes the pipe.
//
// Attach this component to the SAME GameObject that carries NetworkManager +
// UnityTransport (see the M1 scene-wiring notes). The IMGUI panel at the bottom is
// deliberately ugly — it exists so we can smoke-test connectivity before any real
// UI exists.
using System;
using System.Threading.Tasks;
using UnityEngine;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Unity.Services.Core;
using Unity.Services.Authentication;
using Unity.Services.Relay; // RelayService, RelayServiceException
using Unity.Services.Relay.Models; // Allocation, JoinAllocation, AllocationUtils <-- REQUIRED for AllocationUtils
public class NetworkBootstrap : MonoBehaviour
{
[Header("Relay")]
// maxConnections = number of CLIENTS excluding the host. Keep this small for M1
// smoke-testing; bump toward 24 for the ~25-player target later.
[SerializeField] private int maxConnections = 4;
// "dtls" = encrypted UDP, the right choice for desktop friends. ("udp" is
// unencrypted; "wss" is WebGL-only and would also need utp.UseWebSockets = true.)
[SerializeField] private string connectionType = "dtls";
// Cached transport pulled off the same GameObject at startup.
private UnityTransport _utp;
// The join code the host hands out. Empty until we've successfully hosted.
private string _hostJoinCode = "";
// What the joining player types in. Bound to the OnGUI text field.
private string _joinCodeInput = "";
// Simple UI/log line so the harness gives feedback without opening the console.
private string _status = "Idle — Host or Join to begin.";
// Guard so double-clicking Host/Join doesn't fire two overlapping async flows.
private bool _busy;
// -----------------------------------------------------------------------------
// Startup: bring Unity Services online and sign in anonymously ONCE. Relay and
// Auth both refuse to work until this completes, so we do it eagerly on Start
// rather than lazily inside Host/Join (which also re-guard, so it's safe either way).
// -----------------------------------------------------------------------------
private async void Start()
{
_utp = NetworkManager.Singleton != null
? NetworkManager.Singleton.GetComponent<UnityTransport>()
: GetComponent<UnityTransport>();
if (_utp == null)
Debug.LogError("[NetworkBootstrap] No UnityTransport found on the NetworkManager GameObject.");
await EnsureServicesReady();
}
// Idempotent: safe to call before every Host/Join. Guarded by the Services state
// machine and the sign-in flag so we never double-initialize or double-sign-in.
private async Task EnsureServicesReady()
{
try
{
if (UnityServices.State != ServicesInitializationState.Initialized)
await UnityServices.InitializeAsync();
if (!AuthenticationService.Instance.IsSignedIn)
await AuthenticationService.Instance.SignInAnonymouslyAsync();
}
catch (Exception e)
{
// A linked Unity Cloud project ID (Project Settings ▸ Services) is required
// or InitializeAsync throws here.
Debug.LogError($"[NetworkBootstrap] Services init/sign-in failed: {e}");
_status = "Services init failed — see console.";
throw; // let the caller abort its Host/Join flow.
}
}
// -----------------------------------------------------------------------------
// HOST: create a Relay allocation, feed it into UnityTransport, fetch the human-
// friendly join code, then StartHost(). Returns the join code (or null on failure).
// -----------------------------------------------------------------------------
public async Task<string> HostGame()
{
if (_busy) return null;
_busy = true;
_status = "Hosting…";
try
{
await EnsureServicesReady();
// Reserve a slot on Relay. This is where the actual relay server is picked.
Allocation allocation = await RelayService.Instance.CreateAllocationAsync(maxConnections);
// Hand the allocation to the transport. NOTE: with the unified
// com.unity.services.multiplayer SDK, AllocationUtils.ToRelayServerData is the
// ONLY valid conversion — the old `new RelayServerData(allocation, "dtls")`
// ctor does not exist here.
_utp.SetRelayServerData(AllocationUtils.ToRelayServerData(allocation, connectionType));
// The short code friends will type to join.
_hostJoinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);
// SetRelayServerData must happen BEFORE StartHost. StartHost() returns bool.
if (!NetworkManager.Singleton.StartHost())
{
Debug.LogError("[NetworkBootstrap] StartHost() returned false.");
_status = "StartHost failed — see console.";
return null;
}
_status = $"Hosting. Join code: {_hostJoinCode}";
Debug.Log($"[NetworkBootstrap] Hosting with join code {_hostJoinCode}");
return _hostJoinCode;
}
catch (RelayServiceException e)
{
Debug.LogError($"[NetworkBootstrap] Relay error while hosting: {e}");
_status = "Relay error while hosting — see console.";
return null;
}
catch (Exception e)
{
Debug.LogError($"[NetworkBootstrap] Unexpected error while hosting: {e}");
_status = "Host failed — see console.";
return null;
}
finally
{
_busy = false;
}
}
// -----------------------------------------------------------------------------
// JOIN: resolve the join code into a JoinAllocation, configure transport, StartClient().
// -----------------------------------------------------------------------------
public async Task<bool> JoinGame(string joinCode)
{
if (_busy) return false;
if (string.IsNullOrWhiteSpace(joinCode))
{
Debug.LogError("[NetworkBootstrap] JoinGame called with an empty code.");
_status = "Enter a join code first.";
return false;
}
_busy = true;
_status = "Joining…";
try
{
await EnsureServicesReady();
// Relay codes are case-insensitive but we trim stray whitespace/newlines.
JoinAllocation allocation =
await RelayService.Instance.JoinAllocationAsync(joinCode.Trim());
_utp.SetRelayServerData(AllocationUtils.ToRelayServerData(allocation, connectionType));
if (!NetworkManager.Singleton.StartClient())
{
Debug.LogError("[NetworkBootstrap] StartClient() returned false.");
_status = "StartClient failed — see console.";
return false;
}
_status = "Joined. Waiting for host…";
Debug.Log("[NetworkBootstrap] Client started, connecting to host via Relay.");
return true;
}
catch (RelayServiceException e)
{
Debug.LogError($"[NetworkBootstrap] Relay error while joining: {e}");
_status = "Bad code / Relay error — see console.";
return false;
}
catch (Exception e)
{
Debug.LogError($"[NetworkBootstrap] Unexpected error while joining: {e}");
_status = "Join failed — see console.";
return false;
}
finally
{
_busy = false;
}
}
// -----------------------------------------------------------------------------
// Dead-simple IMGUI harness. Once we're connected the buttons hide themselves and
// we just show status + (for the host) a selectable join code you can copy out.
// async void event handlers are fine for a throwaway prototype.
// -----------------------------------------------------------------------------
private void OnGUI()
{
const float pad = 10f;
GUILayout.BeginArea(new Rect(pad, pad, 320f, 260f), GUI.skin.box);
GUILayout.Label("JANKENBOTS — M1 connectivity");
GUILayout.Label(_status);
GUILayout.Space(6f);
bool live = NetworkManager.Singleton != null &&
(NetworkManager.Singleton.IsHost ||
NetworkManager.Singleton.IsServer ||
NetworkManager.Singleton.IsClient);
if (!live)
{
GUI.enabled = !_busy;
// HOST -------------------------------------------------------------
if (GUILayout.Button("Host", GUILayout.Height(30f)))
_ = HostGame(); // fire-and-forget; HostGame owns its own error logging.
GUILayout.Space(8f);
// JOIN -------------------------------------------------------------
GUILayout.Label("Join code:");
_joinCodeInput = GUILayout.TextField(_joinCodeInput, GUILayout.Height(24f));
if (GUILayout.Button("Join", GUILayout.Height(30f)))
_ = JoinGame(_joinCodeInput);
GUI.enabled = true;
}
else
{
// Connected. If we're the host, surface the code as a selectable text field
// so the tester can highlight + copy it to paste into chat.
if (NetworkManager.Singleton.IsHost && !string.IsNullOrEmpty(_hostJoinCode))
{
GUILayout.Label("Share this join code:");
// A read-only-ish selectable field: users can select/copy, edits are ignored.
GUILayout.TextField(_hostJoinCode, GUILayout.Height(28f));
}
GUILayout.Space(8f);
if (GUILayout.Button("Disconnect", GUILayout.Height(28f)))
{
NetworkManager.Singleton.Shutdown();
_hostJoinCode = "";
_status = "Disconnected.";
}
}
GUILayout.EndArea();
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7d25ff1aef9852a4b928785411bb8612

View file

@ -0,0 +1,152 @@
// PlayerDriver.cs — JANKENBOTS Milestone 1, step 2: the replicated player-driven capsule.
//
// WHAT THIS IS (feel over fidelity — throwaway prototype code):
// A single free-roaming capsule that one player owns and drives around the empty
// networked scene. It exists purely to prove out the M1 netcode loop before we bolt
// on the shared bot: "owning client reads input -> host simulates physics -> NGO
// replicates the transform to everyone." If this capsule slides around smoothly on
// a friend's screen over Relay, the host-authoritative pipeline works.
//
// HOST-AUTHORITATIVE, NO PREDICTION (decided for this throwaway only):
// The host owns the Rigidbody and is the ONLY peer that applies force. The owning
// client never moves itself locally — it just ships its input up to the server every
// FixedUpdate via an [Rpc(SendTo.Server)]. The server caches that input and, in its
// own FixedUpdate, pushes the Rigidbody. A server-authoritative NetworkTransform then
// streams the resulting position/rotation back down to all clients. Because there's
// no client-side prediction, the local player feels one round-trip of lag on their own
// capsule — that's fine and expected for M1; we're testing coordination feel, not twitch.
//
// REQUIRED PREFAB SETUP (assumed wired in the Editor — this script does NOT add them):
// * Rigidbody — the simulated body. Only the host actually integrates it;
// NetworkRigidbody/NetworkTransform force isKinematic=true on
// client copies, so DON'T touch isKinematic yourself.
// * NetworkObject — makes it a spawnable networked entity; register the prefab in
// NetworkManager -> Network Prefabs (or set it as the PlayerPrefab).
// * NetworkTransform — server-authoritative by default (leave Interpolate = true).
// This is what actually replicates our host-driven motion to clients.
// (A NetworkRigidbody is optional for a slow capsule; add one — with UseRigidBodyForMotion
// for the fast shove-ball later — if the plain NetworkTransform sync looks jittery.)
//
// Unity 6.x notes baked in below: Rigidbody.linearVelocity (was .velocity); RPC method
// names MUST end in "Rpc"; physics is gated behind IsServer and only ever runs in FixedUpdate.
using Unity.Netcode;
using UnityEngine;
namespace Jankenbots.Net
{
[RequireComponent(typeof(Rigidbody))]
public class PlayerDriver : NetworkBehaviour
{
[Header("Drive feel (host applies these as literal forces)")]
[Tooltip("Force pushing the capsule along the input direction, in Newtons-ish. Tune for a floaty, cartoony glide — this is a party game, not a sim.")]
[SerializeField] float moveForce = 40f;
[Tooltip("Extra damping the host applies when there's no input, so the capsule coasts to a stop instead of drifting forever on the frictionless-ish floor.")]
[SerializeField] float idleDamping = 4f;
[Tooltip("Clamp on horizontal speed so a friend can't rocket the capsule off the arena.")]
[SerializeField] float maxSpeed = 8f;
// The Rigidbody we drive. Present on every copy, but only the HOST integrates it.
Rigidbody rb;
// --- Host-side input cache -------------------------------------------------------
// The owning client streams its raw stick/keys into these fields via the ServerRpc.
// The host reads them in FixedUpdate. On client copies these just sit unused.
// Stored as an already-normalized planar direction (x = strafe, z = forward).
Vector2 cachedInput;
void Awake()
{
rb = GetComponent<Rigidbody>();
}
public override void OnNetworkSpawn()
{
// Belt-and-suspenders: physics must only be integrated on the authority (host).
// NetworkTransform/NetworkRigidbody normally kinematic-lock client copies for us,
// but if this capsule is used with a bare NetworkTransform we make doubly sure the
// non-server copies never fight the replicated transform with their own gravity.
if (!IsServer)
rb.isKinematic = true;
}
// -------------------------------------------------------------------------------------
// OWNING CLIENT: read input every frame and ship it to the host.
// We sample in Update (once per rendered frame — cheap, responsive) and send the RPC.
// The host applies it on its own FixedUpdate clock, so exact send cadence doesn't matter.
// -------------------------------------------------------------------------------------
void Update()
{
// Only the player who OWNS this capsule may drive it. Everyone else (including the
// host, for capsules it doesn't own) skips input entirely — IsOwner is the gate.
if (!IsOwner) return;
// Throwaway-prototype input: legacy Input axes are perfectly fine here. WASD / arrows
// / left stick map to Horizontal + Vertical out of the box. (The project ships the new
// Input System, but its compatibility mode feeds these same axes, so this Just Works
// for M1 without authoring an action asset.)
float strafe = Input.GetAxisRaw("Horizontal"); // A/D, left-stick X
float fwd = Input.GetAxisRaw("Vertical"); // W/S, left-stick Y
Vector2 input = new Vector2(strafe, fwd);
// Normalize so diagonal input isn't ~1.4x faster; keep magnitude for analog sticks.
if (input.sqrMagnitude > 1f) input.Normalize();
// Fire the input up to the server. Sending every frame (even zero) is fine for a
// handful of M1 players — it keeps the host's cache fresh and lets it damp to a stop.
SubmitMoveInputRpc(input);
}
// -------------------------------------------------------------------------------------
// CLIENT -> HOST input channel. [Rpc(SendTo.Server)] is the NGO 6.x universal RPC form;
// the method name MUST end in "Rpc". RequireOwnership defaults to true, which is exactly
// what we want: only this capsule's owner may push input into it.
// -------------------------------------------------------------------------------------
[Rpc(SendTo.Server)]
void SubmitMoveInputRpc(Vector2 input, RpcParams _ = default)
{
// Runs on the host. Just cache — never apply force here. All physics happens in the
// host's FixedUpdate so it's on the fixed timestep the Rigidbody is integrated on.
cachedInput = input;
}
// -------------------------------------------------------------------------------------
// HOST-ONLY physics. Guarded by IsServer so client copies never integrate — they only
// receive the replicated NetworkTransform. Everything here runs on the fixed timestep.
// -------------------------------------------------------------------------------------
void FixedUpdate()
{
if (!IsServer) return;
// Turn the cached 2D input into a world-space push on the XZ plane. We drive in world
// axes (not capsule-local) so a capsule with no meaningful facing still moves intuitively
// relative to the arena — good enough for the M1 "does it replicate?" test.
Vector3 dir = new Vector3(cachedInput.x, 0f, cachedInput.y);
if (dir.sqrMagnitude > 0.0001f)
{
rb.AddForce(dir * moveForce, ForceMode.Force);
}
else
{
// No input: bleed off horizontal drift so the capsule settles. Cheap manual damping
// on the planar velocity only (leave gravity on Y alone).
Vector3 v = rb.linearVelocity; // Unity 6: .linearVelocity (was .velocity)
Vector3 planar = new Vector3(v.x, 0f, v.z);
planar = Vector3.MoveTowards(planar, Vector3.zero, idleDamping * Time.fixedDeltaTime);
rb.linearVelocity = new Vector3(planar.x, v.y, planar.z);
}
// Clamp planar speed so nobody launches the capsule out of the arena.
Vector3 vel = rb.linearVelocity;
Vector3 flat = new Vector3(vel.x, 0f, vel.z);
if (flat.magnitude > maxSpeed)
{
flat = flat.normalized * maxSpeed;
rb.linearVelocity = new Vector3(flat.x, vel.y, flat.z);
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b3b2fa682cfb9874e8f27a02c4e9bf61