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.
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).
| Parameter | JSON field | Value (level 11) | Engine units |
|---|---|---|---|
| Base damage | damage | 627 | HP per hit |
| Charge multiplier | charge_speed_multiplier | 200 | % of base speed |
| Charge distance | charge_range | 300 | internal units (iu) |
| Base speed | speed | 60 → 28 u/tick | after speed_to_units_per_tick() |
| Hit interval | hit_speed | 1400 ms → 28 ticks | ticks 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).
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.
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.
charge_speed_multiplier=200.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).
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).
charge_range)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.
| Parameter | JSON field | Value | Engine units |
|---|---|---|---|
| Damage per second | damage_per_second | 57 | HP/sec |
| Pulse interval | hit_speed (spell) | 250 ms | 5 ticks |
| Zone duration | life_duration | 8000 ms | 160 ticks |
| Speed multiplier | speed_multiplier | −15 | −15% of base speed |
| Target filter | only_enemies | true | Only affects opponent's entities |
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_second)hit_speed)Measured: Golem (8192 HP) → 6710 HP after poison = 1,482 HP total damage. 26 pulses at exactly 57 HP each, consistent 5-tick intervals.
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.
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.
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.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