Gameplay logic: validate before mutation
Commands with explicit results, unchanged state on refusal and clear notifications.
One command, one contract
TryApplyDamage accepts positive damage. It returns false for zero, a negative amount or an already dead target. Refusal does not change health. Large positive damage clamps health to zero rather than producing a negative result.
HealthModel.cs
using System;
public interface IDamageable
{
bool TryApplyDamage(int amount);
}
public sealed class HealthModel : IDamageable
{
public int Maximum { get; }
public int Current { get; private set; }
public HealthModel(int maximum)
{
if (maximum <= 0) throw new ArgumentOutOfRangeException(nameof(maximum));
Maximum = maximum;
Current = maximum;
}
public bool TryApplyDamage(int amount)
{
if (amount <= 0 || Current == 0) return false;
Current = amount >= Current ? 0 : Current - amount;
return true;
}
}Checks belong inside the model, so UI, a projectile hit and a test use the same rules. A controller should not mutate health first and decide whether the action was valid afterward.
When to call it
In the complete example, HitController calls the command for a gameplay action. This operation needs no _Process polling. Put continuous movement, physics and time-dependent work in the appropriate callbacks when the task actually needs every frame.
DamageReceiver emits HealthChanged only after a successful command. Directly subtracting health and emitting a signal does not perform the TryApplyDamage validation.
Refusal is part of the behavior
| Situation | Result |
|---|---|
| 100 health, damage 25 | true, 75 remains |
| Damage 0 or -1 | false, state preserved |
| Damage int.MaxValue | true, 0 remains |
| Another hit against a dead target | false |
| HitController has no target, or it is freed/pending deletion | false |
These are this example's rules. Healing, armor, invulnerability and network authority require explicit additional rules; an interface name does not provide them.