The first multiplayer prototype I built in Unity NGO had a damage path I’m now a little embarrassed by. The shooter’s client ran the raycast, decided how much damage it dealt, and the host accepted the result. The ServerRpc literally took requestedDamage as a parameter and applied it. I knew it was wrong — there was a comment in the file admitting a modded client could send arbitrary numbers — but it shipped because the prototype only had to prove the rest of the loop worked.
When I started the next prototype on the same foundation, the first thing I wanted to fix was that signature. This post is about what happened on the way: what broke when I pulled hit detection onto the server, what surfaced as a side effect, and what I’d carry forward.
The starting point
The carried-over hitscan path looked like this on the client:
// Client
var hit = Physics.Raycast(ray, out var hitInfo, maxRange, hitLayers);
if (hit)
{
var enemyTag = hitInfo.collider.GetComponentInParent<EnemyEntityTag>();
if (enemyTag != null)
{
onHitConfirmed?.RaiseEvent();
ShootServerRpc(enemyTag.EntityId, damage, hitInfo.point);
}
}
And on the server:
[ServerRpc]
private void ShootServerRpc(int targetEntityId, int requestedDamage, Vector3 hitPoint)
{
var state = enemyStateSet.GetData(targetEntityId);
state = EnemyStateCalculator.ApplyDamage(state, requestedDamage);
enemyStateSet.SetData(targetEntityId, state);
// ...
}
The server is trusting which enemy was hit, the damage amount, and that the geometry actually allowed the hit. None of those are safe assumptions, even for a single-platform co-op build played with friends.
Making the cracks visible first
Before rewriting anything I wanted to see what was actually happening. The first thing I did was swap the projectile placeholder out for a beam — a hitscan stand-in with a visible trace. The beam was always going to be temporary; it was there to turn every “did this shot connect?” question into something I could answer by looking. Origin, endpoint, hit-or-miss, surface normal — all of it became visible at once on both client and host.
The same session pushed me to add fireball and AoE explosion placeholders too. Three attack types with very different timing characteristics — instant, in-flight, area — pulled out the parts of the old design that only worked because everything happened to be a single-frame raycast.
The migration
The shape I ended up with is closer to what NGO’s own samples and Boss Room do: the client sends inputs, the server runs the hit detection.
A new AttackConfigSO ScriptableObject owns the per-attack tuning: damage, range, fire interval, projectile speed, AoE radius, hit radius. The AttackType enum has three values (Hitscan, Projectile, AoE) and the server-side processing branches off that. The damage value lives on the server’s copy of the config, never on the wire.
For hitscan, the ServerRpc signature became this:
[ServerRpc]
private void ShootServerRpc(Vector3 origin, Vector3 direction)
{
var config = GetCurrentAttackConfig();
if (config == null) return;
// Origin sanity check
var playerPosition = transform.position;
if (!OriginValidator.IsOriginValid(origin, playerPosition, ORIGIN_VALIDATION_TOLERANCE))
return;
// Server-side fire rate gate with 10% jitter tolerance
float now = Time.time;
if (serverLastFireTimes.TryGetValue(OwnerClientId, out float lastFire))
{
if (now - lastFire < config.FireInterval * SERVER_FIRE_RATE_TOLERANCE) return;
}
serverLastFireTimes[OwnerClientId] = now;
switch (config.AttackType)
{
case AttackType.Hitscan: ProcessHitscanShot(origin, direction); break;
case AttackType.Projectile: ProcessProjectileShot(origin, direction); break;
case AttackType.AoE: ProcessAoEShot(origin, direction); break;
}
}
ORIGIN_VALIDATION_TOLERANCE is 5m and SERVER_FIRE_RATE_TOLERANCE is 0.9 (i.e. the server accepts shots up to 10% earlier than the configured interval to absorb jitter). Both live as constants on the WeaponSystem script, easy to retune later.
The client still raycasts locally, but only to drive the visual. The server runs its own Physics.Raycast against targetingLayers, looks up EnemyEntityTag to resolve which entity was hit, and runs damage through a pure-function calculator (DamageResultCalculator.Calculate) that returns whether the hit landed, whether it killed, and the new state. The NetworkBehaviour is left with the side effects: writing the new state back, raising kill score, sending a confirmation RPC.
Projectiles took a different shape. The client spawns a prediction prefab — a non-networked visual that flies at the configured speed for lifetime + 1.0s of RTT buffer — and the server spawns the real NetworkObject projectile with the shooter as owner. The networked projectile’s Update runs on the server only, does a SphereCast per tick against targetingLayers, and despawns on hit or after lifetime. When the server-spawned projectile arrives on the client, the local prediction is returned to the pool. So the player sees something leave the barrel immediately, and the authoritative version catches up and kills if it actually hit.
AoE was the simplest mechanically and the most fiddly to make feel right. The server raycasts forward to find a ground point, runs Physics.OverlapSphereNonAlloc with a fixed 32-collider buffer at that point, dedupes hits with a HashSet<int> keyed on entity id, and applies linear-falloff damage through AoEDamageCalculator. The fixed buffer matters: an OverlapSphere that allocates per shot across multiple players adds up in GC pressure when an AoE spell starts firing in earnest. There’s an Editor-only warning if the buffer fills, which I’d want to know about before changing the spell config.
The hit feedback bug
Once the server owned the damage decision, the client’s “hit landed” feedback broke. The old code raised onHitConfirmed the moment the client’s local raycast hit anything tagged as an enemy. That’s fine until the server disagrees — origin out of tolerance, fire rate too high, target already dead, geometry mismatch. The crosshair flashes green, no damage gets applied. Bad signal.
The fix was to invert it. The server, after writing the new state, sends ConfirmHitClientRpc targeted only at the shooter:
private void NotifyHitConfirmedClient(ulong targetClientId)
{
singleTargetClientIds[0] = targetClientId;
var rpcParams = new ClientRpcParams
{
Send = new ClientRpcSendParams { TargetClientIds = singleTargetClientIds }
};
ConfirmHitClientRpc(rpcParams);
}
The shooter raises onHitConfirmed only when the RPC arrives. That introduces an RTT-shaped lag in the crosshair feedback, which is honest, because that’s exactly the lag the actual damage takes too.
Adjacent problems that came along
Two things surfaced once the authority boundary moved.
The first was scene state. Server-authoritative damage assumes both sides agree on which scene’s enemies are live. The client’s SceneTransitionTracker had been driven by a local “I started loading scene X” event, which went stale the moment a server-driven transition happened. When the host swapped scenes and the client’s tracker still pointed at the previous one, the next client-side scene action would unload the wrong scene. I added a HandleLoadEventCompleted path on the tracker so that NGO’s LoadEventCompleted becomes the source of truth for what the client thinks is loaded.
The second was layout. The match lifecycle code — WaveManager, MatchStateSetSO, WaveCompletionAction — had been living in the Enemy/ folder because it grew out of enemy spawning. Once damage moved to the server and the wave-end check started reading authoritative state, keeping match logic next to enemy view code became actively confusing. It moved to a dedicated Match/ folder. The damage migration didn’t require that move, but it surfaced the misalignment in a way I couldn’t unsee.
The result screen also broke around the same time, and the fix had nothing to do with damage authority — stale MatchStateSetSO and PlayerScoreStateSetSO data from the previous room was leaking into the next session. A small MatchCleanupHandler listening on onRoomLeft and clearing both reactive sets back to their initial state was enough. I mention it because I’d expected the migration to be self-contained and it kept dragging unrelated lifecycle bugs into view.
Wrap-up
Looking back, the part that actually mattered wasn’t moving the raycast. The old design already ran a ServerRpc. What was broken was the parameter list: the client was deciding which enemy, how much damage, and whether the geometry permitted the hit. Removing requestedDamage from the wire is what fixed the thing I was embarrassed about; moving the raycast onto the server was secondary.
The other thing that helped more than I expected was swapping the projectile for a visible beam before touching anything else. With a hitscan that I couldn’t see, every “did this connect?” question was guesswork. Once the trace was visible, the bugs stopped hiding. I’d want to remember to do that earlier on the next one.
The next prototype will inherit a damage path I no longer feel guilty about. That’s the part that mattered for me.
If you came in from the related Wave 3 postmortem on this same project, the snapshot streaming work picked up right after this migration:
PostHow a 1200-byte ceiling on an NGO Unreliable RPC quietly capped my Wave 3Wave 3 enemies vanished on the client while still hitting the player. The host was throwing OverflowException. Here's the failure story behind a hidden MTU ceiling in Unity Netcode for GameObjects, and what the industry actually does about it.