Building a Modular Task System in Unity: Checkpoints, Collectibles, and Reset Mechanics

Imagine designing a game where every mistake feels like a learning opportunity rather than a total setback. Picture your player moving through a level, collecting treasures, and reaching checkpoints that save their progress. But if they slip up and hit a boundary, only the current challenge resets—leaving their previous accomplishments intact. In this blog post, I'll show you how to build such a system in Unity using principles of modular design, OOP, SOLID, and MVC. We'll also integrate a dynamic scoring system that rewards your players for their careful collection of items, and "banks" that score when they reach a checkpoint.
This guide is designed to be both informative and fun, walking you through each component with clear code examples and real-world insights.
The Big Picture

At its heart, our game system is divided into several self-contained tasks (or levels). Each task involves:
Collectibles: Items scattered around that the player can pick up.
Checkpoints: Safe points that, when reached, secure the player’s progress.
Boundaries: Hazards that, if touched, reset only the current task.
When the player collects an item, their task score increases. Reaching a checkpoint "banks" that score, adding it to their total, and resetting the task score for the next challenge. But if they hit a boundary before reaching a checkpoint, the current task resets—losing the score from that task and restoring any collectibles they might have missed.
Diving into the Code
Let's break down our system into its core components. Each part plays an important role in making the game feel responsive and fair.
1. The Service Locator
To keep our system loosely coupled, we use a simple service locator. This little helper lets different parts of our game talk to each other without being tightly bound to one another.
// ServiceLocator.cs
using System;
using System.Collections.Generic;
public static class ServiceLocator
{
private static readonly Dictionary<Type, object> _services = new Dictionary<Type, object>();
public static void Register<T>(T service)
{
_services[typeof(T)] = service;
}
public static T Get<T>()
{
if (_services.TryGetValue(typeof(T), out object service))
return (T)service;
throw new Exception("Service not registered: " + typeof(T));
}
public static void Clear()
{
_services.Clear();
}
}
This code snippet allows you to register services like the task manager, which then can be retrieved by any component that needs them.
2. Task Management: The Task Service and Generic Task
The TaskService orchestrates the flow of tasks. It knows what the current task is and handles transitions, whether the player completes a task or fails by hitting a boundary.
// TaskService.cs
using System.Collections.Generic;
using UnityEngine;
public interface ITaskService
{
ITask CurrentTask { get; }
void CompleteCurrentTask();
void ResetCurrentTask();
}
public class TaskService : MonoBehaviour, ITaskService
{
[SerializeField, Tooltip("Assign tasks in the order they should appear in the scene.")]
private List<BaseTask> tasks = new List<BaseTask>();
private int currentTaskIndex = 0;
public ITask CurrentTask => tasks[currentTaskIndex];
private void Start()
{
ServiceLocator.Register<ITaskService>(this);
if (tasks.Count > 0)
{
Debug.Log("Initializing first task.");
tasks[currentTaskIndex].Initialize();
}
else
{
Debug.LogError("No tasks assigned in the TaskService!");
}
}
public void CompleteCurrentTask()
{
Debug.Log("Completing task at index: " + currentTaskIndex);
tasks[currentTaskIndex].Complete();
currentTaskIndex++;
if (currentTaskIndex < tasks.Count)
{
Debug.Log("Initializing next task at index: " + currentTaskIndex);
tasks[currentTaskIndex].Initialize();
}
else
{
Debug.Log("All tasks completed!");
}
}
public void ResetCurrentTask()
{
if (currentTaskIndex < tasks.Count)
{
Debug.Log("Resetting task at index: " + currentTaskIndex);
tasks[currentTaskIndex].Reset();
}
else
{
Debug.LogWarning("No current task to reset. All tasks may be complete.");
}
}
}
using UnityEngine;
public abstract class BaseTask : MonoBehaviour, ITask
{
protected bool isCompleted = false;
public virtual bool IsCompleted => isCompleted;
/// <summary>
/// Called to initialize or restart the task
/// </summary>
public virtual void Initialize()
{
isCompleted = false;
// Reset collectibles, reposition objects, etc.
Debug.Log($"{this.GetType().Name} Initialized.");
}
/// <summary>
/// Resets the current task.
/// </summary>
public virtual void Reset()
{
Debug.Log($"{this.GetType().Name} Reset.");
Initialize();
}
/// <summary>
/// Called when the player completes the task.
/// </summary>
public virtual void Complete()
{
isCompleted = true;
Debug.Log($"{this.GetType().Name} Completed.");
}
}
Our GenericTask is a concrete implementation that manages task-specific elements—like resetting collectibles.
// GenericTask.cs
using UnityEngine;
public class GenericTask : BaseTask
{
[Header("Task Configuration")]
[SerializeField] private int taskNumber = 1;
[SerializeField] private int numberOfCollectibles = 0;
[Header("Resettable Objects (Collectibles, etc.)")]
[Tooltip("Assign objects that should be reset when the task resets.")]
[SerializeField] private ResettableObject[] resettableObjects;
public override void Initialize()
{
base.Initialize();
Debug.Log($"[GenericTask] Initializing Task {taskNumber} with {numberOfCollectibles} collectibles.");
foreach (var obj in resettableObjects)
{
if (obj != null)
obj.ResetState();
}
}
public override void Reset()
{
base.Reset();
Debug.Log($"[GenericTask] Resetting Task {taskNumber}.");
foreach (var obj in resettableObjects)
{
if (obj != null)
obj.ResetState();
}
}
public override void Complete()
{
base.Complete();
Debug.Log($"[GenericTask] Completed Task {taskNumber}.");
}
}
This setup ensures that every task is modular—if you add another level, you just create another GenericTask instance and assign its collectibles.
3. Resettable Collectibles
Instead of destroying collectibles when collected, we disable them and later re-enable them if the player resets the task. This way, players have a chance to collect them again.
// ResettableObject.cs
using UnityEngine;
public class ResettableObject : MonoBehaviour
{
private Vector3 initialPosition;
private Quaternion initialRotation;
private bool isCollected;
private void Awake()
{
initialPosition = transform.position;
initialRotation = transform.rotation;
isCollected = false;
}
public void Collect()
{
if (!isCollected)
{
isCollected = true;
gameObject.SetActive(false);
Debug.Log($"{gameObject.name} collected.");
ScoreManager.Instance?.AddCollectible();
}
}
public void ResetState()
{
isCollected = false;
gameObject.SetActive(true);
transform.position = initialPosition;
transform.rotation = initialRotation;
Debug.Log($"{gameObject.name} reset to {initialPosition}");
}
}
4. Player Position Reset: The PlayerResetManager
Our PlayerResetManager tracks where the player started and the most recent checkpoint. If the player fails, they return to the last safe spot.
// PlayerResetManager.cs
using UnityEngine;
public class PlayerResetManager : MonoBehaviour
{
private Vector3 initialPosition;
private Vector3 lastCheckpointPosition;
private bool hasCheckpoint = false;
private void Start()
{
initialPosition = transform.position;
lastCheckpointPosition = initialPosition;
}
public void UpdateCheckpoint(Vector3 checkpointPosition)
{
lastCheckpointPosition = checkpointPosition;
hasCheckpoint = true;
Debug.Log("Updated checkpoint to: " + lastCheckpointPosition);
}
public void ResetPlayerPosition()
{
Vector3 resetPosition = hasCheckpoint ? lastCheckpointPosition : initialPosition;
CharacterController cc = GetComponent<CharacterController>();
if (cc != null)
{
cc.enabled = false;
transform.position = resetPosition;
cc.enabled = true;
}
else
{
transform.position = resetPosition;
}
Debug.Log("Player position reset to: " + transform.position);
}
}
5. Player Controller: Handling Input, Triggers, and Score
Our PlayerController brings it all together by handling movement and interactions. It communicates with our task system, resets the player when needed, and interacts with our scoring system.
// PlayerController.cs
using UnityEngine;
[RequireComponent(typeof(CharacterController))]
public class PlayerController : MonoBehaviour
{
public float speed = 5f;
private CharacterController characterController;
// Cooldown to prevent duplicate checkpoint triggers
private float checkpointCooldown = 1.0f;
private float lastCheckpointTime = -Mathf.Infinity;
private void Awake()
{
characterController = GetComponent<CharacterController>();
}
private void Update()
{
float horizontal = Input.GetAxis("Horizontal");
float vertical = Input.GetAxis("Vertical");
Vector3 move = new Vector3(horizontal, 0, vertical);
characterController.SimpleMove(move * speed);
}
private void OnTriggerEnter(Collider other)
{
Debug.Log("Player collided with: " + other.tag);
if (other.CompareTag("Boundary"))
{
Debug.Log("Player hit a Boundary. Initiating task reset.");
ServiceLocator.Get<ITaskService>().ResetCurrentTask();
PlayerResetManager prm = GetComponent<PlayerResetManager>();
if (prm != null)
prm.ResetPlayerPosition();
ScoreManager.Instance?.ResetTaskScore();
}
else if (other.CompareTag("Checkpoint"))
{
if (Time.time - lastCheckpointTime < checkpointCooldown)
{
Debug.Log("Checkpoint trigger ignored due to cooldown.");
return;
}
lastCheckpointTime = Time.time;
Debug.Log("Player reached a Checkpoint. Completing task.");
PlayerResetManager prm = GetComponent<PlayerResetManager>();
if (prm != null)
prm.UpdateCheckpoint(other.transform.position);
ServiceLocator.Get<ITaskService>().CompleteCurrentTask();
ScoreManager.Instance?.BankTaskScore();
}
else if (other.CompareTag("Collectible"))
{
Debug.Log("Player collected a collectible.");
ResettableObject resettable = other.GetComponent<ResettableObject>();
if (resettable != null)
resettable.Collect();
else
Destroy(other.gameObject);
}
}
}
6. Score Management: The ScoreManager
Finally, our ScoreManager tracks the current task score and the total score, updating the UI as the player collects items and banks their progress.
// ScoreManager.cs
using UnityEngine;
using UnityEngine.UI;
public class ScoreManager : MonoBehaviour
{
public static ScoreManager Instance;
[Header("UI Reference")]
public Text scoreText; // Assign your UI Text element in the Inspector
private int totalScore = 0;
private int currentTaskScore = 0;
private void Awake()
{
if (Instance == null)
Instance = this;
else
Destroy(gameObject);
UpdateScoreUI();
}
public void AddCollectible()
{
currentTaskScore++;
UpdateScoreUI();
}
public void BankTaskScore()
{
totalScore += currentTaskScore;
currentTaskScore = 0;
UpdateScoreUI();
}
public void ResetTaskScore()
{
currentTaskScore = 0;
UpdateScoreUI();
}
private void UpdateScoreUI()
{
int displayScore = totalScore + currentTaskScore;
scoreText.text = "Score: " + displayScore.ToString();
}
}
Bringing It All Together
Scene Setup
UI:
- Create a Canvas with a UI Text element for the score display and assign it to the ScoreManager.
Task Service:
- Create a GameManager GameObject and attach the TaskService. Populate its task list with your task GameObjects (each having the GenericTask component).
Player:
- Set up your Player GameObject with the PlayerController, PlayerResetManager, and CharacterController components.
Tags and Colliders:
- Ensure that all Boundary, Checkpoint, and Collectible objects have appropriate tags and colliders (set as triggers).
Gameplay Flow
Collecting Items:
As the player picks up collectibles, the ScoreManager increases the current task score and updates the UI.Reaching a Checkpoint:
When the player reaches a checkpoint, the current task score is banked into the total score, and the next task begins. The player's checkpoint is updated to ensure they reset to the latest safe spot.Resetting a Task:
If the player hits a boundary, the current task resets. Any collected items are restored, and the task score is lost.
Conclusion
By following this modular approach, you've built a game system in Unity that feels responsive, rewarding, and fair. Your players can collect treasures, safely bank their progress at checkpoints, and even learn from mistakes without losing all their achievements. Whether you're a seasoned developer or just starting out, this setup is flexible enough to integrate into any project and customizable to fit your game's unique style.
Happy coding, and may your game levels always bring both challenge and delight!
Feel free to share your thoughts or ask questions in the comments below.





