Distance-triggered charge & pulsed poison modeling

Clash Royale Simulator — data-driven fidelity verification for two combat subsystems

This is a deterministic multi-agent combat simulator written in Rust — 20 ticks per second, integer-only arithmetic, fully reproducible bit-for-bit. Built to sharpen skills directly applicable to autonomous systems and multi-agent simulation research, this document focuses on two subtle real-world mechanics I reverse-engineered with tick-level precision: distance-triggered charge attacks (step-function acceleration after walking a threshold) and pulsed damage-over-time with simultaneous movement debuff (like environmental hazards in multi-agent scenarios). These mirror exactly the challenges of modeling dynamic vehicle acceleration, road-condition speed penalties, and periodic sensor-triggered effects in swarm coordination.

1. Distance-triggered charge model data-driven

Some entities have a "charge" ability: after walking a threshold distance, they enter a boosted state where movement speed and attack damage both increase. The charge is distance-triggered, not target-triggered — the entity doesn't need to see an enemy. It charges while walking toward any waypoint, including default lane targets (enemy towers).

Data model

ParameterJSON fieldValue (level 11)Engine units
Base damagedamage627HP per hit
Charge multipliercharge_speed_multiplier200% of base speed
Charge distancecharge_range300internal units (iu)
Base speedspeed60 → 28 u/tickafter speed_to_units_per_tick()
Hit intervalhit_speed1400 ms → 28 ticksticks between hits

The engine accumulates walked distance each tick using Euclidean displacement. Once the accumulator exceeds charge_range, the entity enters charge state. Charge damage is exactly 2× base damage (computed as damage * charge_speed_multiplier / 100).

charge_damage = damage × (charge_speed_multiplier / 100) = 627 × 2 = 1254

Speed activation curve

The entity starts at base speed (28 u/tick). After accumulating 300 distance units (~11 ticks at 28 u/tick), charge activates and speed jumps to 57 u/tick — a 2.03× increase. This is a step function, not a gradual ramp.

Tick (1 tick = 50 ms) Speed (u/tick) 0 14 28 42 57 0 5 10 15 20 25 30 Charge activates 300 iu walked (~tick 11) 28 u/tick (base) 57 u/tick (2× charge)
Figure 1 — Speed-over-time curve. Step function at tick ≈11 when cumulative walked distance exceeds 300 iu. Dots are measured values from the test harness.

Damage comparison: charge vs normal hit

To isolate single-hit damage, we use a high-HP target entity (8192 HP) placed far from any automated defensive structures that might interfere. A frame-by-frame track_first_hit() helper steps tick-by-tick and returns the exact tick where the target's HP first drops.

Tick Target HP 8192 7565 6938 0 10 20 30 40 Normal hit: −627 HP 8192 → 7565 Charge hit: −1254 HP 8192 → 6938 (exact 2×)
Figure 2 — HP trace of a high-HP target under normal vs charge attack. Charge hit is exactly 2× normal, matching the JSON charge_speed_multiplier=200.

Charge reset on stun

When an entity is stunned (speed forced to 0 for a duration), the charge state is cancelled and the distance accumulator resets to 0. The entity must re-walk the full charge_range to re-enter charge. We verify this by measuring speed in the first 3 ticks after stun ends — it must be base speed (28), not charge speed (57).

Tick Speed (u/tick) 0 28 57 0 10 20 30 40 50 Stunned Charging Re-walking 300u Re-charged dist = 0 (reset)
Figure 3 — Speed profile interrupted by a 10-tick stun. The distance accumulator resets, requiring the entity to re-walk 300 iu before charge reactivates. Verified by measuring speed in the first 3 ticks post-stun.

Implementation in the tick loop

Charge tracking runs inside the movement phase (tick step 6). Each tick, the entity's displacement from its previous position is computed and subtracted from a distance-remaining counter. When the counter reaches zero, charge activates.

// combat.rs — tick_movement(), charge tracking
let dx = (entity_x - t.charge_prev_x) as i64;
let dy = (entity_y - t.charge_prev_y) as i64;
let moved = ((dx * dx + dy * dy) as f64).sqrt() as i32;

