Unity6 Game Development · Lesson 24

Capstone: a tested collect-and-time gameplay loop

Connect input, physics, trigger identity and terminal rules without hiding the engine boundary.

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.

Connect adapters around a deterministic run state

The capstone joins several systems without pretending they are one mechanism. Input actions provide a requested direction, Rigidbody forces move the player, trigger events identify collectibles, and a plain C# model decides score and terminal state. A HashSet prevents the same identifier from increasing score twice. Running, Won and Lost are mutually exclusive phases, and a terminal run rejects later progress. Those rules can be checked deterministically, while the scene adapters still require actual packages, colliders, tags and references. Keeping this division visible makes failures easier to localize than placing every responsibility in one callback.

The real Unity scene uses a fifteen-second scaled timer, whereas the portable model example starts with five seconds. They share the intended rule shape rather than an identical scenario. Update samples movement input and advances elapsed-time rules; FixedUpdate applies Rigidbody force while the run is active. The force API already integrates its effect with the physics step, so do not append an extra deltaTime multiplication without understanding the force mode. The supplied code deliberately separates the model's state transitions from the engine's frame and physics scheduling rather than promising a fixed number of physics updates per rendered frame.

Portable C# companion: three unique IDs win

In the SharpRune companion, Collect(1) raises score to one and a repeated Collect(1) is rejected. Tick(1) reduces the five-second starting duration to four. Collect(2) and Collect(3) then reach the target of three distinct IDs and change the phase to Won. The printed lines are 3, Won and 4.0, using invariant one-decimal formatting. No keyboard input, collision detection or scene loading occurs. The remaining time stays four after winning because subsequent terminal-state ticks would be ignored, not because a real Unity clock was paused by this console example.

var run = new RunModel(3, 5); run.Collect(1); run.Collect(1); run.Tick(1); run.Collect(2); run.Collect(3);
Console.WriteLine(run.Score); Console.WriteLine(run.Phase); Console.WriteLine(run.Remaining.ToString("0.0", CultureInfo.InvariantCulture));
enum Phase { Running, Won, Lost }
sealed class RunModel {
    private readonly HashSet<int> collected = new(); private readonly int target;
    public int Score { get; private set; } public double Remaining { get; private set; } public Phase Phase { get; private set; }
    public RunModel(int target, double seconds) { if (target < 1 || seconds <= 0 || double.IsNaN(seconds) || double.IsInfinity(seconds)) throw new ArgumentOutOfRangeException(); this.target = target; Remaining = seconds; }
    public bool Collect(int id) { if (Phase != Phase.Running || !collected.Add(id)) return false; Score++; if (Score >= target) Phase = Phase.Won; return true; }
    public void Tick(double seconds) { if (seconds < 0 || double.IsNaN(seconds) || double.IsInfinity(seconds)) throw new ArgumentOutOfRangeException(); if (Phase != Phase.Running) return; Remaining = Math.Max(0, Remaining - seconds); if (Remaining == 0) Phase = Phase.Lost; }
}

Unity Editor: guard the trigger adapter lifetime

CoinRunLab.cs requires the real Input System and configured Rigidbody, sphere collider, Collectible tag and three separate single-collider trigger objects. Its trigger adapter first checks isActiveAndEnabled and initialized state, because Unity can deliver trigger messages to disabled MonoBehaviours. The model then decides whether the identifier is new and the run still accepts progress. Only an accepted collection deactivates that coin and reports score. The excerpt is actual Unity code, not a SharpRune engine emulation. Test the disabled-component case separately, since disabling Update alone would not establish that trigger-driven score changes are also suppressed.

private void OnTriggerEnter(Collider other)
    {
        if (!isActiveAndEnabled || state == null) return;
        if (!other.CompareTag("Collectible") || !state.Collect(other.gameObject.GetInstanceID())) return;
        other.gameObject.SetActive(false);
        Debug.Log("Score: " + state.Score + "/3", this);
        ReportOutcome();
    }

