Collectibles & Power-ups
Build a coin collection system with object pooling, create pattern-based coin layouts, and implement three distinct power-ups that transform gameplay.
What Makes Collectibles Fun?
Think about Subway Surfers, Temple Run, or any endless runner you have played. Coins are everywhere — they draw lines through the air, form arcs over obstacles, and create irresistible paths that pull you toward danger and reward simultaneously. Collectibles serve three critical purposes in an infinite runner:
- Guidance: Coin trails silently teach the player where to go, forming a visual breadcrumb trail through the world.
- Risk-Reward: Placing coins near obstacles forces the player to choose between safety and greed.
- Progression: Coins give the player a secondary goal beyond simply surviving, and they feel rewarding to collect.
Power-ups layer on top of this by temporarily changing the rules of the game. A magnet makes coins fly toward you. A shield lets you survive a hit. A score multiplier rewards skilled play. These moments of empowerment keep the game fresh across long play sessions.
In this chapter, we will build all of these systems from scratch, tying them into the object pooling and chunk systems we already have.
Coin System Design
Before writing any code, let us think about how coins will work in our game. Our design must answer several questions:
- Where do coins come from? Each world chunk has predefined spawn points for collectibles. When a chunk is activated, a CoinSpawner places coins at those points.
- How are coins managed? We use the object pool from Chapter 12. Coins are never instantiated or destroyed at runtime — they are borrowed from and returned to the pool.
- What happens when a player collects a coin? The coin fires a collection event, plays a visual/audio effect, and returns itself to the pool.
- How do coin patterns work? We define pattern templates (lines, arcs, zigzags) that are placed along the chunk's collectible spawn points.
Remember from Chapter 12 that our IPoolable interface requires OnSpawnFromPool() and OnReturnToPool() methods. Every coin implements this interface so the pool can initialize and reset it properly.
The IPoolable Interface
Here is the IPoolable interface we defined in Chapter 12. We will reference it throughout this chapter, so here it is again for convenience:
namespace InfiniteRunner.Core
{
/// <summary>
/// Interface for objects that can be managed by the ObjectPool.
/// Any pooled object must implement these two lifecycle methods.
/// </summary>
public interface IPoolable
{
/// <summary>
/// Called when this object is taken from the pool and placed in the world.
/// Use this to reset state, enable visuals, and start behavior.
/// </summary>
void OnSpawnFromPool();
/// <summary>
/// Called when this object is returned to the pool.
/// Use this to disable visuals, stop coroutines, and clean up.
/// </summary>
void OnReturnToPool();
}
}Building the Coin Script
The Coin class is the heart of our collectible system. It handles the spinning animation that makes coins visually appealing, detects when the player touches it, fires a collection event, and returns itself to the object pool. Let us build it step by step.
Step 1: Create the Script
- In your Project window, navigate to
Assets/Scripts/Gameplay. - Right-click and select Create > C# Script.
- Name it
Coin. - Double-click to open it in your code editor.
Step 2: Write the Full Coin Script
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Represents a single collectible coin in the game world.
/// Coins spin to attract the player's attention, detect collection
/// via trigger colliders, and return themselves to the object pool
/// when collected or when the chunk they belong to is recycled.
/// </summary>
[RequireComponent(typeof(Collider))]
public class Coin : MonoBehaviour, IPoolable
{
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Spin Animation")]
[Tooltip("How fast the coin rotates around its Y-axis in degrees per second.")]
[SerializeField] private float spinSpeed = 180f;
[Header("Hover Animation")]
[Tooltip("How far up and down the coin bobs (in Unity units).")]
[SerializeField] private float hoverAmplitude = 0.15f;
[Tooltip("How fast the coin bobs up and down (cycles per second).")]
[SerializeField] private float hoverFrequency = 2f;
[Header("Collection")]
[Tooltip("How many base points this coin is worth.")]
[SerializeField] private int coinValue = 1;
[Tooltip("Tag that identifies the player GameObject.")]
[SerializeField] private string playerTag = "Player";
// ---------------------------------------------------------------
// Private State
// ---------------------------------------------------------------
// The Y position where this coin was originally placed.
// We use this as the center point for the hover animation.
private float baseYPosition;
// Tracks how far along the hover sine wave we are.
// Randomized on spawn so not all coins bob in sync.
private float hoverTimer;
// Whether this coin is currently active and collectible.
// Prevents double-collection if two triggers overlap.
private bool isActive;
// Cached reference to our visual child (the mesh/sprite).
// We disable this on collection for an instant visual response.
private GameObject visual;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void Awake()
{
// Cache the visual child. We assume the first child is the
// coin's visual representation (mesh or sprite).
if (transform.childCount > 0)
{
visual = transform.GetChild(0).gameObject;
}
// Make sure our collider is set to trigger mode.
// Triggers detect overlap without causing physics collisions.
Collider col = GetComponent<Collider>();
if (col != null)
{
col.isTrigger = true;
}
}
private void Update()
{
// Only animate while the coin is active.
if (!isActive) return;
// --- Spin Animation ---
// Rotate around the Y-axis (up) each frame.
// Time.deltaTime ensures smooth, frame-rate-independent rotation.
transform.Rotate(Vector3.up, spinSpeed * Time.deltaTime, Space.Self);
// --- Hover Animation ---
// Advance the hover timer by the elapsed frame time.
hoverTimer += Time.deltaTime;
// Calculate a new Y position using a sine wave.
// Sin oscillates between -1 and 1, so multiplying by amplitude
// gives us smooth up-and-down motion centered on baseYPosition.
float newY = baseYPosition
+ Mathf.Sin(hoverTimer * hoverFrequency * Mathf.PI * 2f)
* hoverAmplitude;
// Apply the new position, keeping X and Z unchanged.
Vector3 pos = transform.position;
pos.y = newY;
transform.position = pos;
}
// ---------------------------------------------------------------
// Trigger Detection
// ---------------------------------------------------------------
/// <summary>
/// Called by Unity's physics system when another collider enters
/// our trigger volume. We check if it is the player and, if so,
/// collect this coin.
/// </summary>
private void OnTriggerEnter(Collider other)
{
// Ignore if we have already been collected.
if (!isActive) return;
// Only the player can collect coins.
if (!other.CompareTag(playerTag)) return;
Collect();
}
// ---------------------------------------------------------------
// Collection Logic
// ---------------------------------------------------------------
/// <summary>
/// Handles the collection of this coin. Fires an event so other
/// systems (scoring, audio, particles) can react, then returns
/// this coin to the object pool.
/// </summary>
private void Collect()
{
// Mark as inactive to prevent double-collection.
isActive = false;
// Fire the coin collected event so the ScoreManager,
// AudioManager, and particle systems can respond.
GameEvents.OnCoinCollected?.Invoke(coinValue);
// Hide the visual immediately for instant feedback.
// The actual pool return can happen a frame later if needed.
if (visual != null)
{
visual.SetActive(false);
}
// Return this coin to the object pool.
// The pool will deactivate the entire GameObject.
ObjectPool.Instance.Return(gameObject);
}
// ---------------------------------------------------------------
// IPoolable Implementation
// ---------------------------------------------------------------
/// <summary>
/// Called when this coin is spawned from the pool.
/// Resets all state so it behaves like a fresh coin.
/// </summary>
public void OnSpawnFromPool()
{
// Mark as active and collectible.
isActive = true;
// Show the visual.
if (visual != null)
{
visual.SetActive(true);
}
// Record the starting Y position for the hover animation.
baseYPosition = transform.position.y;
// Randomize the hover timer so coins placed in a line
// do not all bob in perfect sync. This looks more natural.
hoverTimer = Random.Range(0f, 2f);
// Reset rotation so every coin starts facing the same way.
transform.rotation = Quaternion.identity;
}
/// <summary>
/// Called when this coin is returned to the pool.
/// Cleans up state and stops animations.
/// </summary>
public void OnReturnToPool()
{
isActive = false;
if (visual != null)
{
visual.SetActive(false);
}
}
// ---------------------------------------------------------------
// Public API
// ---------------------------------------------------------------
/// <summary>
/// Returns the point value of this coin.
/// Used by external systems that need to know coin worth.
/// </summary>
public int GetValue()
{
return coinValue;
}
}
}If you place 10 coins in a line and they all start their sine wave at the same time, they bob up and down in perfect unison. This looks robotic and unnatural. By giving each coin a random starting offset for hoverTimer, they all hover at slightly different phases, creating a much more organic, wave-like visual.
Step 3: Set Up the Coin Prefab in Unity
- In the Hierarchy, create an empty GameObject and name it
Coin. - Add a Sphere Collider component. Set its Radius to
0.5and check Is Trigger. - Create a child object: right-click on Coin > 3D Object > Cylinder. This will be our coin visual.
- Scale the cylinder to
(0.5, 0.05, 0.5)to flatten it into a disc shape. - Create a gold-colored material and assign it to the cylinder.
- Add the
Coinscript to the parentCoinGameObject. - Drag the entire
Coinobject from the Hierarchy into yourAssets/Prefabs/Collectiblesfolder to create the prefab. - Delete the instance from the scene — the pool will manage spawning.
The coin uses CompareTag("Player") to detect collection. Make sure your player GameObject has the Player tag assigned in the Inspector. You can set this at the top of the Inspector when you select the player.
Coin Patterns
Placing coins randomly looks messy and does not guide the player. Professional runners use patterns — predefined arrangements of coins that form recognizable shapes. These patterns serve as visual language: a line of coins says "go straight," an arc says "jump here," and a zigzag says "weave between lanes."
Defining Pattern Types
We start by defining an enum for the different pattern shapes we support:
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Defines the different arrangements coins can be placed in.
/// Each pattern creates a distinct visual shape that guides the player.
/// </summary>
public enum CoinPatternType
{
/// <summary>A straight line of coins along a single lane.</summary>
Line,
/// <summary>An arc of coins that rises and falls, suggesting a jump.</summary>
Arc,
/// <summary>Coins that alternate between two lanes, forming a zigzag.</summary>
Zigzag,
/// <summary>A cluster of coins grouped tightly together.</summary>
Cluster,
/// <summary>No coins for this section (used for spacing).</summary>
None
}
}Pattern Data Structure
Each pattern is described by a ScriptableObject that defines exactly where coins go relative to a starting point. This lets designers create and tweak patterns in the Unity Inspector without touching code.
using UnityEngine;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// ScriptableObject that defines a coin placement pattern.
/// Each pattern stores an array of local offsets where coins
/// should be placed relative to the pattern's origin point.
/// </summary>
[CreateAssetMenu(
fileName = "NewCoinPattern",
menuName = "Infinite Runner/Coin Pattern"
)]
public class CoinPatternData : ScriptableObject
{
[Tooltip("Human-readable name for this pattern.")]
public string patternName = "New Pattern";
[Tooltip("The type of pattern for categorization.")]
public CoinPatternType patternType = CoinPatternType.Line;
[Tooltip("Local position offsets for each coin in this pattern. " +
"X = lane offset, Y = height, Z = forward distance.")]
public Vector3[] coinOffsets;
[Tooltip("How likely this pattern is to be selected (higher = more common).")]
[Range(0f, 1f)]
public float spawnWeight = 0.5f;
[Tooltip("Minimum difficulty level before this pattern can appear.")]
[Range(0f, 1f)]
public float minDifficulty = 0f;
}
}To create a line of 5 coins: set coinOffsets to have 5 entries with values like (0, 1, 0), (0, 1, 2), (0, 1, 4), (0, 1, 6), (0, 1, 8). The X value of 0 means center lane, Y of 1 puts them at collectible height, and Z values space them 2 units apart going forward.
Example Patterns
Here are the offset arrays for each built-in pattern type. You will enter these values in the Unity Inspector when creating your ScriptableObject assets:
Line Pattern (5 coins, straight ahead):
// Coins spaced 2 units apart along the Z-axis (forward)
// All at Y=1 (comfortable collection height)
coinOffsets = new Vector3[]
{
new Vector3(0f, 1f, 0f),
new Vector3(0f, 1f, 2f),
new Vector3(0f, 1f, 4f),
new Vector3(0f, 1f, 6f),
new Vector3(0f, 1f, 8f)
};Arc Pattern (7 coins, rises and falls):
// Coins form an arch shape - great for placing over obstacles
// Y values rise to a peak at the center, then fall back down
coinOffsets = new Vector3[]
{
new Vector3(0f, 1.0f, 0f),
new Vector3(0f, 1.5f, 1.5f),
new Vector3(0f, 2.2f, 3f),
new Vector3(0f, 2.8f, 4.5f), // Peak of the arc
new Vector3(0f, 2.2f, 6f),
new Vector3(0f, 1.5f, 7.5f),
new Vector3(0f, 1.0f, 9f)
};Zigzag Pattern (6 coins, alternates lanes):
// Coins alternate left and right on the X-axis
// laneWidth is typically 2.0 units in our game
coinOffsets = new Vector3[]
{
new Vector3(-2f, 1f, 0f), // Left lane
new Vector3( 0f, 1f, 2f), // Center lane
new Vector3( 2f, 1f, 4f), // Right lane
new Vector3( 0f, 1f, 6f), // Center lane
new Vector3(-2f, 1f, 8f), // Left lane
new Vector3( 0f, 1f, 10f) // Center lane
};The Coin Spawner
The CoinSpawner is responsible for placing coins on each chunk when it is activated. It selects a pattern, pulls coins from the object pool, and positions them at the chunk's collectible spawn points.
using System.Collections.Generic;
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Places coins on world chunks using predefined patterns.
/// Works with the ObjectPool to borrow and return coin instances.
///
/// Attach this to the same GameObject as the WorldGenerator,
/// or any persistent manager object.
/// </summary>
public class CoinSpawner : MonoBehaviour
{
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Coin Prefab")]
[Tooltip("The coin prefab registered with the ObjectPool.")]
[SerializeField] private GameObject coinPrefab;
[Header("Patterns")]
[Tooltip("All available coin patterns. The spawner will randomly " +
"select from these based on weight and difficulty.")]
[SerializeField] private CoinPatternData[] availablePatterns;
[Header("Spawn Settings")]
[Tooltip("Probability (0-1) that a chunk will have coins at all.")]
[Range(0f, 1f)]
[SerializeField] private float spawnChance = 0.7f;
[Tooltip("Maximum number of coins that can be active at once. " +
"Prevents performance issues from too many coins.")]
[SerializeField] private int maxActiveCoins = 50;
// ---------------------------------------------------------------
// Private State
// ---------------------------------------------------------------
// Tracks all currently active coins so we can return them
// when their parent chunk is recycled.
private List<GameObject> activeCoins = new List<GameObject>();
// Reference to the current difficulty level (0 to 1).
// Updated by the DifficultyManager.
private float currentDifficulty = 0f;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void OnEnable()
{
// Listen for chunk activation events from the WorldGenerator.
GameEvents.OnChunkActivated += HandleChunkActivated;
// Listen for chunk deactivation so we can reclaim coins.
GameEvents.OnChunkDeactivated += HandleChunkDeactivated;
// Listen for difficulty changes to filter patterns.
GameEvents.OnDifficultyChanged += HandleDifficultyChanged;
}
private void OnDisable()
{
GameEvents.OnChunkActivated -= HandleChunkActivated;
GameEvents.OnChunkDeactivated -= HandleChunkDeactivated;
GameEvents.OnDifficultyChanged -= HandleDifficultyChanged;
}
// ---------------------------------------------------------------
// Event Handlers
// ---------------------------------------------------------------
/// <summary>
/// Called when a new chunk enters the world.
/// Decides whether to spawn coins and, if so, which pattern to use.
/// </summary>
/// <param name="chunkTransform">The transform of the activated chunk.</param>
/// <param name="spawnPoints">Collectible spawn points on the chunk.</param>
private void HandleChunkActivated(
Transform chunkTransform,
Transform[] spawnPoints)
{
// Roll the dice - should this chunk have coins?
if (Random.value > spawnChance) return;
// Don't exceed our coin budget.
if (activeCoins.Count >= maxActiveCoins) return;
// No spawn points? Nothing to do.
if (spawnPoints == null || spawnPoints.Length == 0) return;
// Pick a pattern that matches the current difficulty.
CoinPatternData pattern = SelectPattern();
if (pattern == null) return;
// Place coins at the spawn point using the chosen pattern.
// We use the first spawn point as the pattern origin.
SpawnPattern(pattern, spawnPoints[0].position);
}
/// <summary>
/// Called when a chunk is recycled. Returns all coins that
/// were on that chunk back to the object pool.
/// </summary>
private void HandleChunkDeactivated(Transform chunkTransform)
{
// We need to return coins that are children of this chunk
// or within its bounds. For simplicity, we track coins by
// their parent association.
ReturnCoinsForChunk(chunkTransform);
}
/// <summary>
/// Updates our local difficulty reference.
/// </summary>
private void HandleDifficultyChanged(float newDifficulty)
{
currentDifficulty = newDifficulty;
}
// ---------------------------------------------------------------
// Pattern Selection
// ---------------------------------------------------------------
/// <summary>
/// Selects a coin pattern based on spawn weights and current
/// difficulty. Patterns with higher weights are more likely
/// to be chosen. Patterns with a minDifficulty above the
/// current level are excluded.
/// </summary>
/// <returns>A suitable pattern, or null if none qualify.</returns>
private CoinPatternData SelectPattern()
{
// Build a list of eligible patterns.
float totalWeight = 0f;
List<CoinPatternData> eligible = new List<CoinPatternData>();
for (int i = 0; i < availablePatterns.Length; i++)
{
CoinPatternData pattern = availablePatterns[i];
// Skip patterns that require higher difficulty than current.
if (pattern.minDifficulty > currentDifficulty) continue;
eligible.Add(pattern);
totalWeight += pattern.spawnWeight;
}
if (eligible.Count == 0) return null;
// Weighted random selection.
float roll = Random.Range(0f, totalWeight);
float cumulative = 0f;
for (int i = 0; i < eligible.Count; i++)
{
cumulative += eligible[i].spawnWeight;
if (roll <= cumulative)
{
return eligible[i];
}
}
// Fallback (should not reach here, but just in case).
return eligible[eligible.Count - 1];
}
// ---------------------------------------------------------------
// Spawning
// ---------------------------------------------------------------
/// <summary>
/// Spawns coins in the world according to the given pattern,
/// anchored at the specified origin position.
/// </summary>
private void SpawnPattern(CoinPatternData pattern, Vector3 origin)
{
for (int i = 0; i < pattern.coinOffsets.Length; i++)
{
// Don't exceed our coin budget mid-pattern.
if (activeCoins.Count >= maxActiveCoins) break;
// Calculate world position from pattern offset.
Vector3 worldPos = origin + pattern.coinOffsets[i];
// Get a coin from the pool.
GameObject coin = ObjectPool.Instance.Get(coinPrefab);
if (coin == null) continue;
// Position the coin in the world.
coin.transform.position = worldPos;
// Initialize the coin via IPoolable.
IPoolable poolable = coin.GetComponent<IPoolable>();
poolable?.OnSpawnFromPool();
// Track this coin.
activeCoins.Add(coin);
}
}
/// <summary>
/// Returns all coins associated with a deactivated chunk
/// back to the object pool.
/// </summary>
private void ReturnCoinsForChunk(Transform chunkTransform)
{
// Get the chunk's Z range to determine which coins belong to it.
float chunkStartZ = chunkTransform.position.z;
float chunkLength = 50f; // Should match your chunk length.
float chunkEndZ = chunkStartZ + chunkLength;
for (int i = activeCoins.Count - 1; i >= 0; i--)
{
if (activeCoins[i] == null)
{
activeCoins.RemoveAt(i);
continue;
}
float coinZ = activeCoins[i].transform.position.z;
// If this coin is within the chunk's Z range, return it.
if (coinZ >= chunkStartZ && coinZ <= chunkEndZ)
{
IPoolable poolable =
activeCoins[i].GetComponent<IPoolable>();
poolable?.OnReturnToPool();
ObjectPool.Instance.Return(activeCoins[i]);
activeCoins.RemoveAt(i);
}
}
}
// ---------------------------------------------------------------
// Public API
// ---------------------------------------------------------------
/// <summary>
/// Returns all active coins to the pool. Called on game restart.
/// </summary>
public void ReturnAllCoins()
{
for (int i = activeCoins.Count - 1; i >= 0; i--)
{
if (activeCoins[i] == null) continue;
IPoolable poolable =
activeCoins[i].GetComponent<IPoolable>();
poolable?.OnReturnToPool();
ObjectPool.Instance.Return(activeCoins[i]);
}
activeCoins.Clear();
}
}
}Notice that in ReturnCoinsForChunk and ReturnAllCoins, we iterate the list backwards (i--). This is a critical pattern in C#: when you remove items from a list while iterating, going backwards prevents index shifting from causing you to skip elements or go out of bounds.
Power-up System Design
Power-ups are temporary abilities that change the rules of the game. They make the player feel powerful for a short time, creating memorable moments. Our system supports three power-ups:
- Magnet: Attracts nearby coins automatically, so you do not need to run directly over them.
- Shield: Absorbs one hit from an obstacle, saving you from death.
- Score Multiplier: Doubles all points earned while active.
All three share common behavior: they can be picked up, they have a duration, and they expire. We use a base class to capture this shared behavior, then create subclasses for the specific effects.
The PowerUp Base Class
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// The type of power-up. Used to identify power-ups throughout
/// the codebase and prevent stacking of the same type.
/// </summary>
public enum PowerUpType
{
Magnet,
Shield,
ScoreMultiplier
}
/// <summary>
/// Base class for all power-up pickups in the game world.
/// Handles the common behavior: trigger detection, pool lifecycle,
/// spinning animation, and event firing.
///
/// Subclasses define the specific effect by overriding
/// GetPowerUpType() and GetDuration().
/// </summary>
[RequireComponent(typeof(Collider))]
public abstract class PowerUp : MonoBehaviour, IPoolable
{
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Pickup Settings")]
[Tooltip("How long the power-up effect lasts in seconds.")]
[SerializeField] protected float duration = 8f;
[Tooltip("How fast the pickup spins in the world.")]
[SerializeField] private float spinSpeed = 120f;
[Tooltip("Tag that identifies the player.")]
[SerializeField] private string playerTag = "Player";
[Header("Visuals")]
[Tooltip("The glow or particle effect shown on the pickup.")]
[SerializeField] private GameObject glowEffect;
// ---------------------------------------------------------------
// Private State
// ---------------------------------------------------------------
private bool isActive;
// ---------------------------------------------------------------
// Abstract Methods (subclasses must implement)
// ---------------------------------------------------------------
/// <summary>
/// Returns the type of this power-up.
/// Each subclass provides its own type.
/// </summary>
public abstract PowerUpType GetPowerUpType();
/// <summary>
/// Returns how long this power-up's effect lasts.
/// </summary>
public virtual float GetDuration()
{
return duration;
}
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void Awake()
{
// Ensure the collider is a trigger.
Collider col = GetComponent<Collider>();
if (col != null)
{
col.isTrigger = true;
}
}
private void Update()
{
if (!isActive) return;
// Spin the pickup to make it visually distinct from coins.
transform.Rotate(Vector3.up, spinSpeed * Time.deltaTime);
}
// ---------------------------------------------------------------
// Trigger Detection
// ---------------------------------------------------------------
private void OnTriggerEnter(Collider other)
{
if (!isActive) return;
if (!other.CompareTag(playerTag)) return;
PickUp();
}
// ---------------------------------------------------------------
// Pickup Logic
// ---------------------------------------------------------------
/// <summary>
/// Called when the player collects this power-up.
/// Fires an event and returns the pickup to the pool.
/// </summary>
private void PickUp()
{
isActive = false;
// Notify the PowerUpManager that a power-up was collected.
// The manager handles activating the effect and managing timers.
GameEvents.OnPowerUpCollected?.Invoke(
GetPowerUpType(),
GetDuration()
);
// Return to pool.
ObjectPool.Instance.Return(gameObject);
}
// ---------------------------------------------------------------
// IPoolable Implementation
// ---------------------------------------------------------------
public void OnSpawnFromPool()
{
isActive = true;
if (glowEffect != null)
{
glowEffect.SetActive(true);
}
transform.rotation = Quaternion.identity;
}
public void OnReturnToPool()
{
isActive = false;
if (glowEffect != null)
{
glowEffect.SetActive(false);
}
}
}
}The abstract keyword means you cannot create a PowerUp directly — you must create a specific subclass like MagnetPowerUp. This enforces the rule that every power-up must define its type and duration. The shared code (spinning, trigger detection, pooling) lives in the base class so we never duplicate it.
The Three Power-ups
Each power-up is a small subclass that identifies its type. The actual effect logic lives in the PowerUpManager (next section), not in the pickup itself. This separation means the pickup is just a trigger — it tells the manager "the player collected a Magnet" and the manager applies the effect.
Magnet Power-up
The Magnet attracts nearby coins within a configurable radius, pulling them toward the player automatically. This makes it incredibly satisfying — coins fly from all lanes toward you.
using UnityEngine;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Magnet power-up pickup. When collected, nearby coins are
/// attracted toward the player for the duration.
/// </summary>
public class MagnetPowerUp : PowerUp
{
[Header("Magnet Settings")]
[Tooltip("The radius within which coins are attracted to the player.")]
[SerializeField] private float attractRadius = 8f;
[Tooltip("How fast coins move toward the player when attracted.")]
[SerializeField] private float attractSpeed = 15f;
/// <summary>
/// Identifies this as a Magnet power-up.
/// </summary>
public override PowerUpType GetPowerUpType()
{
return PowerUpType.Magnet;
}
/// <summary>
/// Returns the attraction radius for the PowerUpManager to use.
/// </summary>
public float GetAttractRadius()
{
return attractRadius;
}
/// <summary>
/// Returns the attraction speed for the PowerUpManager to use.
/// </summary>
public float GetAttractSpeed()
{
return attractSpeed;
}
}
}Shield Power-up
The Shield absorbs one collision with an obstacle, saving the player from death. It shows a visible bubble or glow around the player while active.
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Shield power-up pickup. When collected, the player can
/// survive one obstacle collision without dying.
/// The shield breaks on the first hit and the visual disappears.
/// </summary>
public class ShieldPowerUp : PowerUp
{
/// <summary>
/// Identifies this as a Shield power-up.
/// </summary>
public override PowerUpType GetPowerUpType()
{
return PowerUpType.Shield;
}
}
}Score Multiplier Power-up
The Score Multiplier doubles all points earned (both distance and coin points) for its duration. This encourages skilled play — the better you perform while the multiplier is active, the more you benefit.
using UnityEngine;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Score Multiplier power-up pickup. When collected, all score
/// gains are multiplied for the duration.
/// </summary>
public class ScoreMultiplierPowerUp : PowerUp
{
[Header("Multiplier Settings")]
[Tooltip("The score multiplier applied while this power-up is active.")]
[SerializeField] private int multiplierValue = 2;
/// <summary>
/// Identifies this as a Score Multiplier power-up.
/// </summary>
public override PowerUpType GetPowerUpType()
{
return PowerUpType.ScoreMultiplier;
}
/// <summary>
/// Returns the multiplier value (e.g., 2 for double points).
/// </summary>
public int GetMultiplierValue()
{
return multiplierValue;
}
}
}The Power-up Manager
The PowerUpManager is the brain of the power-up system. It listens for collection events, activates effects, manages timers, handles stacking rules, and fires events when power-ups start or expire. This is where the actual gameplay effects live.
Stacking Rules
What happens if the player picks up a Magnet while one is already active? We have a few options:
- Refresh: Reset the timer back to full duration. (We use this approach.)
- Stack: Add the duration to the remaining time.
- Ignore: Do nothing if the same type is already active.
We choose refresh because it feels rewarding without being overpowered. Different power-up types can be active simultaneously (e.g., Magnet + Shield), but you cannot have two Magnets at once.
using System.Collections.Generic;
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Manages all active power-up effects. Listens for power-up
/// collection events, activates effects, counts down timers,
/// and fires events when power-ups start or expire.
///
/// Attach this to a persistent manager GameObject.
/// </summary>
public class PowerUpManager : MonoBehaviour
{
// ---------------------------------------------------------------
// Singleton
// ---------------------------------------------------------------
public static PowerUpManager Instance { get; private set; }
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Magnet Settings")]
[Tooltip("The radius within which the magnet attracts coins.")]
[SerializeField] private float magnetRadius = 8f;
[Tooltip("How fast coins are pulled toward the player.")]
[SerializeField] private float magnetSpeed = 15f;
[Header("References")]
[Tooltip("Reference to the player's transform for magnet logic.")]
[SerializeField] private Transform playerTransform;
// ---------------------------------------------------------------
// Internal Data Structure
// ---------------------------------------------------------------
/// <summary>
/// Tracks the state of a single active power-up effect.
/// </summary>
private class ActivePowerUp
{
public PowerUpType Type;
public float RemainingTime;
public float TotalDuration;
public ActivePowerUp(PowerUpType type, float duration)
{
Type = type;
RemainingTime = duration;
TotalDuration = duration;
}
/// <summary>
/// Returns 0-1 indicating how much time is left (1 = just started).
/// Used for UI display.
/// </summary>
public float GetNormalizedTimeRemaining()
{
if (TotalDuration <= 0f) return 0f;
return Mathf.Clamp01(RemainingTime / TotalDuration);
}
}
// ---------------------------------------------------------------
// Private State
// ---------------------------------------------------------------
// All currently active power-ups, keyed by type.
// Using a dictionary ensures only one of each type at a time.
private Dictionary<PowerUpType, ActivePowerUp> activePowerUps
= new Dictionary<PowerUpType, ActivePowerUp>();
// Reusable list for collecting expired power-ups to remove.
// Avoids allocating a new list every frame.
private List<PowerUpType> expiredTypes = new List<PowerUpType>();
// The current score multiplier (1 when no multiplier is active).
private int currentMultiplier = 1;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void Awake()
{
// Singleton setup.
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
private void OnEnable()
{
// Listen for power-up collection events.
GameEvents.OnPowerUpCollected += HandlePowerUpCollected;
// Listen for game over to clear all power-ups.
GameEvents.OnGameOver += HandleGameOver;
// Listen for obstacle hits to consume shields.
GameEvents.OnObstacleHit += HandleObstacleHit;
}
private void OnDisable()
{
GameEvents.OnPowerUpCollected -= HandlePowerUpCollected;
GameEvents.OnGameOver -= HandleGameOver;
GameEvents.OnObstacleHit -= HandleObstacleHit;
}
private void Update()
{
// Tick down all active power-up timers.
UpdateTimers();
// If the magnet is active, attract nearby coins.
if (IsPowerUpActive(PowerUpType.Magnet))
{
AttractNearbyCoins();
}
}
// ---------------------------------------------------------------
// Timer Management
// ---------------------------------------------------------------
/// <summary>
/// Updates all active power-up timers. Removes expired ones.
/// </summary>
private void UpdateTimers()
{
expiredTypes.Clear();
// Iterate over all active power-ups and count down.
foreach (var kvp in activePowerUps)
{
ActivePowerUp powerUp = kvp.Value;
powerUp.RemainingTime -= Time.deltaTime;
// Fire a timer update event for UI.
GameEvents.OnPowerUpTimerUpdated?.Invoke(
kvp.Key,
powerUp.GetNormalizedTimeRemaining()
);
// Check if expired.
if (powerUp.RemainingTime <= 0f)
{
expiredTypes.Add(kvp.Key);
}
}
// Remove expired power-ups.
// We do this in a separate pass because you cannot modify
// a dictionary while iterating over it.
for (int i = 0; i < expiredTypes.Count; i++)
{
DeactivatePowerUp(expiredTypes[i]);
}
}
// ---------------------------------------------------------------
// Activation / Deactivation
// ---------------------------------------------------------------
/// <summary>
/// Handles a power-up being collected by the player.
/// If the same type is already active, refreshes the timer.
/// Otherwise, activates a new power-up.
/// </summary>
private void HandlePowerUpCollected(
PowerUpType type,
float duration)
{
if (activePowerUps.ContainsKey(type))
{
// Refresh: reset the timer to the new duration.
activePowerUps[type].RemainingTime = duration;
activePowerUps[type].TotalDuration = duration;
Debug.Log($"[PowerUpManager] Refreshed {type} " +
$"for {duration}s");
}
else
{
// Activate a new power-up.
activePowerUps[type] =
new ActivePowerUp(type, duration);
// Apply the effect.
ApplyEffect(type);
Debug.Log($"[PowerUpManager] Activated {type} " +
$"for {duration}s");
}
// Notify UI and other systems.
GameEvents.OnPowerUpActivated?.Invoke(type, duration);
}
/// <summary>
/// Removes a power-up and reverses its effect.
/// </summary>
private void DeactivatePowerUp(PowerUpType type)
{
if (!activePowerUps.ContainsKey(type)) return;
// Remove the effect.
RemoveEffect(type);
// Remove from active tracking.
activePowerUps.Remove(type);
// Notify UI and other systems.
GameEvents.OnPowerUpExpired?.Invoke(type);
Debug.Log($"[PowerUpManager] Expired: {type}");
}
/// <summary>
/// Applies the gameplay effect of a power-up.
/// </summary>
private void ApplyEffect(PowerUpType type)
{
switch (type)
{
case PowerUpType.Magnet:
// Magnet effect is applied in Update() via
// AttractNearbyCoins(). Nothing to set here.
break;
case PowerUpType.Shield:
// Show the shield visual on the player.
GameEvents.OnShieldActivated?.Invoke(true);
break;
case PowerUpType.ScoreMultiplier:
// Set the score multiplier.
currentMultiplier = 2;
GameEvents.OnMultiplierChanged?.Invoke(currentMultiplier);
break;
}
}
/// <summary>
/// Removes the gameplay effect when a power-up expires.
/// </summary>
private void RemoveEffect(PowerUpType type)
{
switch (type)
{
case PowerUpType.Magnet:
// Nothing to clean up - Update() checks IsActive.
break;
case PowerUpType.Shield:
// Hide the shield visual.
GameEvents.OnShieldActivated?.Invoke(false);
break;
case PowerUpType.ScoreMultiplier:
// Reset the multiplier.
currentMultiplier = 1;
GameEvents.OnMultiplierChanged?.Invoke(currentMultiplier);
break;
}
}
// ---------------------------------------------------------------
// Magnet Logic
// ---------------------------------------------------------------
/// <summary>
/// Finds all coins within the magnet radius and moves them
/// toward the player. Uses Physics.OverlapSphere for detection.
/// </summary>
private void AttractNearbyCoins()
{
if (playerTransform == null) return;
Vector3 playerPos = playerTransform.position;
// Find all colliders within the magnet radius.
Collider[] nearby = Physics.OverlapSphere(
playerPos,
magnetRadius
);
for (int i = 0; i < nearby.Length; i++)
{
// Check if this collider is a coin.
Coin coin = nearby[i].GetComponent<Coin>();
if (coin == null) continue;
// Move the coin toward the player.
Vector3 direction =
(playerPos - coin.transform.position).normalized;
coin.transform.position += direction
* magnetSpeed
* Time.deltaTime;
}
}
// ---------------------------------------------------------------
// Shield Hit Logic
// ---------------------------------------------------------------
/// <summary>
/// Called when the player hits an obstacle. If a shield is
/// active, consumes it instead of killing the player.
/// </summary>
private void HandleObstacleHit()
{
if (IsPowerUpActive(PowerUpType.Shield))
{
// Consume the shield.
DeactivatePowerUp(PowerUpType.Shield);
// Notify systems that the shield absorbed the hit.
GameEvents.OnShieldConsumed?.Invoke();
Debug.Log("[PowerUpManager] Shield absorbed a hit!");
}
}
// ---------------------------------------------------------------
// Game Over
// ---------------------------------------------------------------
/// <summary>
/// Clears all active power-ups when the game ends.
/// </summary>
private void HandleGameOver()
{
// Build a list of all active types to deactivate.
List<PowerUpType> toRemove =
new List<PowerUpType>(activePowerUps.Keys);
for (int i = 0; i < toRemove.Count; i++)
{
DeactivatePowerUp(toRemove[i]);
}
}
// ---------------------------------------------------------------
// Public API
// ---------------------------------------------------------------
/// <summary>
/// Checks whether a specific power-up type is currently active.
/// </summary>
public bool IsPowerUpActive(PowerUpType type)
{
return activePowerUps.ContainsKey(type);
}
/// <summary>
/// Returns the current score multiplier.
/// Returns 1 if no multiplier power-up is active.
/// </summary>
public int GetCurrentMultiplier()
{
return currentMultiplier;
}
/// <summary>
/// Returns the normalized time remaining (0-1) for a power-up.
/// Returns 0 if the power-up is not active.
/// </summary>
public float GetTimeRemaining(PowerUpType type)
{
if (activePowerUps.TryGetValue(type, out ActivePowerUp powerUp))
{
return powerUp.GetNormalizedTimeRemaining();
}
return 0f;
}
}
}You cannot add or remove items from a C# Dictionary while iterating over it with foreach. That is why UpdateTimers() collects expired types into a separate list, then removes them in a second pass. Trying to modify the dictionary during iteration would throw an InvalidOperationException.
Power-up Spawn Probability
Power-ups should be rare — if they appear too often, they stop feeling special. We control spawn probability through the CoinSpawner (or a separate PowerUpSpawner that follows the same pattern). Here is the key logic:
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Spawns power-up pickups on world chunks. Power-ups are much
/// rarer than coins and their spawn rate is influenced by the
/// current difficulty level.
///
/// Attach this to the same manager object as CoinSpawner.
/// </summary>
public class PowerUpSpawner : MonoBehaviour
{
// ---------------------------------------------------------------
// Inspector Fields
// ---------------------------------------------------------------
[Header("Power-up Prefabs")]
[Tooltip("Prefab for the Magnet pickup.")]
[SerializeField] private GameObject magnetPrefab;
[Tooltip("Prefab for the Shield pickup.")]
[SerializeField] private GameObject shieldPrefab;
[Tooltip("Prefab for the Score Multiplier pickup.")]
[SerializeField] private GameObject multiplierPrefab;
[Header("Spawn Probability")]
[Tooltip("Base chance (0-1) that a chunk contains a power-up. " +
"This is very low - power-ups are rare!")]
[Range(0f, 1f)]
[SerializeField] private float baseSpawnChance = 0.08f;
[Tooltip("How much difficulty increases the spawn chance. " +
"At max difficulty, chance = baseChance + difficultyBonus.")]
[Range(0f, 0.2f)]
[SerializeField] private float difficultyBonus = 0.05f;
[Header("Type Weights")]
[Tooltip("Relative weight for Magnet spawning.")]
[SerializeField] private float magnetWeight = 1f;
[Tooltip("Relative weight for Shield spawning.")]
[SerializeField] private float shieldWeight = 1f;
[Tooltip("Relative weight for Score Multiplier spawning.")]
[SerializeField] private float multiplierWeight = 0.7f;
[Header("Placement")]
[Tooltip("Height above ground to place power-ups.")]
[SerializeField] private float spawnHeight = 1.5f;
// ---------------------------------------------------------------
// Private State
// ---------------------------------------------------------------
private float currentDifficulty = 0f;
// ---------------------------------------------------------------
// Unity Lifecycle
// ---------------------------------------------------------------
private void OnEnable()
{
GameEvents.OnChunkActivated += HandleChunkActivated;
GameEvents.OnDifficultyChanged += d => currentDifficulty = d;
}
private void OnDisable()
{
GameEvents.OnChunkActivated -= HandleChunkActivated;
}
// ---------------------------------------------------------------
// Spawning Logic
// ---------------------------------------------------------------
/// <summary>
/// Decides whether to spawn a power-up on the given chunk.
/// </summary>
private void HandleChunkActivated(
Transform chunkTransform,
Transform[] spawnPoints)
{
// Calculate effective spawn chance based on difficulty.
float effectiveChance =
baseSpawnChance + (currentDifficulty * difficultyBonus);
// Roll the dice.
if (Random.value > effectiveChance) return;
// No spawn points? Can't place anything.
if (spawnPoints == null || spawnPoints.Length == 0) return;
// Pick a random spawn point on the chunk.
Transform spawnPoint =
spawnPoints[Random.Range(0, spawnPoints.Length)];
// Select which power-up type to spawn.
GameObject prefab = SelectPowerUpPrefab();
if (prefab == null) return;
// Get from pool and position.
GameObject pickup = ObjectPool.Instance.Get(prefab);
if (pickup == null) return;
Vector3 position = spawnPoint.position;
position.y = spawnHeight;
pickup.transform.position = position;
// Initialize via IPoolable.
IPoolable poolable = pickup.GetComponent<IPoolable>();
poolable?.OnSpawnFromPool();
}
/// <summary>
/// Selects a power-up prefab using weighted random selection.
/// </summary>
private GameObject SelectPowerUpPrefab()
{
float totalWeight =
magnetWeight + shieldWeight + multiplierWeight;
float roll = Random.Range(0f, totalWeight);
if (roll < magnetWeight)
return magnetPrefab;
roll -= magnetWeight;
if (roll < shieldWeight)
return shieldPrefab;
return multiplierPrefab;
}
}
}Start with a very low base spawn chance (around 5-10%) and playtest. If power-ups feel too rare, increase gradually. A common mistake is making power-ups too common, which removes the excitement of finding one. In Subway Surfers, you might run for 15-30 seconds between power-ups — that rarity is what makes them feel special.
Visual Indicators for Active Power-ups
Players need clear visual feedback about which power-ups are active and how much time is left. We will connect the power-up events to the UI system (built in Chapter 19), but here are the visual effects you should set up in the scene:
Shield Visual
- Create a child object under the Player called
ShieldVisual. - Add a semi-transparent sphere mesh (or a particle effect) around the player.
- Set it to inactive by default in the Inspector.
- Create a script that listens for
GameEvents.OnShieldActivatedand enables/disables the visual.
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// Controls the visual representation of the shield power-up
/// on the player. Shows a semi-transparent bubble when active.
/// </summary>
public class ShieldVisual : MonoBehaviour
{
[Tooltip("The shield mesh/particle child object.")]
[SerializeField] private GameObject shieldEffect;
[Tooltip("How fast the shield pulses (scale oscillation).")]
[SerializeField] private float pulseSpeed = 2f;
[Tooltip("How much the shield scales during pulse (1 +/- this).")]
[SerializeField] private float pulseAmount = 0.05f;
private bool isShieldActive;
private Vector3 baseScale;
private void Awake()
{
if (shieldEffect != null)
{
baseScale = shieldEffect.transform.localScale;
shieldEffect.SetActive(false);
}
}
private void OnEnable()
{
GameEvents.OnShieldActivated += SetShieldActive;
GameEvents.OnShieldConsumed += PlayBreakEffect;
}
private void OnDisable()
{
GameEvents.OnShieldActivated -= SetShieldActive;
GameEvents.OnShieldConsumed -= PlayBreakEffect;
}
private void Update()
{
if (!isShieldActive || shieldEffect == null) return;
// Gentle pulsing effect to show the shield is alive.
float pulse = 1f + Mathf.Sin(Time.time * pulseSpeed) * pulseAmount;
shieldEffect.transform.localScale = baseScale * pulse;
}
/// <summary>
/// Shows or hides the shield visual.
/// </summary>
private void SetShieldActive(bool active)
{
isShieldActive = active;
if (shieldEffect != null)
{
shieldEffect.SetActive(active);
shieldEffect.transform.localScale = baseScale;
}
}
/// <summary>
/// Plays a break/shatter effect when the shield is consumed.
/// For now, we just deactivate. Add particle burst here later.
/// </summary>
private void PlayBreakEffect()
{
isShieldActive = false;
if (shieldEffect != null)
{
// TODO: Instantiate a shield-break particle effect here.
shieldEffect.SetActive(false);
}
}
}
}Magnet Visual
For the magnet, add a subtle blue glow or electric particle effect around the player. Follow the same pattern: a child object toggled by the OnPowerUpActivated and OnPowerUpExpired events.
Multiplier Visual
For the score multiplier, a "2x" floating text or golden glow effect works well. This connects to the HUD system we will build in Chapter 19.
You may have noticed we reference events like GameEvents.OnCoinCollected, GameEvents.OnPowerUpCollected, and others. These should be declared in your GameEvents static class from Chapter 7. Here is a summary of the new events needed for this chapter:
using System;
namespace InfiniteRunner.Core
{
/// <summary>
/// Add these new events to your existing GameEvents class.
/// </summary>
public static partial class GameEvents
{
// --- Coin Events ---
/// <summary>Fired when a coin is collected. Parameter: coin value.</summary>
public static Action<int> OnCoinCollected;
// --- Power-up Events ---
/// <summary>Fired when a power-up pickup is collected.</summary>
public static Action<PowerUpType, float> OnPowerUpCollected;
/// <summary>Fired when a power-up becomes active.</summary>
public static Action<PowerUpType, float> OnPowerUpActivated;
/// <summary>Fired when a power-up's timer expires.</summary>
public static Action<PowerUpType> OnPowerUpExpired;
/// <summary>Fired every frame with normalized time remaining.</summary>
public static Action<PowerUpType, float> OnPowerUpTimerUpdated;
// --- Shield Events ---
/// <summary>Fired to show/hide the shield visual.</summary>
public static Action<bool> OnShieldActivated;
/// <summary>Fired when the shield absorbs a hit.</summary>
public static Action OnShieldConsumed;
// --- Multiplier Events ---
/// <summary>Fired when the score multiplier changes.</summary>
public static Action<int> OnMultiplierChanged;
// --- Obstacle Events ---
/// <summary>Fired when the player hits an obstacle.</summary>
public static Action OnObstacleHit;
// --- Chunk Events ---
/// <summary>Fired when a chunk is activated in the world.</summary>
public static Action<Transform, Transform[]> OnChunkActivated;
/// <summary>Fired when a chunk is deactivated/recycled.</summary>
public static Action<Transform> OnChunkDeactivated;
// --- Difficulty Events ---
/// <summary>Fired when the difficulty level changes (0-1).</summary>
public static Action<float> OnDifficultyChanged;
}
}Chapter Summary
In this chapter, you built a complete collectible and power-up system:
- Coin.cs — Poolable coins with spin and hover animations that detect player collection via triggers.
- CoinPatternData — ScriptableObject patterns (lines, arcs, zigzags) that define coin arrangements.
- CoinSpawner.cs — Places coin patterns on chunks using weighted random selection.
- PowerUp base class — Abstract base with three concrete subclasses: Magnet, Shield, and Score Multiplier.
- PowerUpManager.cs — Manages active power-ups, timers, stacking rules, and gameplay effects.
- PowerUpSpawner.cs — Controls rare power-up spawning with difficulty-influenced probability.
- ShieldVisual.cs — Visual feedback for the shield power-up with pulsing animation.
All of these systems communicate through events, keeping them decoupled and easy to extend. In the next chapter, we will build the scoring system that tracks distance, coins, and multipliers.
Before moving on, enter Play Mode and verify: coins appear on chunks and spin, collecting a coin fires the event (check the console with a Debug.Log), power-up pickups work, and the magnet pulls coins toward the player. If anything is not working, double-check your event subscriptions and make sure prefabs are registered with the ObjectPool.
Coins, magnets, shields, multipliers — the collectibles system is packed with content. Save it.
git add .
git commit -m "Implement collectibles with coins and power-up system"
Try running git log --oneline on this branch. Notice only the gameplay-related commits appear. The world generation commits are on main. Each branch tells a clean story.