if moved > 0 {
    t.charge_distance_remaining -= moved;
    if t.charge_distance_remaining <= 0 && !t.is_charging {
        t.is_charging = true;       // charge state ON
        t.charge_hit_ready = true;  // next hit deals 2× damage
    }
}
if t.is_charging {
    effective_speed = effective_speed * charge_mult / 100;  // 200% = double
}

The key property: charge is tracked by cumulative walked distance, not by proximity to any target. This means an entity charges while walking down an empty lane toward a distant tower — validated by test 1107 (no enemies on the map, charge still activates at tick ~11).

Part A: Prince in empty lane — no enemies on the map. Speed jumps from 29 to 59 u/tick at tick 31 after accumulating 319 distance units. Charge is distance-triggered, not target-triggered.
Part B: Prince charges into Golem (8192 HP). First hit deals exactly 1254 damage — 2× the base 627, matching charge_speed_multiplier=200 from the JSON data.
Part A — Charge activation (no enemies):
Ticks 1–19: deploy timer (speed=0)  →  Ticks 20–30: base speed 29 u/tick  →  Tick 31: speed jumps to 59 u/tick
Cumulative distance at activation: 319 iu (threshold: 300 from charge_range)
Part B — Charge damage:
First hit at tick 97: −1254 HP (exact 2× of 627). Subsequent hits: 627 (normal). Princess towers: 109.
PASS — Charge activates via distance alone ✓   First hit = 1254 (exact 2×) ✓   Step function, not gradual ramp ✓
Deploy timer: Troops have a ~1 second deploy animation (~20 ticks) before they become active. The Prince sits idle at speed=0 for ticks 1–19, then starts walking at tick 20. Charge activates ~11 ticks of walking later (tick 31). The writeup's "tick ~11" refers to ticks of actual movement, not absolute ticks.

2. Pulsed damage-over-time with simultaneous slow data-driven

The Poison spell creates a zone on the arena that simultaneously applies two effects to enemies inside it: periodic damage (pulsed, not per-tick) and a movement speed debuff. Both effects are buff-based — the zone applies a buff to entities, and the buff's tick logic handles the actual damage and slow.

Data model

ParameterJSON fieldValueEngine units
Damage per seconddamage_per_second57HP/sec
Pulse intervalhit_speed (spell)250 ms5 ticks
Zone durationlife_duration8000 ms160 ticks
Speed multiplierspeed_multiplier−15−15% of base speed
Target filteronly_enemiestrueOnly affects opponent's entities

Pulsed DOT mechanism

The damage does not fire every tick. A damage_hit_timer counts down from the pulse interval (5 ticks / 250 ms). When it reaches 0, one pulse of damage fires and the timer resets. The spell zone reapplies the buff every pulse, and because enable_stacking=True, multiple buff instances stack — accumulating the speed debuff over time.

damage_per_pulse = 57 HP (from buff damage_per_second)
pulse_interval = 5 ticks (250 ms, from spell hit_speed)
total_pulses ≈ 26 over ~130 active ticks
total_damage = 26 × 57 = 1,482 HP (measured)

Measured: Golem (8192 HP) → 6710 HP after poison = 1,482 HP total damage. 26 pulses at exactly 57 HP each, consistent 5-tick intervals.

Dual-effect timeline

Target HP 8192 7736 Zone active (160 ticks / 8 seconds) 8 pulses × 57 HP = 456 total Red dots = pulse events t=160 Tick (1 tick = 50 ms, 20 ticks = 1 second) Speed (u/tick) 19 16 13 10 7 0 normal: 19 4× stacks (steady state) 19 × 40% = 7 u/tick ramp ↓ ramp ↑ 0 50 100 150 180
Figure 4 — Dual-effect timeline over 200 ticks. Top: HP staircase with 8 DOT pulses (57 HP each). Bottom: speed staircase as buff stacks accumulate additively (19 → 16 → 13 → 10 → 7 u/tick), hold at steady state, then ramp back up symmetrically as stacks expire.

Buff-based architecture

Both the DOT and the slow are carried by a single buff object attached to the target entity. The spell zone applies the buff; the buff's tick_buffs() logic handles the per-tick effects:

// entities.rs — tick_buffs(), pulsed DOT mechanism
if buff.damage_hit_interval > 0 {
    buff.damage_hit_timer -= 1;
    if buff.damage_hit_timer <= 0 {
        buff.damage_hit_timer = buff.damage_hit_interval;  // reset to 20
        tick_dot = buff.damage_per_tick;                    // 57 HP
    }
}

