Unity6 Game Development · Lesson 23

A testable health model behind a Unity shell

Keep gameplay rules independent from engine callbacks and presentation.

Unity 6 Editor walkthrough

The Unity script needs the Editor and the setup listed below. The separate C# companion practice runs in SharpRune without UnityEngine.

Keep the health invariant in an ordinary model

A gameplay model can own its invariants without knowing about scenes, colliders or animation. HealthModel accepts a positive starting maximum, rejects negative damage and prevents health from falling below zero. Alive is derived from Current rather than maintained as a second writable flag that might disagree. These rules remain understandable when tested with ordinary C# calls. A MonoBehaviour can then translate engine events into those calls and present the result, keeping the number of engine-dependent behaviors small enough to inspect and integrate deliberately.

DamageableActor is that adapter in this lesson. Awake creates a model using a validated starting value, and a Play-mode context action applies three damage and logs the outcome. The shell owns lifecycle and presentation; the model owns the mathematical rule. Its nested HealthModel type is still ordinary C# rather than a MonoBehaviour. This boundary makes deterministic rule checks possible without constructing a GameObject, but it does not remove the need to test whether the correct shell is attached, initialized and invoked by the intended scene event in a real Unity project.

Portable C# companion: damage, overkill and rejection

The SharpRune companion starts at ten and applies three damage, so its first line is 7. Applying one hundred next clamps the value to zero, producing 0 and then False for Alive. The final negative damage request throws ArgumentOutOfRangeException and prints invalid damage from the catch block; it does not heal or reduce health further. These four observations establish the demonstrated model transitions for ordinary C#. They do not run Awake, invoke a Unity context action or check that a real collision would call the intended actor.

var health = new HealthModel(10); health.Damage(3); Console.WriteLine(health.Current); health.Damage(100); Console.WriteLine(health.Current); Console.WriteLine(health.Alive);
try { health.Damage(-1); } catch (ArgumentOutOfRangeException) { Console.WriteLine("invalid damage"); }
sealed class HealthModel { public int Current { get; private set; } public bool Alive => Current > 0; public HealthModel(int maximum) { if (maximum < 1) throw new ArgumentOutOfRangeException(nameof(maximum)); Current = maximum; } public void Damage(int amount) { if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount)); Current = Math.Max(0, Current - amount); } }

Unity Editor: a small lifecycle and presentation adapter

The Unity Editor excerpt belongs to DamageableActor.cs. Attach the component with startingHealth ten, enter Play mode and invoke Apply Three Damage repeatedly. The real action produces health values 7, 4, 1 and 0, with later hits remaining zero. It validates runtime readiness through Application.isPlaying and a nonnull model check. That sequence differs from the companion's deliberate overkill case, so label each result accurately. There is no automatic collision or UI binding in the supplied shell; adding one would create another adapter boundary that needs its own integration check.

private void Awake() { health = new HealthModel(Mathf.Max(1, startingHealth)); }
    [ContextMenu("Apply Three Damage")]
    public void ApplyThreeDamage()
    {
        if (!Application.isPlaying || health == null) return;
        health.Damage(3);
        Debug.Log("Health=" + health.Current + ", Alive=" + health.Alive, this);
    }

Inspector guidance does not replace model validation

The Min attribute helps Inspector editing, but callers and imported data still need runtime validation. The model therefore rejects invalid capacity and negative damage itself, while the shell clamps its serialized starting value before construction. Do not expose Current as an unrestricted setter just to make presentation convenient, since that bypasses the invariant. Also avoid burying every gameplay rule in Update or assuming model tests prove animation, audio or collider configuration. Effects should follow explicit state transitions, and a future death notification needs a deliberate once-only policy rather than firing on every overkill hit.

Two independent model and adapter exercises

