Netcode for GameObjects: P2P Host-Client Setup Guide
This guide provides a complete, step-by-step implementation for running Enemy Masses in a Peer-to-Peer Host-Client multiplayer scenario using Unity Netcode for GameObjects (NGO) 2.7.0.
Overview
In a P2P Host-Client model:
One player acts as the Host (runs both server + client logic)
Other players connect as Clients
No dedicated server required — great for playing with friends
If Package Manager resolves a newer 2.4.x transport for NGO 2.7, use that resolved version.
Required Components
Unity 6 LTS or newer
Enemy Masses 1.4.0+
Basic understanding of NGO concepts (NetworkBehaviour, RPCs)
Enemy Masses ships with built-in network-agnostic events — no modification to the source code is required. You simply subscribe to events and route them through your networking layer.
Game Mode Considerations
Enemy Masses supports different multiplayer game types with varying synchronization strategies:
RTS / Strategy Games
Players control specific factions — ownership matters
Commands synced — move, attack, stop commands sent to server
Moderate position sync — periodic corrections (0.5s intervals)
Strict validation — verify ownership before executing commands
Co-op / Horde / Survivor Games (e.g., Vampire Survivors-style)
All enemies are AI-controlled — no player ownership of enemies
Player positions synced — enemies target nearest synced player
Relaxed validation — generous distance tolerances for hit detection
Higher position sync rate — more frequent updates (0.1-0.2s) for smoother enemy movement
Key Difference: Command-Based vs Position-Sync
Approach
Best For
How It Works
Command-Based
RTS games
Clients send move/attack commands; server executes; positions drift but periodically correct
Position-Sync
Co-op/Horde
Server runs all AI; broadcasts positions; clients are visual-only for enemies
For co-op games, use the Position-Sync approach with higher stateSyncInterval frequency:
Make sure the prefab is in the list before the game starts
Step 4: Create Host/Join UI
Create a simple UI for hosting and joining games:
Step 5: Use Built-in Network Events
Enemy Masses ships with network-agnostic events that fire when key gameplay actions occur. These events use plain C# delegates with no networking dependencies — you subscribe to them and route data through your networking solution (NGO, Mirror, Photon, etc.).
Architecture: Enemy Masses fires events → Your integration layer listens → You send RPCs. This keeps Enemy Masses network-agnostic while giving you everything needed for multiplayer.
5.1 Available Events on EnemyMassesCrowdController
5.2 Available Events on EnemyMassesRTSController
5.3 Event Data Structures (Already Defined)
All event data structures are in Arawn.EnemyMasses.Runtime.Networking:
5.4 Example: Subscribing in Your Network Authority
5.5 CrowdAgent Network Properties
Each CrowdAgent has built-in network support properties:
No modification to Enemy Masses source code is required! Just subscribe to the events and implement your RPC layer.
Step 6: Scene Setup
6.1 Required Scene Objects
Your multiplayer scene needs:
6.2 Prefab Configuration
NetworkManager:
Ensure NetworkManager component is present
Add UnityTransport component
Register EnemyMassesNetworkAuthority prefab
EnemyMassesNetworkAuthority Prefab:
Has NetworkObject component
Has EnemyMassesNetworkAuthority script
References are set in inspector (or found at runtime)
Step 7: Testing
7.1 Local Testing (Same PC)
Build the game (File → Build Settings → Build)
Run the built executable
In Unity Editor, enter Play Mode
In the built game: click Host
In the Editor: enter the IP shown in the built game, click Join
You can extend the late-joiner sync to include custom data:
NavMeshAgent Position Synchronization
Unity's NavMeshAgent is not deterministic across different machines. Even with identical inputs, agents will drift apart over time due to:
Floating-point differences between CPUs
Frame rate variations
Path recalculation timing
Local avoidance interactions
When is "Deterministic Enough" Actually Enough?
In some scenarios, running AI locally on each client without position sync can work:
Scenario
Local AI Works?
Why
Simple target-chasing
Maybe
If enemies just move toward synced player positions, they'll be close enough
Hit validation with generous tolerance
Yes
If you allow 5+ meter tolerance, drift doesn't matter
Strict distance-based hit validation
No
Enemies could be meters apart between server/client
Visual-only (no gameplay impact)
Yes
Players won't notice slight differences
Bottom Line: If your server validates hits with distance/facing checks, you must sync positions from the server. Client-side AI simulations will NOT match closely enough.
The Solution: Server Authority
This guide uses server-authoritative positions:
Server runs all NavMeshAgents and owns the "truth"
Clients receive position updates periodically via SyncCriticalState()
Correction threshold (1 meter by default) prevents jitter
Tuning Position Sync
Scenario
stateSyncInterval
Correction Threshold
Validation Tolerance
RTS (commands only)
0.5s
2.0f
N/A
Co-op casual
0.2s
1.0f
5.0f
Co-op precise
0.1s
0.5f
2.0f
Competitive
0.05s
0.25f
1.0f
Note: Lower intervals = more bandwidth but more accurate positions.
Alternative: Client-Side Prediction
For smoother visuals, you can implement client-side prediction:
Co-op Architecture:
┌─────────────────────────────────────────────────────────────┐
│ HOST │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ • Spawns all enemies (server-authoritative) │ │
│ │ • Runs enemy AI targeting synced player positions │ │
│ │ • Validates damage with relaxed distance checks │ │
│ │ • Broadcasts enemy positions to clients │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
Position Updates (0.1-0.2s)
│
┌─────────────────────────────────────────────────────────────┐
│ CLIENTS │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ • Receive enemy positions from host │ │
│ │ • Send player position to host │ │
│ │ • Request damage via RPC (server validates) │ │
│ │ • No local enemy AI simulation │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
// For co-op games: more frequent position updates
[SerializeField] private float stateSyncInterval = 0.1f; // 10 Hz instead of 0.5s
NetworkManager Settings:
├── Player Prefab: (leave empty for now)
├── Network Prefabs: (we'll add these later)
├── Protocol Version: 0
├── Network Transport: Unity Transport
└── Tick Rate: 30
Unity Transport Settings:
├── Protocol Type: Unity Transport
├── Connection Data:
│ ├── Address: 127.0.0.1 (for local testing)
│ ├── Port: 7777
│ └── Server Listen Address: 0.0.0.0
└── Network Simulator: (leave disabled)
using System;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using Arawn.EnemyMasses.Runtime;
using Arawn.EnemyMasses.Runtime.Networking;
namespace YourGame.Networking
{
/// <summary>
/// Main network authority for Enemy Masses P2P multiplayer.
/// Attach this to a NetworkObject in your scene.
/// </summary>
public class EnemyMassesNetworkAuthority : NetworkBehaviour
{
public static EnemyMassesNetworkAuthority Instance { get; private set; }
[Header("References")]
[SerializeField] private EnemyMassesCrowdController crowdController;
[SerializeField] private EnemyMassesRTSController rtsController;
[Header("Settings")]
[SerializeField] private float stateSyncInterval = 0.5f;
[SerializeField] private bool enableAntiCheat = true;
[SerializeField] private bool debugLogging = false;
// Network state
private uint _nextNetworkId = 1;
private Dictionary<uint, int> _networkIdToAgentIndex = new Dictionary<uint, int>();
private Dictionary<int, uint> _agentIndexToNetworkId = new Dictionary<int, uint>();
private float _lastStateSyncTime;
// Properties
public bool IsAuthority => IsServer || IsHost;
public int LocalPlayerId => (int)NetworkManager.Singleton.LocalClientId;
#region Unity Lifecycle
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
// Find references if not set
if (crowdController == null)
crowdController = FindFirstObjectByType<EnemyMassesCrowdController>();
if (rtsController == null)
rtsController = FindFirstObjectByType<EnemyMassesRTSController>();
// Subscribe to Enemy Masses events
SubscribeToEvents();
if (debugLogging)
Debug.Log($"[NetworkAuthority] Spawned. IsServer={IsServer}, IsClient={IsClient}, IsHost={IsHost}");
}
public override void OnNetworkDespawn()
{
UnsubscribeFromEvents();
base.OnNetworkDespawn();
}
private void Update()
{
// Periodic state sync (server only)
if (IsServer && Time.time - _lastStateSyncTime > stateSyncInterval)
{
_lastStateSyncTime = Time.time;
SyncCriticalState();
}
}
#endregion
#region Event Subscriptions
private void SubscribeToEvents()
{
if (rtsController != null)
{
rtsController.NetworkMoveCommandIssued += OnLocalMoveCommand;
rtsController.NetworkAttackCommandIssued += OnLocalAttackCommand;
rtsController.NetworkStopCommandIssued += OnLocalStopCommand;
}
if (crowdController != null)
{
crowdController.NetworkEventDamageApplied += OnLocalDamageApplied;
crowdController.NetworkEventAgentDied += OnLocalAgentDied;
}
}
private void UnsubscribeFromEvents()
{
if (rtsController != null)
{
rtsController.NetworkMoveCommandIssued -= OnLocalMoveCommand;
rtsController.NetworkAttackCommandIssued -= OnLocalAttackCommand;
rtsController.NetworkStopCommandIssued -= OnLocalStopCommand;
}
if (crowdController != null)
{
crowdController.NetworkEventDamageApplied -= OnLocalDamageApplied;
crowdController.NetworkEventAgentDied -= OnLocalAgentDied;
}
}
#endregion
#region Network ID Management
/// <summary>
/// Generates a unique network ID. Only call on server/host.
/// </summary>
public uint GenerateNetworkId()
{
if (!IsServer)
{
Debug.LogError("[NetworkAuthority] Only server can generate network IDs!");
return 0;
}
return _nextNetworkId++;
}
/// <summary>
/// Registers an agent with a network ID.
/// </summary>
public void RegisterAgent(int agentIndex, uint networkId)
{
_networkIdToAgentIndex[networkId] = agentIndex;
_agentIndexToNetworkId[agentIndex] = networkId;
// Store on the agent itself (skip if your CrowdAgent doesn't expose networkId)
var agents = crowdController.GetAgents();
if (agentIndex >= 0 && agentIndex < agents.Count)
{
agents[agentIndex].networkId = networkId;
}
}
/// <summary>
/// Gets agent index from network ID.
/// </summary>
public int GetAgentIndex(uint networkId)
{
return _networkIdToAgentIndex.TryGetValue(networkId, out int index) ? index : -1;
}
/// <summary>
/// Gets network ID from agent index.
/// </summary>
public uint GetNetworkId(int agentIndex)
{
return _agentIndexToNetworkId.TryGetValue(agentIndex, out uint id) ? id : 0;
}
/// <summary>
/// Converts array of network IDs to agent indices.
/// </summary>
public List<int> ConvertToAgentIndices(uint[] networkIds)
{
var indices = new List<int>(networkIds.Length);
foreach (uint id in networkIds)
{
int index = GetAgentIndex(id);
if (index >= 0)
indices.Add(index);
}
return indices;
}
/// <summary>
/// Converts array of agent indices to network IDs.
/// </summary>
public uint[] ConvertToNetworkIds(int[] agentIndices)
{
var ids = new uint[agentIndices.Length];
for (int i = 0; i < agentIndices.Length; i++)
{
ids[i] = GetNetworkId(agentIndices[i]);
}
return ids;
}
#endregion
#region Move Commands
private void OnLocalMoveCommand(CommandEventData data)
{
if (data.agentIndices == null || data.agentIndices.Length == 0)
return;
// Use the network IDs from the event if available, otherwise convert
uint[] networkIds = data.networkIds ?? ConvertToNetworkIds(data.agentIndices);
if (IsServer)
{
// Host: validate and broadcast
if (ValidateMoveCommand(networkIds, data.targetPosition, LocalPlayerId))
{
BroadcastMoveCommandClientRpc(networkIds, data.targetPosition,
(int)data.formation, data.formationSpacing);
}
}
else
{
// Client: request from server
RequestMoveCommandServerRpc(networkIds, data.targetPosition,
(int)data.formation, data.formationSpacing);
}
}
[Rpc(SendTo.Server, RequireOwnership = false)]
private void RequestMoveCommandServerRpc(uint[] agentIds, Vector3 destination,
int formation, float spacing, RpcParams rpcParams = default)
{
int senderId = (int)rpcParams.Receive.SenderClientId;
if (debugLogging)
Debug.Log($"[NetworkAuthority] Move request from player {senderId} for {agentIds.Length} agents");
if (ValidateMoveCommand(agentIds, destination, senderId))
{
// Execute locally on server
ExecuteMoveCommand(agentIds, destination, (FormationType)formation, spacing);
// Broadcast to all clients (including sender for confirmation)
BroadcastMoveCommandClientRpc(agentIds, destination, formation, spacing);
}
else
{
if (debugLogging)
Debug.LogWarning($"[NetworkAuthority] Move command rejected for player {senderId}");
}
}
[Rpc(SendTo.NotServer)]
private void BroadcastMoveCommandClientRpc(uint[] agentIds, Vector3 destination,
int formation, float spacing)
{
ExecuteMoveCommand(agentIds, destination, (FormationType)formation, spacing);
}
private void ExecuteMoveCommand(uint[] agentIds, Vector3 destination,
FormationType formation, float spacing)
{
var indices = ConvertToAgentIndices(agentIds);
if (indices.Count == 0) return;
// Basic per-agent move; add formation logic here if needed.
var agents = crowdController.GetAgents();
for (int i = 0; i < indices.Count; i++)
{
int index = indices[i];
if (index < 0 || index >= agents.Count) continue;
var agent = agents[index];
if (agent == null || agent.navMeshAgent == null || !agent.navMeshAgent.isOnNavMesh)
continue;
agent.navMeshAgent.SetDestination(destination);
}
if (debugLogging)
Debug.Log($"[NetworkAuthority] Executed move for {indices.Count} agents to {destination}");
}
private bool ValidateMoveCommand(uint[] agentIds, Vector3 destination, int playerId)
{
if (!enableAntiCheat) return true;
// Basic validation
if (agentIds == null || agentIds.Length == 0)
return false;
// Check agent ownership
foreach (uint id in agentIds)
{
int index = GetAgentIndex(id);
if (index < 0) continue;
// Set owner (skip if you track ownership externally)
var agents = crowdController.GetAgents();
if (index >= agents.Count) continue;
var agent = agents[index];
if (agent.ownerPlayerId != playerId && agent.ownerPlayerId != -1)
{
if (debugLogging)
Debug.LogWarning($"[NetworkAuthority] Player {playerId} doesn't own agent {id}");
return false;
}
}
// Check destination is reasonable (within world bounds, on NavMesh, etc.)
// Add your own validation here
return true;
}
#endregion
#region Attack Commands
private void OnLocalAttackCommand(CommandEventData data)
{
if (data.agentIndices == null || data.agentIndices.Length == 0)
return;
uint[] attackerIds = data.networkIds ?? ConvertToNetworkIds(data.agentIndices);
uint targetId = data.targetNetworkId != 0 ? data.targetNetworkId : GetNetworkId(data.targetAgentIndex);
if (IsServer)
{
if (ValidateAttackCommand(attackerIds, targetId, LocalPlayerId))
{
BroadcastAttackCommandClientRpc(attackerIds, targetId, data.targetAgentIndex);
}
}
else
{
RequestAttackCommandServerRpc(attackerIds, targetId, data.targetAgentIndex);
}
}
[Rpc(SendTo.Server, RequireOwnership = false)]
private void RequestAttackCommandServerRpc(uint[] attackerIds, uint targetId,
int targetIndex, RpcParams rpcParams = default)
{
int senderId = (int)rpcParams.Receive.SenderClientId;
if (ValidateAttackCommand(attackerIds, targetId, senderId))
{
ExecuteAttackCommand(attackerIds, targetId, targetIndex);
BroadcastAttackCommandClientRpc(attackerIds, targetId, targetIndex);
}
}
[Rpc(SendTo.NotServer)]
private void BroadcastAttackCommandClientRpc(uint[] attackerIds, uint targetId, int targetIndex)
{
ExecuteAttackCommand(attackerIds, targetId, targetIndex);
}
private void ExecuteAttackCommand(uint[] attackerIds, uint targetId, int targetIndex)
{
var attackerIndices = ConvertToAgentIndices(attackerIds);
if (attackerIndices.Count == 0) return;
// Resolve target index if needed
if (targetIndex < 0)
targetIndex = GetAgentIndex(targetId);
if (targetIndex < 0) return;
var agents = crowdController.GetAgents();
if (targetIndex >= agents.Count) return;
var targetAgent = agents[targetIndex];
crowdController.SetAttackTargetForAgents(attackerIndices,
targetAgent.navMeshAgent?.transform);
}
private bool ValidateAttackCommand(uint[] attackerIds, uint targetId, int playerId)
{
if (!enableAntiCheat) return true;
// Validate ownership of attackers
foreach (uint id in attackerIds)
{
int index = GetAgentIndex(id);
if (index < 0) continue;
var agents = crowdController.GetAgents();
if (index >= agents.Count) continue;
var agent = agents[index];
if (agent.ownerPlayerId != playerId && agent.ownerPlayerId != -1)
return false;
}
// Validate target exists
int targetIndex = GetAgentIndex(targetId);
if (targetIndex < 0) return false;
return true;
}
#endregion
#region Stop Commands
private void OnLocalStopCommand(CommandEventData data)
{
if (data.agentIndices == null || data.agentIndices.Length == 0)
return;
uint[] networkIds = data.networkIds ?? ConvertToNetworkIds(data.agentIndices);
if (IsServer)
{
BroadcastStopCommandClientRpc(networkIds);
}
else
{
RequestStopCommandServerRpc(networkIds);
}
}
[Rpc(SendTo.Server, RequireOwnership = false)]
private void RequestStopCommandServerRpc(uint[] agentIds, RpcParams rpcParams = default)
{
int senderId = (int)rpcParams.Receive.SenderClientId;
// Validate ownership
bool valid = true;
foreach (uint id in agentIds)
{
int index = GetAgentIndex(id);
if (index < 0) continue;
var agents = crowdController.GetAgents();
if (index >= agents.Count) continue;
if (agents[index].ownerPlayerId != senderId && agents[index].ownerPlayerId != -1)
{
valid = false;
break;
}
}
if (valid)
{
ExecuteStopCommand(agentIds);
BroadcastStopCommandClientRpc(agentIds);
}
}
[Rpc(SendTo.NotServer)]
private void BroadcastStopCommandClientRpc(uint[] agentIds)
{
ExecuteStopCommand(agentIds);
}
private void ExecuteStopCommand(uint[] agentIds)
{
var indices = ConvertToAgentIndices(agentIds);
foreach (int index in indices)
{
var agents = crowdController.GetAgents();
if (index >= 0 && index < agents.Count)
{
var agent = agents[index];
agent.navMeshAgent?.ResetPath();
agent.attackTargetAgentIndex = -1;
agent.attackTargetTransform = null;
}
}
}
#endregion
#region Damage Synchronization
private void OnLocalDamageApplied(DamageEventData data)
{
if (IsServer)
{
// Server: broadcast to clients
BroadcastDamageClientRpc(
data.targetNetworkId,
data.attackerNetworkId,
data.damage,
data.newHealth,
data.isDead,
data.hitPoint
);
}
// Clients don't send damage - server calculates
}
[Rpc(SendTo.Server, RequireOwnership = false)]
public void RequestDamageServerRpc(uint attackerId, uint targetId, float damage,
Vector3 hitPoint, RpcParams rpcParams = default)
{
int senderId = (int)rpcParams.Receive.SenderClientId;
// Validate damage request
if (!ValidateDamageRequest(attackerId, targetId, damage, senderId))
{
if (debugLogging)
Debug.LogWarning($"[NetworkAuthority] Damage request rejected from player {senderId}");
return;
}
// Apply damage on server
int targetIndex = GetAgentIndex(targetId);
if (targetIndex < 0) return;
int attackerIndex = GetAgentIndex(attackerId);
bool killed = crowdController.TryApplyDamage(
targetIndex, damage, true, false,
null, null, null, attackerIndex, false
);
// Get updated health
var agents = crowdController.GetAgents();
float newHealth = targetIndex < agents.Count ? agents[targetIndex].health : 0;
bool isDead = targetIndex < agents.Count && agents[targetIndex].isDead;
// Broadcast to all clients
BroadcastDamageClientRpc(targetId, attackerId, damage, newHealth, isDead, hitPoint);
}
[Rpc(SendTo.NotServer)]
private void BroadcastDamageClientRpc(uint targetId, uint attackerId, float damage,
float newHealth, bool isDead, Vector3 hitPoint)
{
int targetIndex = GetAgentIndex(targetId);
if (targetIndex < 0) return;
var agents = crowdController.GetAgents();
if (targetIndex >= agents.Count) return;
var agent = agents[targetIndex];
// Apply authoritative state
agent.health = newHealth;
if (isDead && !agent.isDead)
{
agent.isDead = true;
// Trigger death visuals
crowdController.TriggerDeathVisuals(targetIndex);
}
}
private bool ValidateDamageRequest(uint attackerId, uint targetId, float damage, int playerId)
{
if (!enableAntiCheat) return true;
// Check attacker ownership
int attackerIndex = GetAgentIndex(attackerId);
if (attackerIndex >= 0)
{
var agents = crowdController.GetAgents();
if (attackerIndex < agents.Count)
{
var attacker = agents[attackerIndex];
if (attacker.ownerPlayerId != playerId && attacker.ownerPlayerId != -1)
return false;
}
}
// Check damage amount is reasonable
if (damage < 0 || damage > 10000)
return false;
// Check target exists
int targetIndex = GetAgentIndex(targetId);
if (targetIndex < 0) return false;
// OPTIONAL: Distance validation for stricter anti-cheat
// Uncomment and adjust tolerance for your game type:
//
// var agents = crowdController.GetAgents();
// var attacker = agents[attackerIndex];
// var target = agents[targetIndex];
// float distance = Vector3.Distance(
// attacker.navMeshAgent.transform.position,
// target.navMeshAgent.transform.position
// );
//
// // Relaxed tolerance for co-op (accounts for position sync delay)
// float maxAttackRange = 5f; // Your weapon's range
// float tolerance = 3f; // Extra tolerance for network delay
// if (distance > maxAttackRange + tolerance)
// return false;
return true;
}
private void OnLocalAgentDied(DeathEventData data)
{
if (IsServer)
{
BroadcastDeathClientRpc(data.networkId, data.killerNetworkId,
data.deathPosition, data.useRagdoll);
}
}
[Rpc(SendTo.NotServer)]
private void BroadcastDeathClientRpc(uint victimId, uint killerId,
Vector3 deathPos, bool useRagdoll)
{
int victimIndex = GetAgentIndex(victimId);
if (victimIndex < 0) return;
var agents = crowdController.GetAgents();
if (victimIndex >= agents.Count) return;
var agent = agents[victimIndex];
if (!agent.isDead)
{
agent.isDead = true;
crowdController.TriggerDeathVisuals(victimIndex);
}
}
#endregion
#region Spawn Synchronization
/// <summary>
/// Call this when spawning agents. Server generates IDs and broadcasts.
/// </summary>
public void SpawnAgentsNetworked(int crowdIndex, Vector3 position,
Quaternion rotation, int count, int ownerPlayerId)
{
if (IsServer)
{
// Generate network IDs
uint[] networkIds = new uint[count];
for (int i = 0; i < count; i++)
{
networkIds[i] = GenerateNetworkId();
}
// Spawn locally
var spawnedIndices = crowdController.SpawnEnemyCrowd(
crowdIndex, position, rotation, count, FormationType.Square);
// Register network IDs
for (int i = 0; i < Mathf.Min(spawnedIndices.Count, networkIds.Length); i++)
{
RegisterAgent(spawnedIndices[i], networkIds[i]);
// Set owner (skip if you track ownership externally)
var agents = crowdController.GetAgents();
if (spawnedIndices[i] < agents.Count)
{
agents[spawnedIndices[i]].ownerPlayerId = ownerPlayerId;
}
}
// Broadcast to clients
BroadcastSpawnClientRpc(networkIds, crowdIndex, position, rotation, ownerPlayerId);
}
else
{
// Client: request spawn from server
RequestSpawnServerRpc(crowdIndex, position, rotation, count);
}
}
[Rpc(SendTo.Server, RequireOwnership = false)]
private void RequestSpawnServerRpc(int crowdIndex, Vector3 position,
Quaternion rotation, int count, RpcParams rpcParams = default)
{
int senderId = (int)rpcParams.Receive.SenderClientId;
// Validate spawn request (add your own limits)
if (count > 100)
{
Debug.LogWarning($"[NetworkAuthority] Spawn request too large from player {senderId}");
return;
}
SpawnAgentsNetworked(crowdIndex, position, rotation, count, senderId);
}
[Rpc(SendTo.NotServer)]
private void BroadcastSpawnClientRpc(uint[] networkIds, int crowdIndex,
Vector3 position, Quaternion rotation, int ownerPlayerId)
{
// Spawn on client
var spawnedIndices = crowdController.SpawnEnemyCrowd(
crowdIndex, position, rotation, networkIds.Length, FormationType.Square);
// Register network IDs
for (int i = 0; i < Mathf.Min(spawnedIndices.Count, networkIds.Length); i++)
{
RegisterAgent(spawnedIndices[i], networkIds[i]);
var agents = crowdController.GetAgents();
if (spawnedIndices[i] < agents.Count)
{
agents[spawnedIndices[i]].ownerPlayerId = ownerPlayerId;
}
}
}
#endregion
#region State Synchronization
/// <summary>
/// Periodic state sync - syncs health AND positions for all agents.
/// Called at stateSyncInterval rate on the server.
/// </summary>
private void SyncCriticalState()
{
if (!IsServer) return;
var agents = crowdController.GetAgents();
var stateUpdates = new List<AgentFullState>();
for (int i = 0; i < agents.Count; i++)
{
var agent = agents[i];
if (agent == null || agent.networkId == 0 || agent.isDead) continue;
var transform = agent.navMeshAgent?.transform;
if (transform == null) continue;
stateUpdates.Add(new AgentFullState
{
networkId = agent.networkId,
factionIndex = agent.factionIndex,
position = transform.position,
rotation = transform.rotation,
health = agent.health,
isDead = agent.isDead,
ownerPlayerId = agent.ownerPlayerId
});
// Batch to avoid huge RPCs
if (stateUpdates.Count >= 50)
{
SyncStateBatchClientRpc(stateUpdates.ToArray());
stateUpdates.Clear();
}
}
if (stateUpdates.Count > 0)
{
SyncStateBatchClientRpc(stateUpdates.ToArray());
}
}
[Rpc(SendTo.NotServer)]
private void SyncStateBatchClientRpc(AgentFullState[] updates)
{
var agents = crowdController.GetAgents();
foreach (var update in updates)
{
int index = GetAgentIndex(update.networkId);
if (index < 0 || index >= agents.Count) continue;
var agent = agents[index];
if (agent == null || agent.navMeshAgent == null) continue;
// Update position (with interpolation-friendly approach)
var transform = agent.navMeshAgent.transform;
float distance = Vector3.Distance(transform.position, update.position);
// Only correct if significantly different (avoids jitter)
if (distance > 1f)
{
agent.navMeshAgent.Warp(update.position);
transform.rotation = update.rotation;
}
agent.health = update.health;
if (update.isDead && !agent.isDead)
{
agent.isDead = true;
crowdController.TriggerDeathVisuals(index);
}
}
}
#endregion
#region Late-Joiner Synchronization
/// <summary>
/// Sends full game state to a newly connected client.
/// Called by the server when a client connects mid-game.
/// </summary>
public void SendFullStateToClient(ulong clientId)
{
if (!IsServer) return;
var agents = crowdController.GetAgents();
var fullState = new List<AgentFullState>();
for (int i = 0; i < agents.Count; i++)
{
var agent = agents[i];
if (agent == null || agent.networkId == 0) continue;
var transform = agent.navMeshAgent?.transform;
fullState.Add(new AgentFullState
{
networkId = agent.networkId,
factionIndex = agent.factionIndex,
position = transform?.position ?? Vector3.zero,
rotation = transform?.rotation ?? Quaternion.identity,
health = agent.health,
isDead = agent.isDead,
ownerPlayerId = agent.ownerPlayerId
});
// Send in batches
if (fullState.Count >= 50)
{
SendFullStateBatchRpc(fullState.ToArray(), RpcTarget.Single(clientId, RpcTargetUse.Temp));
fullState.Clear();
}
}
if (fullState.Count > 0)
{
SendFullStateBatchRpc(fullState.ToArray(), RpcTarget.Single(clientId, RpcTargetUse.Temp));
}
if (debugLogging)
Debug.Log($"[NetworkAuthority] Sent full state ({agents.Count} agents) to client {clientId}");
}
[Rpc(SendTo.SpecifiedInParams)]
private void SendFullStateBatchRpc(AgentFullState[] states, RpcParams rpcParams = default)
{
// Client receives full state from server
ApplyFullStateBatch(states);
}
/// <summary>
/// Applies a batch of full agent states. Used for late-joiner sync.
/// Spawns agents that don't exist locally and updates existing ones.
/// </summary>
private void ApplyFullStateBatch(AgentFullState[] states)
{
foreach (var state in states)
{
int existingIndex = GetAgentIndex(state.networkId);
if (existingIndex >= 0)
{
// Agent exists - update state
var agents = crowdController.GetAgents();
if (existingIndex < agents.Count)
{
var agent = agents[existingIndex];
if (agent?.navMeshAgent != null)
{
agent.navMeshAgent.Warp(state.position);
agent.navMeshAgent.transform.rotation = state.rotation;
}
agent.health = state.health;
agent.isDead = state.isDead;
agent.ownerPlayerId = state.ownerPlayerId;
if (state.isDead)
{
crowdController.TriggerDeathVisuals(existingIndex);
}
}
}
else
{
// Agent doesn't exist - spawn it
var spawnedIndices = crowdController.SpawnAgents(
state.factionIndex,
1,
state.position,
FormationType.None,
1f
);
if (spawnedIndices.Count > 0)
{
int newIndex = spawnedIndices[0];
RegisterAgent(newIndex, state.networkId);
var agents = crowdController.GetAgents();
var agent = agents[newIndex];
if (agent?.navMeshAgent != null)
{
agent.navMeshAgent.Warp(state.position);
agent.navMeshAgent.transform.rotation = state.rotation;
}
agent.health = state.health;
agent.isDead = state.isDead;
agent.ownerPlayerId = state.ownerPlayerId;
if (state.isDead)
{
crowdController.TriggerDeathVisuals(newIndex);
}
}
}
}
if (debugLogging)
Debug.Log($"[NetworkAuthority] Applied full state batch ({states.Length} agents)");
}
#endregion
}
#region Data Structures
[Serializable]
public struct AgentHealthUpdate : INetworkSerializable
{
public uint networkId;
public float health;
public bool isDead;
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
{
serializer.SerializeValue(ref networkId);
serializer.SerializeValue(ref health);
serializer.SerializeValue(ref isDead);
}
}
/// <summary>
/// Full agent state for late-joiner synchronization and position sync.
/// </summary>
[Serializable]
public struct AgentFullState : INetworkSerializable
{
public uint networkId;
public int factionIndex;
public Vector3 position;
public Quaternion rotation;
public float health;
public bool isDead;
public int ownerPlayerId;
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
{
serializer.SerializeValue(ref networkId);
serializer.SerializeValue(ref factionIndex);
serializer.SerializeValue(ref position);
serializer.SerializeValue(ref rotation);
serializer.SerializeValue(ref health);
serializer.SerializeValue(ref isDead);
serializer.SerializeValue(ref ownerPlayerId);
}
}
#endregion
}
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
namespace YourGame.Networking
{
public class P2PConnectionUI : MonoBehaviour
{
[Header("UI References")]
[SerializeField] private TMP_InputField ipAddressInput;
[SerializeField] private TMP_InputField portInput;
[SerializeField] private Button hostButton;
[SerializeField] private Button joinButton;
[SerializeField] private Button disconnectButton;
[SerializeField] private TextMeshProUGUI statusText;
[SerializeField] private GameObject connectionPanel;
[SerializeField] private GameObject gamePanel;
[Header("Network Authority")]
[SerializeField] private GameObject networkAuthorityPrefab;
private void Start()
{
// Default values
ipAddressInput.text = "127.0.0.1";
portInput.text = "7777";
// Button listeners
hostButton.onClick.AddListener(HostGame);
joinButton.onClick.AddListener(JoinGame);
disconnectButton.onClick.AddListener(Disconnect);
// Network callbacks
NetworkManager.Singleton.OnClientConnectedCallback += OnClientConnected;
NetworkManager.Singleton.OnClientDisconnectCallback += OnClientDisconnected;
UpdateUI();
}
private void OnDestroy()
{
if (NetworkManager.Singleton != null)
{
NetworkManager.Singleton.OnClientConnectedCallback -= OnClientConnected;
NetworkManager.Singleton.OnClientDisconnectCallback -= OnClientDisconnected;
}
}
public void HostGame()
{
// Configure transport
var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
// 127.0.0.1 is the host's local client connect address.
// 0.0.0.0 listens on all interfaces for incoming clients.
transport.SetConnectionData("127.0.0.1", ushort.Parse(portInput.text), "0.0.0.0");
// Start host (server + client)
if (NetworkManager.Singleton.StartHost())
{
statusText.text = $"Hosting on port {portInput.text}...";
// Spawn network authority
SpawnNetworkAuthority();
}
else
{
statusText.text = "Failed to start host!";
}
UpdateUI();
}
public void JoinGame()
{
// Configure transport
var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
transport.SetConnectionData(ipAddressInput.text, ushort.Parse(portInput.text));
// Start client
if (NetworkManager.Singleton.StartClient())
{
statusText.text = $"Connecting to {ipAddressInput.text}:{portInput.text}...";
}
else
{
statusText.text = "Failed to start client!";
}
UpdateUI();
}
public void Disconnect()
{
NetworkManager.Singleton.Shutdown();
statusText.text = "Disconnected";
UpdateUI();
}
private void SpawnNetworkAuthority()
{
if (NetworkManager.Singleton.IsServer && networkAuthorityPrefab != null)
{
var instance = Instantiate(networkAuthorityPrefab);
instance.GetComponent<NetworkObject>().Spawn();
}
}
private void OnClientConnected(ulong clientId)
{
if (clientId == NetworkManager.Singleton.LocalClientId)
{
statusText.text = NetworkManager.Singleton.IsHost
? $"Hosting (Your IP: {GetLocalIPAddress()})"
: "Connected to host!";
}
else if (NetworkManager.Singleton.IsServer)
{
statusText.text = $"Player {clientId} connected! ({NetworkManager.Singleton.ConnectedClientsList.Count} players)";
// LATE-JOINER SYNC: Send full game state to newly connected client
var networkAuthority = FindFirstObjectByType<EnemyMassesNetworkAuthority>();
if (networkAuthority != null)
{
// Small delay to ensure client is fully ready
StartCoroutine(SendLateJoinerState(networkAuthority, clientId));
}
}
UpdateUI();
}
/// <summary>
/// Sends full game state to a late-joining client after a small delay.
/// </summary>
private System.Collections.IEnumerator SendLateJoinerState(EnemyMassesNetworkAuthority authority, ulong clientId)
{
// Wait for client to be fully initialized
yield return new WaitForSeconds(0.5f);
authority.SendFullStateToClient(clientId);
Debug.Log($"[LobbyUI] Sent full state to late-joiner {clientId}");
}
private void OnClientDisconnected(ulong clientId)
{
if (clientId == NetworkManager.Singleton.LocalClientId)
{
statusText.text = "Disconnected from server";
}
else if (NetworkManager.Singleton.IsServer)
{
statusText.text = $"Player {clientId} disconnected";
}
UpdateUI();
}
private void UpdateUI()
{
bool isConnected = NetworkManager.Singleton.IsClient || NetworkManager.Singleton.IsServer;
connectionPanel.SetActive(!isConnected);
gamePanel.SetActive(isConnected);
disconnectButton.gameObject.SetActive(isConnected);
}
private string GetLocalIPAddress()
{
var host = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName());
foreach (var ip in host.AddressList)
{
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
return ip.ToString();
}
}
return "127.0.0.1";
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// BUILT-IN NETWORK EVENTS (no modification needed!)
// ═══════════════════════════════════════════════════════════════════════════
// Fired when agents are spawned
public event Action<SpawnEventData> NetworkEventAgentSpawned;
// Fired when an agent takes damage
public event Action<DamageEventData> NetworkEventDamageApplied;
// Fired when an agent dies
public event Action<DeathEventData> NetworkEventAgentDied;
// Fired when health changes (damage or healing)
public event Action<HealthChangeEventData> NetworkEventHealthChanged;
// Fired when combat/navigation state changes
public event Action<StateChangeEventData> NetworkEventStateChanged;
// Fired when agents are despawned
public event Action<uint[], DespawnReason> NetworkEventAgentsDespawned;
// ═══════════════════════════════════════════════════════════════════════════
// BUILT-IN COMMAND EVENTS (for RTS-style games)
// ═══════════════════════════════════════════════════════════════════════════
// Fired when a move command is issued
public event Action<CommandEventData> NetworkMoveCommandIssued;
// Fired when an attack command is issued
public event Action<CommandEventData> NetworkAttackCommandIssued;
// Fired when a stop command is issued
public event Action<CommandEventData> NetworkStopCommandIssued;
// In your EnemyMassesNetworkAuthority.OnNetworkSpawn():
private void SubscribeToEvents()
{
// Subscribe to crowd controller events
if (crowdController != null)
{
crowdController.NetworkEventAgentSpawned += OnAgentSpawned;
crowdController.NetworkEventDamageApplied += OnDamageApplied;
crowdController.NetworkEventAgentDied += OnAgentDied;
}
// Subscribe to RTS controller events (if using RTS mode)
if (rtsController != null)
{
rtsController.NetworkMoveCommandIssued += OnMoveCommand;
rtsController.NetworkAttackCommandIssued += OnAttackCommand;
rtsController.NetworkStopCommandIssued += OnStopCommand;
}
}
private void OnDamageApplied(DamageEventData data)
{
if (IsServer)
{
// Server: broadcast authoritative damage to all clients
BroadcastDamageClientRpc(
data.targetNetworkId,
data.attackerNetworkId,
data.damage,
data.newHealth,
data.isDead,
data.hitPoint
);
}
}
// On every CrowdAgent:
agent.networkId // uint - unique ID across network
agent.ownerPlayerId // int - which player owns this agent (-1 for AI)
agent.isNetworkAuthoritative // bool - whether this instance is authoritative
agent.spawnGroupId // int - batch spawn grouping
Scene Hierarchy:
├── NetworkManager (with UnityTransport)
├── EnemyMassesCrowdController
├── EnemyMassesRTSController (optional, for RTS games)
├── P2PConnectionUI (Canvas with UI)
├── Camera
├── Lighting
└── NavMesh Surface
// In your custom network authority
public void SendCustomStateToClient(ulong clientId)
{
// Call base implementation
SendFullStateToClient(clientId);
// Send additional game state
SendGameScoreRpc(currentScore, RpcTarget.Single(clientId, RpcTargetUse.Temp));
SendObjectivesRpc(activeObjectives.ToArray(), RpcTarget.Single(clientId, RpcTargetUse.Temp));
}
// Only correct if significantly different (avoids jitter)
if (distance > 1f)
{
agent.navMeshAgent.Warp(update.position);
transform.rotation = update.rotation;
}
// In SyncStateBatchClientRpc - instead of instant correction:
private void ApplyPositionCorrection(NavMeshAgent agent, Vector3 serverPos)
{
float distance = Vector3.Distance(agent.transform.position, serverPos);
if (distance > 3f)
{
// Large error: instant warp
agent.Warp(serverPos);
}
else if (distance > 0.5f)
{
// Small error: smooth interpolation
StartCoroutine(SmoothCorrection(agent, serverPos, 0.2f));
}
// Under threshold: ignore (client prediction is close enough)
}
private IEnumerator SmoothCorrection(NavMeshAgent agent, Vector3 target, float duration)
{
Vector3 start = agent.transform.position;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = elapsed / duration;
agent.transform.position = Vector3.Lerp(start, target, t);
yield return null;
}
agent.Warp(target);
}