Skip to content

How to Build: Combat Tick

This is the beating heart of the game. Every 250 milliseconds — four times per second — the combat tick system resolves attacks between the player and every enemy they're fighting. Attack timers fire, accuracy is rolled, damage is dealt, experience is granted, and deaths are checked. It's the system where all the other Phase 3 pieces come together.

How combat flows

Each combatant (player and every engaged enemy) has an independent attack timer. Every tick, each timer increments by one. When a timer reaches the combatant's attack speed, an attack resolves: first an accuracy roll (can the attacker hit?), then a damage roll (how hard?). The player can be fighting up to 32 enemies simultaneously — true multi-combat.

Let's build it piece by piece.

The CombatState trait

Every combatant — player and enemy alike — carries this trait.

typescript
type CombatState = {
  targetEntity: number    // entity ID of current target (0 = none)
  attackTimer: number     // ticks since last attack
  inCombat: boolean
}

The aggro system (covered in its own guide) sets inCombat and targetEntity. The combat tick reads and advances attackTimer.

Combat math: accuracy and damage

Two pure functions handle all the randomness in combat.

typescript
function rollAccuracy(attackerAttack: number, defenderDefence: number): boolean

function rollDamage(
  attackerStrength: number,
  defenderDefence: number,
  weaponMin: number,
  weaponMax: number
): number

Accuracy is a contest: roll a random value from 0 to the attacker's Attack stat, roll another from 0 to the defender's Defence stat. If the attack roll wins, the hit lands. Simple, but it creates a smooth probability curve — a small Defence advantage doesn't guarantee misses, it just makes them more likely.

Damage starts with a random roll between the weapon's min and max hit, adds the attacker's Strength, then subtracts the defender's Defence. The result is floored at COMBAT.MIN_DAMAGE (probably 1) so that every successful hit does something.

Processing the player's attack

Each tick, the system checks whether the player is in combat and has a valid target. If the target has died since last tick, combat state clears. Otherwise, the attack timer advances.

typescript
function processPlayerAttack(player: Entity, world: World): void

When the timer reaches the equipped weapon's attack speed, an attack fires. On a successful accuracy roll, damage is dealt to the target and the player earns Attack and Strength experience. Regardless of hit or miss, the player earns Hitpoints experience — you learn something from every swing.

The timer resets to zero after firing, and the cycle begins again.

Processing enemy attacks

Every engaged enemy runs the same loop independently, attacking the player on their own timer.

typescript
function processEnemyAttack(enemy: Entity, player: Entity): void

The enemy's stats come from EnemyStats rather than skills and equipment. When an enemy's attack fires and hits, the player takes damage. The player earns Defence and Hitpoints experience from being attacked — getting hit is a learning experience too.

Notice how this creates an interesting dynamic: the more enemies attacking you simultaneously, the faster your Defence skill levels up. There's a natural reward for pushing into harder content.

The main tick function

The top-level system ties it all together.

typescript
function tickCombat(world: World): void

It queries for the player, processes the player's attack, then iterates over all engaged enemies and processes each of their attacks. After all attacks resolve, it runs death checking.

One important implementation detail: when iterating over enemies, you need mutable access to CombatState since processEnemyAttack modifies the attack timer. In Koota, that means using updateEach instead of readEach.

Checking for deaths

After all attacks in a tick have resolved, we sweep through combatants to see if anyone's HP has hit zero.

typescript
function checkDeaths(world: World): void

For enemies, any entity with Health.current <= 0 that doesn't already have IsDead gets tagged and passes through the enemy death handler (gold drops, respawn timer).

For the player, death is handled differently: the player entity is never tagged with IsDead. Instead, when Health.current hits zero, the player death handler fires immediately — saving progress, regenerating the level with a new seed, and resetting the player at the start. This keeps things simple and avoids edge cases where a dead player entity is still being queried by other systems.

A note on Defence double-dipping

You might have noticed that Defence shows up in both the accuracy roll and the damage calculation. This is intentional. Defence is a powerful stat that both reduces the chance of being hit and reduces damage when hits land. This creates a meaningful build choice: invest in Attack and Strength for an offensive glass-cannon style, or invest in Defence for a tanky style that shrugs off most incoming damage.

The exact formulas will need tuning during development, but the double-dipping structure gives us a wide knob to turn for balancing.

Phase 3 tick execution order

All Phase 3 systems run within the 250 millisecond game tick. The order matters — here's the full sequence:

  1. tickAggro — detect new engagements, check leash distances
  2. tickCombat — process all attacks, deal damage
  3. tickConsumables — auto-use potions after taking damage
  4. checkDeaths (within tickCombat) — handle deaths after all combat resolves
  5. tickHpRegen — regenerate HP if out of combat
  6. tickRespawn — count down respawn timers for dead enemies

Notice how aggro runs first so that newly engaged enemies participate in combat on the same tick. Consumables run after combat so potions respond to damage taken this tick. Regen runs near the end and only fires when out of combat. Respawn is last since it just counts down timers.

Relevant files

src/core/systems/tick-combat.ts    — Main combat tick
src/core/utils/combat-math.ts     — Accuracy roll, damage roll functions

We now have enemies that fight back and a player that can die. But how do enemies decide to fight? That's next: aggro and leashing.