First, independently extend the console rule checks with zero damage, an exact lethal hit and a hit after death. Start each case with a fresh model and predict that zero leaves health unchanged, exact damage reaches zero and later hits remain zero. Hint: test the boundaries around zero instead of only another comfortable middle value. Add invalid initial capacities as separate exception cases. This practice checks the rule contract without creating Unity objects and should explain why rejection is preferable to silently turning negative damage into an accidental healing feature.

Second, create an independent Unity adapter experiment that routes one actual Button click or collision into the actor's damage action. Keep the HealthModel implementation unchanged and verify one intended event produces one three-point transition. Hint: diagnose event delivery and model arithmetic separately; the existing context action can establish initialization before the new binding is exercised. Test disable and re-enable behavior under the chosen adapter policy. A successful integration experiment should record actual scene components and references, not claim that the portable test somehow simulated the physics or UI event source.

Review rules separately from scene wiring

Review a gameplay feature by stating its invariants before naming any engine callbacks. For this model, capacity is positive, damage is nonnegative, Current is never negative and Alive follows Current. Then list what the shell must establish: construction at the intended lifecycle point, a valid incoming event and an observable presentation result. The portable trace gives deterministic rule evidence; the Editor context action gives limited adapter evidence. Keep those scopes distinct so future tests can locate a broken rule, missing reference or duplicated event without moving unrelated engine details into the model.

  • Keep Current controlled by the model's invariant-preserving methods.
  • Test zero, lethal, overkill and invalid-input boundaries independently.
  • Treat Inspector constraints as guidance alongside runtime validation.
  • Verify actual input or collision wiring separately in Unity.
Runs in Unity 6 Editor

Unity Editor example

Set up in Unity 6

  1. Attach DamageableActor.cs to an active object and set startingHealth to 10.
  2. Enter Play mode and invoke Apply Three Damage repeatedly.
  3. Observe health 7,4,1,0 and that further hits remain at zero.
  4. Run the separate C# practice and companion project to inspect rule tests without claiming scene integration.
  5. In an extension, route a real collision or UI action into the model and test that adapter independently in the Editor.

DamageableActor.cs

Download Unity script ↗
using System;
using UnityEngine;
public sealed class DamageableActor : MonoBehaviour
{
    [SerializeField, Min(1)] private int startingHealth = 10;
    private HealthModel health;
    private void Awake() { health = new HealthModel(Mathf.Max(1, startingHealth)); }
    [ContextMenu("Apply Three Damage")]
    public void ApplyThreeDamage()
    {
        if (!Application.isPlaying || health == null) return;
        health.Damage(3);
        Debug.Log("Health=" + health.Current + ", Alive=" + health.Alive, this);
    }
    public sealed class HealthModel
    {
        public int Current { get; private set; }
        public bool Alive { get { return Current > 0; } }
        public HealthModel(int maximum) { if (maximum < 1) throw new ArgumentOutOfRangeException(nameof(maximum)); Current = maximum; }
        public void Damage(int amount) { if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount)); Current = Math.Max(0, Current - amount); }
    }
}

What to observe in Unity

The Unity shell reports clamped health and alive state after each context action. The ordinary rule model can be tested independently from scene callbacks.

The console practice below isolates the C# logic. It does not run a Unity scene or emulate Unity engine callbacks.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

var health = new HealthModel(10); health.Damage(3); Console.WriteLine(health.Current); health.Damage(100); Console.WriteLine(health.Current); Console.WriteLine(health.Alive);
try { health.Damage(-1); } catch (ArgumentOutOfRangeException) { Console.WriteLine("invalid damage"); }
sealed class HealthModel { public int Current { get; private set; } public bool Alive => Current > 0; public HealthModel(int maximum) { if (maximum < 1) throw new ArgumentOutOfRangeException(nameof(maximum)); Current = maximum; } public void Damage(int amount) { if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount)); Current = Math.Max(0, Current - amount); } }
Open C# practice

C# practice output

7
0
False
invalid damage
Try this
Test the rule model separately, then verify that the Unity shell calls it at the intended lifecycle boundary.

Further reading