// Speed reduction is applied via speed_percent on the buff:
//   speed_multiplier = -15 → effective speed = base × (100 + (-15)) / 100 = 85%

This two-layer design (zone → buff → tick effect) is important: the zone only needs to apply the buff once per pulse interval. The buff then handles its own lifecycle, including expiry when remaining_ticks reaches 0.

Timing subtlety: first pulse at tick 20, not tick 1

The buff's damage_hit_timer initializes to max(interval, 1) = 20, not 0. This means the first DOT pulse fires at tick 20 of the buff's life. If a test checks for damage at tick 15, it sees zero — the timer hasn't fired yet. This is consistent with the real game: Poison's first damage tick has a noticeable delay.

0 Zone fires Buff applied 15 timer = 5 no damage yet 20 FIRST PULSE −57 HP 40 60 ··· 160 Zone expires damage_hit_timer: 20 → 19 → 18 → ... → 0 → FIRE
Figure 5 — Pulse timing: the damage_hit_timer counts down from the interval. First damage arrives after the timer expires, not on tick 1. Tests must account for this delay.
Poison on a Golem (8192 HP). HP staircase: 26 pulses of exactly 57 HP at 5-tick intervals. Speed ramps down as buff stacks accumulate, then ramps back up as stacks expire. Full speed recovery after poison ends.
DOT: 26 pulses × 57 HP = 1,482 total. Interval: 5 ticks (250 ms). First pulse at relative tick ~20.
Speed debuff (stacking):
  Baseline: 19 u/tick → tick 30: 20 → tick 35: 16 → tick 40: 13 → tick 45: 10 → tick 50: 7 (fully stacked)
  During poison: 7 u/tick (stable) — 4 concurrent −15% stacks = (100 − 4×15) = 40% of base → 60% reduction
  Recovery: tick 160: 7 → 165: 10 → 170: 13 → 175: 16 → 180: 19 (full recovery ✓)
  The ramp-down mirrors the ramp-up exactly — each step is one buff stack applying or expiring.
PASS — Pulsed DOT (not per-tick) ✓   All pulses = 57 HP ✓   Speed debuff active ✓   Speed recovers after expiry ✓
Stacking mechanic: The poison buff has enable_stacking=True with buff_time=1000ms (20 ticks per stack). New stacks are applied every 5 ticks (the pulse interval). Since each stack lasts 20 ticks and a new one arrives every 5, up to 4 stacks overlap at steady state. The engine sums speed_percent from all active buffs additively: effective speed = base × (100 + Σ speed_percent) / 100. With 4 stacks of −15%: speed = base × (100 − 60) / 100 = 40% of base. Measured: 19 × 0.40 = 7.6 → 7 u/tick (integer truncation), matching the observed 60% slow exactly. The measured ramp (19 → 16 → 13 → 10 → 7) decreases by ~3 u/tick per stack — the linear step pattern confirms additive stacking.

3. Validation results

RESULTS: 42 passed, 0 failed out of 42 assertions

Charge mechanics:
  ✓ Normal hit = 627, charge hit = 1254 (exact 2×)
  ✓ Speed 29 → 59 u/tick (step function at 300 iu walked)
  ✓ Speed returns to base after charge hit connects
  ✓ Stun cancels charge (first 3 ticks post-stun: base speed)
  ✓ Splash charge hits ≥ 2 targets (Dark Prince, radius=1100)
  ✓ Charging entity covers ≥ 40% more ground than non-charging
  ✓ Charge activates with no enemy on the map

Poison:
  ✓ Pulsed DOT: 26 × 57 HP at 5-tick (250 ms) intervals
  ✓ Speed debuff: stacking −15% per buff (4 stacks = 60% total slow)
  ✓ Speed ramp: 19 → 16 → 13 → 10 → 7 u/tick (one stack per 5-tick pulse)
  ✓ Both slow + DOT active simultaneously during zone lifetime
  ✓ Speed fully recovers after poison expires (7 → 10 → 13 → 16 → 19)

Targeting:
  ✓ Sticky targeting (keeps original target when closer enemy spawns)
  ✓ Nearest-first (closer full-HP target chosen over farther damaged one)
  ✓ target_lowest_hp=False confirmed behaviorally