Terminal rules and callback ordering need a policy

Instance IDs are suitable for identifying these demo objects during this session, but they are not durable save identifiers. Multiple collider GameObjects or inconsistent tags would change what the adapter regards as a collectible, so preserve the specified one-object, one-trigger setup. A timeout and a contact near the same boundary also require an application rule: whichever model call is accepted first under the current execution path determines the supplied outcome. Do not invent a global guarantee that every trigger always runs before or after every timer update. If fairness needs another tie policy, encode and test it explicitly.

Two independent capstone verification exercises

First, independently test the console state model with a fresh run that receives Tick(5) before collecting. Predict Lost, score zero and remaining zero, then verify later collection cannot revive it. In a second fresh run, collect all three IDs before ticking again and confirm Won remains terminal. Hint: use distinct instances so one scenario cannot leak into the other, and inspect duplicate IDs separately. This practice investigates transition order as an explicit rule; it does not prove which order a real scene's physics and rendered-frame events will produce near a time boundary.

Second, perform an independent Unity integration pass before tuning the timer or adding art. Verify focused Game-view WASD input, visible Rigidbody movement, one point per distinct coin and no extra point for repeated contact. Disable only the component and confirm its trigger guard blocks progress, then re-enable under the chosen policy. Hint: check the Physics layer matrix, trigger flags, tag spelling and input backend one boundary at a time. Run separate win and timeout sessions, repeat in a development Player, and document any desired boundary-tie policy instead of inferring it from one successful run.

Review the complete gameplay boundary

Review the capstone as a chain of evidence: deterministic model transitions, correct adapter lifetimes, correctly wired scene components and a representative Player run. The console's 3, Won, 4.0 trace is useful rule evidence, while the Unity example starts with fifteen seconds and requires real engine execution. Check that terminal reporting happens once, movement stops at the terminal outcome and duplicate contacts cannot increase score. Keep runtime instance identity out of saves, preserve an explicit disabled-trigger guard, and state the timing policy before extending the scene into a larger game loop.

  • Configure the actual Input System, tags, colliders and Physics layers.
  • Guard disabled-behaviour trigger callbacks before touching run state.
  • Use distinct collectible identities and terminal-state rules.
  • Test win, timeout, repeated contact and intended Player behavior.
Runs in Unity 6 Editor

Unity Editor example

