Crowd runner games — the genre where you sprint down a lane collecting followers, then throw that crowd into a battle sequence — have quietly become one of the most consistent hits in mobile gaming. Think Last War: Survival, Zombie Run, Count Masters. The appeal is simple to describe and surprisingly tricky to implement well: a runner section that feeds directly into a combat section, where every decision in phase one visibly changes your odds in phase two.
I want to walk through the actual systems that make this genre work, with real C# code you can drop into a Unity project. We'll cover three core pieces:
- A performant crowd/army system (using object pooling, not
Instantiatespam) - A formation-following movement system for your crowd
- A phase-transition state machine that connects the runner section to the combat section
By the end you'll have a working skeleton you can extend into a full game.
Why Crowd Runners Are Deceptively Hard to Build
The naive approach — spawn a GameObject for every unit, parent it to the player, and call it a day — falls apart fast. If your player is meant to command 50, 100, or even 300 units on screen simultaneously (which is normal for this genre), naive instantiation and per-frame Transform updates will tank your frame rate on mobile hardware within seconds.
The three problems you need to solve up front are:
- Spawning and despawning hundreds of units without garbage collection spikes
- Making a crowd move together in a way that looks organic, not like a single rigid block
- Cleanly transitioning game state from "running" to "combat" without hacky flags scattered across your codebase
Let's tackle each one.
1. Object Pooling for Crowd Units
Every Instantiate() and Destroy() call allocates and frees memory. Do that 50 times a frame as your crowd grows and shrinks, and you'll see GC spikes causing visible stutter. The fix is a pool: pre-allocate your unit objects once, and recycle them.
using System.Collections.Generic;
using UnityEngine;
public class CrowdUnitPool : MonoBehaviour
{
[SerializeField] private GameObject unitPrefab;
[SerializeField] private int initialPoolSize = 200;
private readonly Queue<GameObject> pool = new Queue<GameObject>();
private void Awake()
{
for (int i = 0; i < initialPoolSize; i++)
{
GameObject unit = Instantiate(unitPrefab, transform);
unit.SetActive(false);
pool.Enqueue(unit);
}
}
public GameObject GetUnit(Vector3 position, Quaternion rotation)
{
GameObject unit = pool.Count > 0
? pool.Dequeue()
: Instantiate(unitPrefab, transform); // fallback if pool runs dry
unit.transform.SetPositionAndRotation(position, rotation);
unit.SetActive(true);
return unit;
}
public void ReturnUnit(GameObject unit)
{
unit.SetActive(false);
unit.transform.SetParent(transform);
pool.Enqueue(unit);
}
}
The key design decision here is the fallback Instantiate inside GetUnit. Your army size is variable — a player might recruit far more units than your initialPoolSize accounts for. Rather than hard-capping crowd size (which breaks the power fantasy of the genre), let the pool grow on demand, but always return objects to the pool instead of destroying them once they've been created.
2. Formation-Based Crowd Movement
A crowd that moves as a single rigid block reads as fake immediately. What actually sells the "army" feeling is loose formation logic: each unit has a target offset from the player, with some smoothing so units drift into position rather than snapping.
using System.Collections.Generic;
using UnityEngine;
public class CrowdFormationController : MonoBehaviour
{
[SerializeField] private Transform player;
[SerializeField] private float followSpeed = 8f;
[SerializeField] private float spacing = 0.8f;
[SerializeField] private int unitsPerRow = 6;
private readonly List<Transform> units = new List<Transform>();
public void AddUnit(Transform unit)
{
units.Add(unit);
}
public void RemoveUnit(Transform unit)
{
units.Remove(unit);
}
private void Update()
{
for (int i = 0; i < units.Count; i++)
{
Vector3 targetOffset = GetFormationOffset(i);
Vector3 targetPosition = player.position + targetOffset;
units[i].position = Vector3.Lerp(
units[i].position,
targetPosition,
followSpeed * Time.deltaTime
);
}
}
private Vector3 GetFormationOffset(int index)
{
int row = index / unitsPerRow;
int col = index % unitsPerRow;
// Center the row so units fan out symmetrically behind the player
float centeredCol = col - (unitsPerRow - 1) / 2f;
return new Vector3(
centeredCol * spacing,
0f,
-(row + 1) * spacing // stack rows behind the player
);
}
}
A few things worth calling out:
-
Vector3.LerpwithTime.deltaTimegives you smooth, organic-looking movement instead of units snapping to grid positions. This alone makes a huge visual difference. - Row/column offset math keeps your formation readable even at large crowd sizes, rather than units overlapping or clipping into each other.
- Consider adding a small random jitter (
Random.insideUnitCircle * 0.1f) to each unit's offset if you want an even more organic, less "grid-like" look.
For performance at very high unit counts (200+), you'd eventually want to move this off individual Transform updates and into Unity's Job System with Burst compilation, or use GPU instancing for rendering. But this CPU-based version comfortably handles a few hundred units on mid-range mobile hardware, which covers most crowd runner use cases.
3. The Run → Combat State Machine
This is the part that actually defines the genre. The running phase and the combat phase need to feel like two different games, but they need to share data cleanly — specifically, the size and strength of the army you built during the run.
A simple, explicit state machine keeps this manageable:
using UnityEngine;
public enum GamePhase
{
Running,
Transitioning,
Combat,
ResultsScreen
}
public class GamePhaseController : MonoBehaviour
{
public static GamePhaseController Instance { get; private set; }
public GamePhase CurrentPhase { get; private set; } = GamePhase.Running;
[SerializeField] private RunnerController runnerController;
[SerializeField] private CombatController combatController;
[SerializeField] private ArmyData armyData;
private void Awake()
{
Instance = this;
}
public void EnterCombatPhase()
{
if (CurrentPhase != GamePhase.Running) return;
CurrentPhase = GamePhase.Transitioning;
runnerController.StopRunning();
// Hand off the army built during the run to the combat system
combatController.InitializeBattle(armyData.CurrentUnitCount, armyData.AverageUnitPower);
CurrentPhase = GamePhase.Combat;
}
public void EndCombatPhase(bool playerWon)
{
CurrentPhase = GamePhase.ResultsScreen;
// Trigger UI, rewards, and progression updates here
}
}
[System.Serializable]
public class ArmyData
{
public int CurrentUnitCount;
public float AverageUnitPower;
public void AddUnit(float unitPower)
{
float totalPower = AverageUnitPower * CurrentUnitCount;
CurrentUnitCount++;
AverageUnitPower = (totalPower + unitPower) / CurrentUnitCount;
}
public void RemoveUnits(int count)
{
CurrentUnitCount = Mathf.Max(0, CurrentUnitCount - count);
}
}
The reason to keep ArmyData as a plain serializable class rather than baking the numbers directly into your runner or combat controllers is reusability — you can persist it, feed it into a save system, or use it to drive a pre-battle "army preview" screen without your combat logic knowing anything about how the army was built.
Notice the explicit Transitioning state between Running and Combat. It's tempting to skip this and just flip straight from one to the other, but having a dedicated transition state gives you a clean hook for things like a camera pan, a "Battle Start" animation, or a short loading pause — all without cluttering your Running or Combat state logic with one-off timing hacks.
Connecting the Systems
Putting it together, your recruitment logic (picking up allies during the run) should call both CrowdFormationController.AddUnit() and ArmyData.AddUnit() at the same time:
public class RecruitGate : MonoBehaviour
{
[SerializeField] private CrowdUnitPool unitPool;
[SerializeField] private CrowdFormationController formation;
[SerializeField] private ArmyData armyData;
[SerializeField] private float unitBasePower = 10f;
private void OnTriggerEnter(Collider other)
{
if (!other.CompareTag("Player")) return;
GameObject newUnit = unitPool.GetUnit(other.transform.position, Quaternion.identity);
formation.AddUnit(newUnit.transform);
armyData.AddUnit(unitBasePower);
}
}
This keeps a clean separation of concerns: the pool handles memory, the formation controller handles visual positioning, and ArmyData handles the numbers that actually matter for combat balance. When your combat phase starts, it only needs to read from ArmyData — it doesn't care how the army was visually assembled.
Performance Checklist Before You Ship
A few things worth double-checking once your core loop is working, especially if you're targeting lower-end Android devices:
- Profile with the actual target device, not just the Unity Editor. Crowd rendering performance varies wildly between a flagship phone and a budget Android device.
- Batch your unit materials. If every unit shares the same material and mesh, Unity can batch draw calls automatically (static or dynamic batching / GPU instancing). Mixing materials per-unit kills this optimization.
- Cap active combat units on screen, even if your "army size" number is higher. Many shipped crowd runner games visually cap on-screen combatants at 40–60 and represent the rest abstractly in UI, because rendering hundreds of animated characters simultaneously in the combat phase is a much heavier cost than in the running phase.
- LOD (Level of Detail) your unit models if your crowd count regularly exceeds 100 — distant or background units don't need full-resolution meshes.
Wrapping Up
The core technical challenge of a crowd runner combat game isn't any single system — it's making the pooling, formation, and phase-transition logic talk to each other cleanly without your codebase turning into a tangle of flags and special cases. Get those three systems solid, and the rest of the genre — upgrades, cosmetics, monetization hooks, level design — builds on top of a foundation that won't fight you later.
If you'd rather not build this stack from scratch, it's worth knowing that fully implemented versions of this exact system — pooling, formation movement, run/combat state handling, and progression — exist as ready-made Unity templates. I found a solid library of them, including crowd-combat and runner-genre projects specifically, while researching this piece: unitysourcecode.net/products. Even if you don't buy one, reading through a shipped implementation is a good way to sanity-check your own architecture against something that's already handled the edge cases.
If you build your own version of this system, I'd genuinely like to hear what approach you took for large-crowd performance — Job System, GPU instancing, or something else entirely. Drop it in the comments.
Top comments (0)