Skip to content

Squad combat

Four components turn a scene of sprites into a battle: AI Mover decides where a unit goes, Ranged Weapon decides when it shoots, Squad Control decides which one you're driving, and Team Roster decides when it's over.

This page is about how they fit together. Each component's exact fields are in the built-in reference.

The unit stack

A fighting unit carries six components. Three hold state, three make decisions:

ComponentRole
TeamA faction string. Everything faction-aware is a query over this tag.
Healthhp / maxHp, emits died.
Sprite RendererThe art.
Character controllerThe legs. Owns speed and controlledBy.
AI MoverAcquires a target, paths to it, hides when hurt.
Ranged WeaponFires when in range with a clear shot.

Give every unit both a controller and an AI Mover — even the one the player starts on. Possession is a field flip, so a unit you switch away from should already be a competent squadmate.

Targeting: one pick, shared

Set AI Mover's targetMode to nearestEnemy and it resolves the nearest living object whose Team differs, publishing the result as currentTargetId.

Ranged Weapon in auto mode prefers that pick and only runs its own search when there is no AI Mover. This is deliberate: two independent searches let a unit walk toward one enemy while shooting a different one — behaviour that looks broken even though each component is individually right.

Acquisition is sticky. The held target survives until it dies, leaves Detect Range, or retargetInterval elapses. Re-picking every frame makes a squad jitter between equidistant enemies and costs a full scene scan per unit per frame.

A dead target is released immediately

The retarget timer does not gate death. A killed target is dropped on the next tick regardless, so squads never keep shooting corpses.

Cover: one definition of "solid"

Mark your scenery with a group — say cover — and point both components at it:

aiMover.obstacleGroup     = "cover"
rangedWeapon.obstacleGroup = "cover"

Both resolve that group through the same obstacle footprints, so pathing and sight can never disagree. An AI that walks around a rock but believes it can shoot through it is the classic symptom of two systems each computing "solid" their own way.

Leaving obstacleGroup empty falls back to anything with a Collider 2D.

Pathfinding

Turn on avoidObstacles and AI Mover routes around scenery instead of pressing into it:

  • Straight line whenever the way is clear. A* only runs when sight to the target is actually blocked — that's both the cheap path and the good-looking one.
  • Obstacles inflate by agentRadius, so a unit is never routed through a gap narrower than its own body.
  • Diagonals never cut corners, so units don't clip wall edges.
  • Routes are string-pulled, so movement reads as intentional rather than as a staircase.
  • Paths are cached until consumed, until the target moves past repathTolerance, or until repathInterval elapses.

Pathing is confined to the stage

The nav grid uses the scene's stage bounds. Without them it would only span the obstacles themselves, and a unit blocked by a wall would happily route around the outside of the level.

Marking cover

Put a Cover component on a sandbag, wall or crate to say this is a place you can shelter at. It hands out a standing spot on the side away from whoever is shooting, and — the part that matters with a squad — it limits how many can use it:

cover.capacity  = 2     # two can share this sandbag nest
cover.standoff  = 24    # how far clear of the edge they stand
cover.quality   = 2     # preferred over a thin post at the same distance

Without occupancy, every hurt soldier runs to the same nearest rock and stands on top of the others. Claims are what make a squad spread out.

Cover is chosen by distance weighted by quality, and the claimed spot is then verified to actually break line of sight before it is accepted — an object marked as cover can still leave you exposed from where the shooter happens to be standing.

Claims clean up after themselves

A unit killed on its way to cover never gets to release its spot, so the component drops claimants that died, left, or reserved without ever arriving. Nothing has to remember to tidy up.

Units fall back to hiding behind any blocking scenery when nothing marked is within reach. Turn that off per unit with aiMover.useUnmarkedCover: false if you want cover to be strictly authored.

Taking cover

Set coverBelowHealth to a fraction — 0.5 means "break off at half health". The unit then searches for the nearest reachable spot that breaks line of sight to its target, runs there, and holds.

The cycle is duck → hold → re-engage, never duck-and-stay:

  1. Health drops below the threshold → it looks for cover.
  2. It reaches cover → isInCover goes true and coverHoldTime starts counting.
  3. The hold elapses → it breaks out and fights again.
  4. coverRecheckDelay prevents an immediate second duck.

A unit that hides permanently once hurt has removed itself from the battle — that reads as broken rather than tactical, which is why the hold is bounded.

Cover is only ever a spot that is genuinely hidden and genuinely reachable; a hiding place across a sealed wall is rejected rather than sending a unit into the obstacle forever. With nothing to hide behind, the unit fights on instead of freezing.

Vary the threshold per role