Set up in Unity 6

  1. Install com.unity.inputsystem for Unity6.0 and enable Input System Package (New) or Both, applying any requested restart.
  2. Create a Player sphere with a nontrigger SphereCollider and Rigidbody; attach CoinRunLab.cs and set Rigidbody Linear Damping to a modest value such as 4 for this demo.
  3. Create the Collectible tag, then place three separate single-collider trigger spheres tagged Collectible at reachable X/Z positions near the player.
  4. Keep the Player and coins at the same Y, configure a camera to see the area, and verify the Physics layer matrix permits these trigger interactions.
  5. Enter Play mode, focus Game view and use WASD; collect each coin once before fifteen scaled seconds expire.
  6. Run separate win, timeout and repeated-contact checks, then repeat in a development Player; do not save GetInstanceID values as durable IDs.
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(Rigidbody), typeof(SphereCollider))]
public sealed class CoinRunLab : MonoBehaviour
{
    [SerializeField] private float acceleration = 4f;
    private Rigidbody body;
    private InputAction move;
    private Vector2 input;
    private RunState state;
    private bool reported;
    private void Awake()
    {
        body = GetComponent<Rigidbody>();
        body.useGravity = false;
        body.constraints = RigidbodyConstraints.FreezePositionY | RigidbodyConstraints.FreezeRotation;
        state = new RunState(3, 15.0);
        move = new InputAction("Move", InputActionType.Value);
        move.AddCompositeBinding("2DVector").With("Up", "<Keyboard>/w").With("Down", "<Keyboard>/s").With("Left", "<Keyboard>/a").With("Right", "<Keyboard>/d");
    }
    private void OnEnable() { if (move != null) move.Enable(); }
    private void OnDisable() { if (move != null) move.Disable(); }
    private void OnDestroy() { if (move != null) move.Dispose(); }
    private void Update()
    {
        if (state.Phase != RunPhase.Running) return;
        input = move.ReadValue<Vector2>();
        state.Tick(Time.deltaTime);
        ReportOutcome();
    }
    private void FixedUpdate()
    {
        if (state.Phase != RunPhase.Running) return;
        body.AddForce(new Vector3(input.x, 0f, input.y) * acceleration, ForceMode.Acceleration);
    }
    private void OnTriggerEnter(Collider other)
    {
        if (!isActiveAndEnabled || state == null) return;
        if (!other.CompareTag("Collectible") || !state.Collect(other.gameObject.GetInstanceID())) return;
        other.gameObject.SetActive(false);
        Debug.Log("Score: " + state.Score + "/3", this);
        ReportOutcome();
    }
    private void ReportOutcome()
    {
        if (reported || state.Phase == RunPhase.Running) return;
        reported = true;
        body.linearVelocity = Vector3.zero;
        body.angularVelocity = Vector3.zero;
        Debug.Log("Run: " + state.Phase, this);
    }
    public enum RunPhase { Running, Won, Lost }
    public sealed class RunState
    {
        private readonly HashSet<int> collected = new HashSet<int>();
        private readonly int target;
        public int Score { get; private set; }
        public double Remaining { get; private set; }
        public RunPhase Phase { get; private set; }
        public RunState(int target, double seconds) { if (target < 1 || seconds <= 0 || double.IsNaN(seconds) || double.IsInfinity(seconds)) throw new ArgumentOutOfRangeException(); this.target = target; Remaining = seconds; }
        public bool Collect(int id) { if (Phase != RunPhase.Running || !collected.Add(id)) return false; Score++; if (Score >= target) Phase = RunPhase.Won; return true; }
        public void Tick(double seconds) { if (seconds < 0 || double.IsNaN(seconds) || double.IsInfinity(seconds)) throw new ArgumentOutOfRangeException(); if (Phase != RunPhase.Running) return; Remaining = Math.Max(0, Remaining - seconds); if (Remaining == 0) Phase = RunPhase.Lost; }
    }
}

What to observe in Unity

The configured scene permits WASD movement, logs one score per distinct demo collectible, and reports Won at three or Lost when time expires. Only the portable model is executed inside SharpRune.

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 run = new RunModel(3, 5); run.Collect(1); run.Collect(1); run.Tick(1); run.Collect(2); run.Collect(3);
Console.WriteLine(run.Score); Console.WriteLine(run.Phase); Console.WriteLine(run.Remaining.ToString("0.0", CultureInfo.InvariantCulture));
enum Phase { Running, Won, Lost }
sealed class RunModel {
    private readonly HashSet<int> collected = new(); private readonly int target;
    public int Score { get; private set; } public double Remaining { get; private set; } public Phase Phase { get; private set; }
    public RunModel(int target, double seconds) { if (target < 1 || seconds <= 0 || double.IsNaN(seconds) || double.IsInfinity(seconds)) throw new ArgumentOutOfRangeException(); this.target = target; Remaining = seconds; }
    public bool Collect(int id) { if (Phase != Phase.Running || !collected.Add(id)) return false; Score++; if (Score >= target) Phase = Phase.Won; return true; }
    public void Tick(double seconds) { if (seconds < 0 || double.IsNaN(seconds) || double.IsInfinity(seconds)) throw new ArgumentOutOfRangeException(); if (Phase != Phase.Running) return; Remaining = Math.Max(0, Remaining - seconds); if (Remaining == 0) Phase = Phase.Lost; }
}
Open C# practice

C# practice output

3
Won
4.0
Try this
The deterministic lab tests domain transitions; actual input and trigger behavior require the configured Unity scene.

Further reading