diff --git a/Assets/DefaultNetworkPrefabs.asset b/Assets/DefaultNetworkPrefabs.asset
new file mode 100644
index 0000000..219664e
--- /dev/null
+++ b/Assets/DefaultNetworkPrefabs.asset
@@ -0,0 +1,16 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: e651dbb3fbac04af2b8f5abf007ddc23, type: 3}
+ m_Name: DefaultNetworkPrefabs
+ m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkPrefabsList
+ IsDefault: 1
+ List: []
diff --git a/Assets/DefaultNetworkPrefabs.asset.meta b/Assets/DefaultNetworkPrefabs.asset.meta
new file mode 100644
index 0000000..e57a20a
--- /dev/null
+++ b/Assets/DefaultNetworkPrefabs.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 26dc7ddf766d17a4f81221bfec5c38f7
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts.meta b/Assets/Scripts.meta
new file mode 100644
index 0000000..6eca423
--- /dev/null
+++ b/Assets/Scripts.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 085dcb5c31d236f4286afc181d1f9825
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/Bot.meta b/Assets/Scripts/Bot.meta
new file mode 100644
index 0000000..6514c81
--- /dev/null
+++ b/Assets/Scripts/Bot.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 913ee549699dc974a8fa30340fd726f8
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/Bot/SeatManager.cs b/Assets/Scripts/Bot/SeatManager.cs
new file mode 100644
index 0000000..f34d1d8
--- /dev/null
+++ b/Assets/Scripts/Bot/SeatManager.cs
@@ -0,0 +1,184 @@
+using Unity.Netcode;
+using UnityEngine;
+
+namespace Jankenbots.Prototype
+{
+ ///
+ /// 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
+ /// (== 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 : a TreadPart is tagged
+ /// Left or Right, and on the host it only honors input from the client that
+ /// currently owns that seat.
+ ///
+ [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 LeftPilot =
+ new NetworkVariable(NoPilot,
+ NetworkVariableReadPermission.Everyone,
+ NetworkVariableWritePermission.Server);
+
+ public readonly NetworkVariable RightPilot =
+ new NetworkVariable(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.
+
+ ///
+ /// 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.
+ ///
+ [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.
+ }
+
+ ///
+ /// Voluntarily give up your seat (frees it as NEUTRAL). Handy for testing and
+ /// for letting a player hop from Left to Right. Server-authoritative.
+ ///
+ [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)
+ // ---------------------------------------------------------------------
+
+ /// Does currently pilot ?
+ /// TreadPart calls this on the host to decide whether to honor an input RPC.
+ public bool OwnsSeat(Seat seat, ulong clientId)
+ {
+ return PilotOf(seat) == clientId && clientId != NoPilot;
+ }
+
+ /// ClientId piloting a seat, or if free.
+ public ulong PilotOf(Seat seat)
+ {
+ return seat == Seat.Left ? LeftPilot.Value : RightPilot.Value;
+ }
+
+ /// Which seat (if any) the local player holds — for local UI only.
+ 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}";
+ }
+}
diff --git a/Assets/Scripts/Bot/SeatManager.cs.meta b/Assets/Scripts/Bot/SeatManager.cs.meta
new file mode 100644
index 0000000..cfc4ea9
--- /dev/null
+++ b/Assets/Scripts/Bot/SeatManager.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3dd8122938f14804f9e4ef7e494fb2f6
\ No newline at end of file
diff --git a/Assets/Scripts/Bot/TreadPart.cs b/Assets/Scripts/Bot/TreadPart.cs
new file mode 100644
index 0000000..ca98c0c
--- /dev/null
+++ b/Assets/Scripts/Bot/TreadPart.cs
@@ -0,0 +1,250 @@
+using Unity.Netcode;
+using UnityEngine;
+
+namespace Jankenbots.Prototype
+{
+ ///
+ /// 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.
+ ///
+ [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 twice 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();
+ }
+
+ // ==========================================================================
+ // 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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).
+ // ==========================================================================
+
+ /// 0..1 throttle. Vertical axis, forward only (no reverse in v0.1).
+ 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;
+ }
+
+ /// Is the plant-pivot button held?
+ bool ReadPivotHeld()
+ {
+ // Placeholder: left shift = plant. Real build: per-seat gamepad button.
+ return Input.GetKey(KeyCode.LeftShift);
+ }
+
+ /// -1..1 sideways ballast request.
+ 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;
+ }
+ }
+ }
+}
diff --git a/Assets/Scripts/Bot/TreadPart.cs.meta b/Assets/Scripts/Bot/TreadPart.cs.meta
new file mode 100644
index 0000000..d961dba
--- /dev/null
+++ b/Assets/Scripts/Bot/TreadPart.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a07e6377a09865c4e8c2b59a637e7dd1
\ No newline at end of file
diff --git a/Assets/Scripts/Net.meta b/Assets/Scripts/Net.meta
new file mode 100644
index 0000000..e7c9565
--- /dev/null
+++ b/Assets/Scripts/Net.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 737e8fe9fca19404bb92c886b857a5bf
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/Net/NetworkBootstrap.cs b/Assets/Scripts/Net/NetworkBootstrap.cs
new file mode 100644
index 0000000..53ce09e
--- /dev/null
+++ b/Assets/Scripts/Net/NetworkBootstrap.cs
@@ -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()
+ : GetComponent();
+
+ 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 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 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();
+ }
+}
diff --git a/Assets/Scripts/Net/NetworkBootstrap.cs.meta b/Assets/Scripts/Net/NetworkBootstrap.cs.meta
new file mode 100644
index 0000000..017ffb4
--- /dev/null
+++ b/Assets/Scripts/Net/NetworkBootstrap.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7d25ff1aef9852a4b928785411bb8612
\ No newline at end of file
diff --git a/Assets/Scripts/Net/PlayerDriver.cs b/Assets/Scripts/Net/PlayerDriver.cs
new file mode 100644
index 0000000..640f4f5
--- /dev/null
+++ b/Assets/Scripts/Net/PlayerDriver.cs
@@ -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();
+ }
+
+ 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);
+ }
+ }
+ }
+}
diff --git a/Assets/Scripts/Net/PlayerDriver.cs.meta b/Assets/Scripts/Net/PlayerDriver.cs.meta
new file mode 100644
index 0000000..8867d96
--- /dev/null
+++ b/Assets/Scripts/Net/PlayerDriver.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b3b2fa682cfb9874e8f27a02c4e9bf61
\ No newline at end of file
diff --git a/Packages/manifest.json b/Packages/manifest.json
index 8445ccd..5c7a31f 100644
--- a/Packages/manifest.json
+++ b/Packages/manifest.json
@@ -7,7 +7,9 @@
"com.unity.ide.visualstudio": "2.0.26",
"com.unity.inputsystem": "1.19.0",
"com.unity.multiplayer.center": "1.0.1",
+ "com.unity.netcode.gameobjects": "2.13.0",
"com.unity.render-pipelines.universal": "17.5.0",
+ "com.unity.services.multiplayer": "2.2.4",
"com.unity.test-framework": "1.7.0",
"com.unity.timeline": "1.8.12",
"com.unity.ugui": "2.5.0",
diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json
index d32be9c..37a06a9 100644
--- a/Packages/packages-lock.json
+++ b/Packages/packages-lock.json
@@ -45,7 +45,7 @@
},
"com.unity.collections": {
"version": "6.5.0",
- "depth": 2,
+ "depth": 1,
"source": "builtin",
"dependencies": {
"com.unity.burst": "1.8.25",
@@ -89,7 +89,7 @@
},
"com.unity.mathematics": {
"version": "1.4.0",
- "depth": 3,
+ "depth": 2,
"source": "builtin",
"dependencies": {}
},
@@ -101,9 +101,19 @@
"com.unity.modules.uielements": "1.0.0"
}
},
+ "com.unity.netcode.gameobjects": {
+ "version": "2.13.0",
+ "depth": 0,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.transport": "2.6.0",
+ "com.unity.nuget.mono-cecil": "1.11.4"
+ },
+ "url": "https://packages.unity.com"
+ },
"com.unity.nuget.mono-cecil": {
"version": "1.11.6",
- "depth": 3,
+ "depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
@@ -151,6 +161,87 @@
"dependencies": {},
"url": "https://packages.unity.com"
},
+ "com.unity.services.authentication": {
+ "version": "3.7.1",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.ugui": "1.0.0",
+ "com.unity.services.core": "1.18.0",
+ "com.unity.nuget.newtonsoft-json": "3.2.2",
+ "com.unity.modules.unitywebrequest": "1.0.0"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.core": {
+ "version": "1.18.0",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.modules.androidjni": "1.0.0",
+ "com.unity.nuget.newtonsoft-json": "3.2.2",
+ "com.unity.modules.unitywebrequest": "1.0.0"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.deployment": {
+ "version": "1.7.2",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.services.core": "1.15.1",
+ "com.unity.services.deployment.api": "1.1.2"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.deployment.api": {
+ "version": "1.1.3",
+ "depth": 2,
+ "source": "registry",
+ "dependencies": {},
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.multiplayer": {
+ "version": "2.2.4",
+ "depth": 0,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.transport": "2.6.0",
+ "com.unity.collections": "2.2.1",
+ "com.unity.services.qos": "1.4.1",
+ "com.unity.services.core": "1.16.0",
+ "com.unity.services.wire": "1.4.4",
+ "com.unity.services.deployment": "1.7.1",
+ "com.unity.nuget.newtonsoft-json": "3.2.2",
+ "com.unity.modules.unitywebrequest": "1.0.0",
+ "com.unity.services.authentication": "3.6.0"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.qos": {
+ "version": "1.4.1",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.collections": "1.2.4",
+ "com.unity.services.core": "1.12.5",
+ "com.unity.nuget.newtonsoft-json": "3.0.2",
+ "com.unity.modules.unitywebrequest": "1.0.0",
+ "com.unity.services.authentication": "3.5.2"
+ },
+ "url": "https://packages.unity.com"
+ },
+ "com.unity.services.wire": {
+ "version": "1.4.4",
+ "depth": 1,
+ "source": "registry",
+ "dependencies": {
+ "com.unity.services.core": "1.12.5",
+ "com.unity.nuget.newtonsoft-json": "3.2.1",
+ "com.unity.services.authentication": "2.7.4"
+ },
+ "url": "https://packages.unity.com"
+ },
"com.unity.shadergraph": {
"version": "17.5.0",
"depth": 1,
@@ -172,7 +263,7 @@
},
"com.unity.test-framework.performance": {
"version": "3.5.0",
- "depth": 3,
+ "depth": 2,
"source": "registry",
"dependencies": {
"com.unity.test-framework": "1.1.33",
@@ -192,6 +283,16 @@
},
"url": "https://packages.unity.com"
},
+ "com.unity.transport": {
+ "version": "6.5.0",
+ "depth": 1,
+ "source": "builtin",
+ "dependencies": {
+ "com.unity.collections": "6.5.0",
+ "com.unity.burst": "1.8.24",
+ "com.unity.mathematics": "1.4.0"
+ }
+ },
"com.unity.ugui": {
"version": "2.5.0",
"depth": 0,