A long-range unit that breaks contact early and a short-range unit that commits play very differently. Staggering thresholds across a squad is most of what makes a battle feel like it has roles.

Facing the right way

Top-down games are routinely drawn with side-view character art. Rotating such a sprite to face its target turns it upside down, so both facing options take a style:

topDownController.rotateSprite = true
topDownController.spriteFacing = "flipX"   # face movement by mirroring
rangedWeapon.aimFacing         = "flipX"   # face the target you shoot

Flipping mirrors around the object's authored flip, so art drawn facing left and art drawn facing right both work — the scene decides which way "unflipped" means.

Aim beats movement: rangedWeapon ticks after the controller, so a unit walking left while shooting right ends up facing its target.

Rotate needs turning, flip does not

The rotate path is a no-op while the controller's turning is none. flipX has no such dependency.

Projectiles that travel

Hitscan is fine for a rifle, but a shot that arrives instantly cannot be dodged, cannot miss a mover, and cannot land behind someone. Set fireMode: projectile and Ranged Weapon spawns a prefab aimed at the target instead of applying damage directly.

Put Ballistic on that prefab to make it fly, and Area Damage on it too if it should explode:

# a rocket
ballistic.flightMode  = "direct"     # blocked by cover
ballistic.speed       = 420
ballistic.damage      = 0            # the blast is the weapon
areaDamage.radius     = 90
areaDamage.damage     = 18

# a grenade
ballistic.flightMode  = "lobbed"     # arcs OVER cover
ballistic.arcHeight   = 60
ballistic.spin        = 360
areaDamage.radius     = 110

The two flight modes are what separate the weapons. A direct shot stops at the first obstacle, so cover protects you. A lobbed shot passes over everything and lands where it was aimed — which is precisely why sitting behind a rock is not a winning strategy once somebody brings grenades.

Pair a lobbed weapon with requireLineOfSight: false on Ranged Weapon, or it will refuse to fire at a target it cannot see — the one it is best equipped to hit.

Explosions hurt your own squad

allowFriendlyFire defaults to on. A rocket that lands short kills your own soldier, which is both physically honest and a defining moment of the genre. The blast resolves sides through the object the shell was spawned from, since the shell itself has no Team.

Possession

Squad Control goes on a scene manager object — an empty game object — with squadGroup set to the squad's group.

It does not move anything. It writes controlledBy on member controllers, holding one invariant: exactly one living member is player-controlled. It corrects scene data that marks two, skips dead members when cycling, and edge-triggers the switch key so holding it swaps once rather than every frame.

Leave handOffOnDeath on. Without it, when your active unit dies the player is stranded on a corpse with no input and nothing to do.

Ending the battle

Team Roster watches a comma-separated list of teams and publishes live counts plus terminal events — Team Eliminated, Last Team Standing, All Eliminated. The last two fire once, so whatever shows a victory screen isn't re-triggered every frame.

Keep requireHealth on. A team-tagged object with no Health — a destructible wall, a spawn marker — otherwise counts as a survivor forever and the battle can never end.

Wiring a HUD

UI bindings resolve get(id) to an object and reach component state through .components.<componentId>:

js
"=`RED  ${get('battle_manager').components.teamRoster.countData.red}`"
"=get('battle_manager').components.squadControl.activeMemberName"
"=!!get('battle_manager').components.teamRoster.winnerLabel"

visible is bindable, so an outcome banner needs no logic of its own — bind it to winnerLabel and it appears exactly when there's a result.

The expression sandbox allows no string methods

.toUpperCase() in a binding throws. Anything a label needs to display must be formatted where the value is produced — which is why Team Roster publishes winnerLabel alongside the semantic winningTeam.

A worked setup

A 3v3 skirmish over cover:

On each rock: a Sprite Renderer and group cover.

On each unit: Team (red or blue), Health, a Top-Down Controller set to Controlled By: AI, plus:

aiMover
  behavior          follow
  targetMode        nearestEnemy
  stopWithin        175          # hold at weapon range
  avoidObstacles    true
  obstacleGroup     cover
  agentRadius       16
  coverBelowHealth  0.45

rangedWeapon
  range             210
  cooldown          0.4
  damage            2
  damageVariance    1
  requireLineOfSight true
  obstacleGroup     cover

On a manager object: Squad Control (squadGroup: redSquad) and Team Roster (teams: red, blue).

Two tuning notes that matter more than they look:

  • stopWithin should sit just inside range. A unit that stops outside its own weapon range walks up, stops, and never fires.
  • damageVariance: 0 makes symmetric fights end in draws. Identical units resolve every duel on the same frame, so both sides die simultaneously. A small variance desynchronizes them.