Deterministic Damage Pipelines in Unity and Godot - Keep Combat Numbers Consistent Across Frame Rates
If your combat feels balanced at 60 FPS but breaks at 120 FPS, the bug is usually not in your damage formula.
It is in when and how damage events are processed.
A deterministic damage pipeline means the same inputs produce the same health outcomes, regardless of frame rate. That matters for both local feel and multiplayer trust.
Why combat damage drifts across frame rates
Teams usually hit one or more of these:
- hit checks running in render loops instead of fixed simulation loops
- duplicate hit registration within one attack window
- floating timing thresholds tied to frame delta noise
- mixed authority (client applies once, server applies again)
The result is inconsistent DPS, desync reports, and balance work that never sticks.
Deterministic baseline architecture
Use one clear flow:
- Collect hit intents during frame update
- Queue intents with deterministic metadata (tick, attacker, target, move ID)
- Resolve damage only in fixed simulation step
- Apply once under authority rules
- Log checksum markers for verification builds
Treat this as a gameplay contract. All combat systems should route through it.
Step 1 - Standardize hit event payloads
Your payload should include:
tickattacker_idtarget_idmove_idbase_damagecrit_seedor deterministic roll seed
Avoid payloads that depend on transient frame-only state.
Step 2 - Resolve in fixed-step loops only
Unity
Capture input and collision hints in Update, but commit damage in FixedUpdate.
public struct DamageIntent
{
public int Tick;
public int AttackerId;
public int TargetId;
public int MoveId;
public int BaseDamage;
}
Keep a deterministic queue and process by tick order.
Godot
Capture player intent in _process if needed, then resolve in _physics_process.
Use fixed delta ticks (or your own integer tick counter) as the authority timeline.
Step 3 - Add one-hit-per-window guards
A single swing should not apply damage multiple times unless designed to multihit.
Use a key like:
(attack_instance_id, target_id)
Store it in a temporary resolved set and clear by attack window expiry.
This removes frame-rate-dependent duplicate collisions.
For related state reliability patterns, the Godot guide chapter on fixed-step combat timing is a good companion: /guides/godot?chapter=godot-deterministic-combat-timing-fixed-step-playbook.
Step 4 - Keep randomness deterministic
If crits or proc chances exist:
- use deterministic seeds derived from session/tick/actor IDs
- do not call unconstrained random APIs from render loops
Otherwise, two clients can branch into different outcomes even with identical inputs.
Step 5 - Separate authority and presentation
Compute real damage in authoritative simulation only.
Presentation (floating text, hit flashes, shake) can run client-side but should reflect authoritative outcomes.
In Unity Netcode contexts, pair this with your multiplayer lessons where reconciliation and host behavior are already defined:
/courses/ship-multiplayer-vertical-slice-unity-2026/lessons/lesson-7-lag-prediction-and-reconciliation-primer-server-authoritative-feel/courses/ship-multiplayer-vertical-slice-unity-2026/lessons/lesson-10-cheat-and-abuse-surface-triage-server-validation-rate-limits
Validation pass - 30/60/120 FPS parity
Run the same scripted combat scenario at 30, 60, and 120 FPS:
- same input sequence
- same enemy stats
- same seed
Expected:
- identical kill time (within acceptable tiny tolerance)
- identical total damage applied
- identical event counts per attack
If results diverge, diff your damage intent logs by tick.
Common mistakes to avoid
Mistake - Applying cooldown decay with delta and resolving hits in render loop
This causes timing drift under high or unstable FPS.
Fix: quantize cooldown updates to simulation ticks.
Mistake - Mixing float thresholds with direct equality checks
Small precision differences create branch mismatches.
Fix: use integer or fixed-point damage/timing units where practical.
Mistake - Letting VFX hit callbacks mutate gameplay state directly
Different visual timings produce different outcomes.
Fix: VFX reads authoritative events, never owns damage application.
Pro tips for small teams
- Keep a single
DamageResolvermodule and route all attacks through it. - Add a deterministic replay file for one benchmark combat encounter.
- Emit a tiny checksum every N ticks in debug builds.
- Version your damage formulas so balance patches remain traceable.
FAQ
Do I need lockstep networking to be deterministic?
No. You still get major value from deterministic local authority and consistent server resolution.
Should I avoid all floats in combat code?
Not always, but critical branching logic should prefer deterministic integer/fixed representations.
Is this overkill for a single-player action game?
Not if combat balance matters. Deterministic pipelines reduce platform-specific tuning bugs.
How early should I implement this?
As soon as your game has repeat combat loops and progression tuning.
Final takeaway
Deterministic damage is mostly a systems discipline problem, not a math problem.
If event ordering, authority boundaries, and fixed-step processing are consistent, your combat numbers stop drifting and your balancing work starts compounding.
If this helped, bookmark it and use the 30/60/120 FPS parity test as a release gate for every combat-heavy milestone.