Netcode Bootstrap

This commit is contained in:
Luis Gonzalez
2026-05-31 14:27:52 -07:00
parent 99d8d2d2a9
commit 7fa77ce821
1813 changed files with 2921554 additions and 84 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5e118095ea29346b188e4c17f8d29181
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,37 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the player ghost prefab. Bakes the gameplay components onto the entity and
/// exposes movement tunables for designers. Ghost replication, <c>GhostOwner</c> and
/// AutoCommandTarget are supplied by the GhostAuthoringComponent added on the same prefab
/// GameObject. <c>GetEntity(TransformUsageFlags.Dynamic)</c> ensures a runtime-mutable
/// LocalTransform exists.
/// </summary>
public class PlayerAuthoring : MonoBehaviour
{
[Min(0f)] public float MoveSpeed = 6f;
[Min(0f)] public float TurnRateDegreesPerSec = 720f;
private class PlayerBaker : Baker<PlayerAuthoring>
{
public override void Bake(PlayerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<PlayerTag>(entity);
AddComponent(entity, new PlayerMoveStats
{
MoveSpeed = authoring.MoveSpeed,
TurnRateRadiansPerSec = math.radians(authoring.TurnRateDegreesPerSec)
});
AddComponent<PlayerFacing>(entity);
AddComponent<PlayerInput>(entity);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 766c44362be2b4fcaa872e6fb44fc42f
@@ -4,6 +4,8 @@
"references": [
"ProjectM.Simulation",
"Unity.Entities",
"Unity.Entities.Hybrid",
"Unity.Collections",
"Unity.Mathematics",
"Unity.NetCode"
],
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a5cd4882c5ffe499fb63c8c385ddf8d2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,34 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring placed once in the gameplay subscene. Bakes a <see cref="PlayerSpawner"/> singleton
/// holding the player ghost prefab entity and a spawn point, which the server's
/// GoInGameServerSystem instantiates on each connect.
/// </summary>
public class PlayerSpawnerAuthoring : MonoBehaviour
{
[Tooltip("The Player ghost prefab to spawn for each connected client.")]
public GameObject PlayerPrefab;
public Vector3 SpawnPoint = Vector3.zero;
private class PlayerSpawnerBaker : Baker<PlayerSpawnerAuthoring>
{
public override void Bake(PlayerSpawnerAuthoring authoring)
{
// The spawner itself needs no transform; it is a data singleton.
var entity = GetEntity(authoring, TransformUsageFlags.None);
AddComponent(entity, new PlayerSpawner
{
PlayerPrefab = GetEntity(authoring.PlayerPrefab, TransformUsageFlags.Dynamic),
SpawnPoint = authoring.SpawnPoint
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e56c91ba352644bd900142035ff2799
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2913bc88b49f3421c82223b996a66a4b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,47 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Client
{
/// <summary>
/// Client-side connection handshake: for every connection that has been assigned a
/// <see cref="NetworkId"/> but is not yet <see cref="NetworkStreamInGame"/>, mark it in-game and
/// fire a <see cref="GoInGameRequest"/> RPC so the server spawns this client's player ghost.
/// Adding NetworkStreamInGame is what gates snapshot/command flow on. Mirrors the netcode
/// "networked-cube" go-in-game sample.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]
public partial struct GoInGameClientSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<NetworkId>()
.WithNone<NetworkStreamInGame>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (_, connection) in
SystemAPI.Query<RefRO<NetworkId>>().WithNone<NetworkStreamInGame>().WithEntityAccess())
{
ecb.AddComponent<NetworkStreamInGame>(connection);
var request = ecb.CreateEntity();
ecb.AddComponent<GoInGameRequest>(request);
ecb.AddComponent(request, new SendRpcCommandRequest { TargetConnection = connection });
}
ecb.Playback(state.EntityManager);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e2b0386021d7340d0b023ab1d954ce14
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bb249fc7c6daa49478fb318eb553b719
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,68 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Client
{
/// <summary>
/// Client-only twin-stick input gather. Samples the Input System once per frame (WASD /
/// left-stick -&gt; Move, right-stick -&gt; Aim) and writes <see cref="PlayerInput"/> on the
/// locally-owned player ghost (filtered to <see cref="GhostOwnerIsLocal"/>). Runs in
/// <see cref="GhostInputSystemGroup"/> — NOT the prediction loop — so devices are read once per
/// frame, never re-read during rollback. Implemented as a non-Burst <see cref="ISystem"/>
/// because it reads the managed Input System.
/// <para>
/// NOTE: the Input System device types are fully qualified rather than imported via
/// <c>using UnityEngine.InputSystem;</c> on purpose — that namespace also defines a
/// <c>PlayerInput</c> type which would collide with <see cref="ProjectM.Simulation.PlayerInput"/>
/// and make the Entities source generator bind <c>RefRW&lt;PlayerInput&gt;</c> to the managed
/// class (a spurious CS8377 "must be unmanaged").
/// </para>
/// </summary>
[UpdateInGroup(typeof(GhostInputSystemGroup))]
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial struct PlayerInputGatherSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<PlayerInput>();
}
public void OnUpdate(ref SystemState state)
{
float2 move = float2.zero;
float2 aim = float2.zero;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null)
{
if (keyboard.wKey.isPressed) move.y += 1f;
if (keyboard.sKey.isPressed) move.y -= 1f;
if (keyboard.dKey.isPressed) move.x += 1f;
if (keyboard.aKey.isPressed) move.x -= 1f;
}
var gamepad = UnityEngine.InputSystem.Gamepad.current;
if (gamepad != null)
{
float2 leftStick = gamepad.leftStick.ReadValue();
if (math.lengthsq(leftStick) > math.lengthsq(move))
move = leftStick;
aim = gamepad.rightStick.ReadValue();
}
// Right-stick deadzone: a resting stick yields zero Aim so PlayerAimSystem falls back to
// the movement heading (controller-first directional aim).
if (math.lengthsq(aim) < 0.04f)
aim = float2.zero;
foreach (var input in SystemAPI.Query<RefRW<PlayerInput>>().WithAll<GhostOwnerIsLocal>())
{
input.ValueRW.Move = move;
input.ValueRW.Aim = aim;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 27c80caff08534f239e39ff4732fbdfd
@@ -8,7 +8,8 @@
"Unity.Mathematics",
"Unity.Burst",
"Unity.NetCode",
"Unity.Entities.Graphics"
"Unity.Entities.Graphics",
"Unity.InputSystem"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e45f23db9544e4b1d94a4914c10f35d3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,60 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative player spawn. On each received <see cref="GoInGameRequest"/>: mark the
/// source connection in-game, instantiate the player ghost from the baked
/// <see cref="PlayerSpawner"/>, stamp <see cref="GhostOwner"/> with the connection's
/// <see cref="NetworkId"/>, place it at the spawn point, and link it to the connection's
/// LinkedEntityGroup so it auto-despawns on disconnect. Mirrors the netcode "networked-cube"
/// ModifiedGoInGameServer sample. All structural changes are batched through an
/// <see cref="EntityCommandBuffer"/>.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct GoInGameServerSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<PlayerSpawner>();
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<GoInGameRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var spawner = SystemAPI.GetSingleton<PlayerSpawner>();
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>>().WithAll<GoInGameRequest>().WithEntityAccess())
{
var connection = receive.ValueRO.SourceConnection;
ecb.AddComponent<NetworkStreamInGame>(connection);
var networkId = SystemAPI.GetComponent<NetworkId>(connection);
var player = ecb.Instantiate(spawner.PlayerPrefab);
ecb.SetComponent(player, LocalTransform.FromPosition(spawner.SpawnPoint));
ecb.SetComponent(player, new GhostOwner { NetworkId = networkId.Value });
// Auto-despawn the player when its owning connection is removed.
ecb.AppendToBuffer(connection, new LinkedEntityGroup { Value = player });
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2c89f67860a64492bb09af8cc38f3c83
@@ -4,6 +4,7 @@
"references": [
"ProjectM.Simulation",
"Unity.Entities",
"Unity.Transforms",
"Unity.Collections",
"Unity.Mathematics",
"Unity.Burst",
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 08533a9dddc374862b7e3259cb8f872d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client -&gt; server one-off request to enter gameplay. On receipt the server adds
/// NetworkStreamInGame to the connection (enabling snapshot/command flow) and spawns the
/// client's player ghost. Lives in Simulation so both worlds see the type for RPC source-gen.
/// </summary>
public struct GoInGameRequest : IRpcCommand { }
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b633994e24f874e0796d1fa93cc46679
@@ -15,8 +15,11 @@ namespace ProjectM.Simulation
{
public override bool Initialize(string defaultWorldName)
{
// 0 = do not auto-connect; worlds are still created. Set a port later to auto-connect.
AutoConnectPort = 0;
// Auto-connect in-editor: the server listens and the in-process client connects (over
// IPC) on the default BinaryWorlds host mode — one process hosts both worlds (M1 listen
// server). M3 replaces this with an explicit Unity Relay host/join flow. Set to 0 to
// disable auto-connect.
AutoConnectPort = 7979;
CreateDefaultClientServerWorlds();
return true;
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 78b348213a4864001bf105954525fbda
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,41 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Predicted aim/facing: writes <see cref="PlayerFacing"/> from twin-stick Aim, falling back to
/// the movement direction when Aim is zero (controller-first directional aim). Also turns the
/// ghost transform toward the facing direction for top-down presentation. When there is no input
/// this tick the previous facing is held. Deterministic (pure math); filtered to
/// <see cref="Simulate"/> so it runs only for predicted ghosts.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[BurstCompile]
public partial struct PlayerAimSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var (facing, transform, input) in
SystemAPI.Query<RefRW<PlayerFacing>, RefRW<LocalTransform>, RefRO<PlayerInput>>()
.WithAll<Simulate>())
{
float2 aim = input.ValueRO.Aim;
if (math.lengthsq(aim) < 1e-6f)
aim = input.ValueRO.Move; // fall back to movement heading
if (math.lengthsq(aim) < 1e-6f)
continue; // no input this tick: keep last facing
aim = math.normalize(aim);
facing.ValueRW.Direction = aim;
float3 forward = new float3(aim.x, 0f, aim.y);
transform.ValueRW.Rotation = quaternion.LookRotationSafe(forward, math.up());
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3a274d036dc034cbaa70f6a782d8784a
@@ -0,0 +1,18 @@
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Authoritative aim/facing direction, decoupled from movement heading so twin-stick aim is
/// independent of travel direction. Written in predicted sim from PlayerInput.Aim; consumed by
/// presentation now and ability systems (M2). Replicated so interpolated remote players show
/// the correct facing.
/// </summary>
public struct PlayerFacing : IComponentData
{
/// <summary>Normalized planar facing direction (world XZ mapped to float2 x,y).</summary>
[GhostField(Quantization = 1000)] public float2 Direction;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d0adf0e4def842a89a3017de243aca9
@@ -0,0 +1,30 @@
using Unity.Collections;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Twin-stick player input (server-authoritative, input-only clients). Gathered once per frame
/// on the owning client in <see cref="GhostInputSystemGroup"/> and streamed to the server via
/// AutoCommandTarget. Netcode source-gen produces InputBufferData&lt;PlayerInput&gt; plus the
/// copy/apply systems from this type. The [GhostField]s let remote owners be predicted; the
/// data replays deterministically under rollback.
/// </summary>
public struct PlayerInput : IInputComponentData
{
/// <summary>WASD / left-stick movement, normalized to roughly -1..1 per axis.</summary>
[GhostField(Quantization = 1000)] public float2 Move;
/// <summary>Right-stick / cursor aim direction (normalized). Zero =&gt; face movement direction.</summary>
[GhostField(Quantization = 1000)] public float2 Aim;
public FixedString512Bytes ToFixedString()
{
var s = new FixedString512Bytes();
s.Append(Move.x); s.Append(','); s.Append(Move.y); s.Append(';');
s.Append(Aim.x); s.Append(','); s.Append(Aim.y);
return s;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 72ac3666802bf49f891f49a0d03201e5
@@ -0,0 +1,17 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Per-player movement tunables, baked from authoring. Identical on client (re-prediction) and
/// server so movement is deterministic. Not replicated.
/// </summary>
public struct PlayerMoveStats : IComponentData
{
/// <summary>Planar movement speed in units/second.</summary>
public float MoveSpeed;
/// <summary>Max turn rate (radians/second) when rotating toward the facing direction.</summary>
public float TurnRateRadiansPerSec;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cf5fc79d6c67d4ef39ba4e7e9457dd85
@@ -0,0 +1,39 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Canonical predicted system: advances each player's planar (XZ) position from twin-stick Move
/// input. Runs inside the prediction loop on the owning client (re-simulated on rollback) and
/// once per tick on the server; filtered to <see cref="Simulate"/> so only predicted ghosts move.
/// Deterministic by construction: uses <c>SystemAPI.Time.DeltaTime</c> (the fixed tick step)
/// only — no wall-clock, no <c>System.Random</c>. Move is clamped to unit length so diagonal
/// keyboard movement is not faster than cardinal.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[BurstCompile]
public partial struct PlayerMoveSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float dt = SystemAPI.Time.DeltaTime;
foreach (var (transform, input, stats) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<PlayerInput>, RefRO<PlayerMoveStats>>()
.WithAll<Simulate>())
{
float2 move = input.ValueRO.Move;
if (math.lengthsq(move) > 1f)
move = math.normalize(move);
float3 delta = new float3(move.x, 0f, move.y) * stats.ValueRO.MoveSpeed * dt;
transform.ValueRW.Position += delta;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 35cd3eacccc2f4172b557b2807a6df22
@@ -0,0 +1,10 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Zero-size marker identifying a player-character ghost. Lets movement/aim/ability systems
/// query players without coupling to other gameplay components. Added by PlayerBaker.
/// </summary>
public struct PlayerTag : IComponentData { }
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1f967358256a44853bfc5be66e13bf3b
@@ -3,6 +3,7 @@
"rootNamespace": "ProjectM.Simulation",
"references": [
"Unity.Entities",
"Unity.Transforms",
"Unity.Collections",
"Unity.Mathematics",
"Unity.Burst",
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 37f2ed4a443ac4a18aba505897f9e6fa
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,15 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Singleton baked into the gameplay subscene, holding the baked player ghost prefab entity so
/// the server spawn system can instantiate it on connect. Mirrors the netcode CubeSpawner sample.
/// </summary>
public struct PlayerSpawner : IComponentData
{
public Entity PlayerPrefab;
public float3 SpawnPoint;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d1945139846b49a7943b7149188aa45