class_name SimCore extends RefCounted ## The server-authoritative simulation: a deterministic, side-effect-free, ## fixed-timestep step function. ## ## It owns its own clock (a constant tick delta) so the same core can be driven ## by the local game loop, a headless test, a bot, or — later — the network ## layer, and always advances identically for identical input. Keep it free of ## any rendering, engine-input, or global-state coupling. const TICK_RATE := 60 const TICK_DELTA := 1.0 / TICK_RATE ## A hero's body radius for collision — how far its centre is kept from an obstacle's surface. One ## shared value for v1: the mobile non-creep units (heroes) collide as they move; lane creeps march ## uncollided so a wave is never jammed on its own forward tower, and structures never move. Read by ## `apply_movement` here and by the nav grid, so a routed path and the resolve agree. const UNIT_RADIUS := 40.0 ## Tower combat tuning. A tower out-ranges and chips a unit that wanders into it, ## but takes many shots to kill — pressure, not an instant wall. const TOWER_HP := 1000 const TOWER_DAMAGE := 50 const TOWER_RANGE := 500.0 const TOWER_COOLDOWN_TICKS := 60 ## The nexus is a destructible structure with no attack of its own. const NEXUS_HP := 2000 ## Creep tuning. A creep is a fragile melee unit — it dies in a few tower shots, ## but a wave pushing together trades, sieges, and (unopposed) fells a nexus. const CREEP_HP := 100 const CREEP_DAMAGE := 20 const CREEP_RANGE := 150.0 const CREEP_COOLDOWN_TICKS := 30 const CREEP_SPEED := 210.0 ## Wave cadence. Both teams spawn a wave per lane every interval, the first at ## tick 0. Creeps within a wave are strung along the lane so they file out of the ## base rather than stacking on one point. const CREEP_WAVE_INTERVAL_TICKS := 600 const CREEP_PER_WAVE := 3 const CREEP_SPAWN_SPACING := 80.0 ## How close (world units) a creep must come to its target waypoint before it ## switches to the next one — large enough to round a corner without stalling. const WAYPOINT_ARRIVE_RADIUS := 40.0 ## Hero tuning. A hero out-hits a creep and out-ranges one, so a player can clear ## a wave, pressure a tower, and duel the enemy hero — but a tower still out-ranges ## and out-hits a lone hero, so diving one undefended is punished. const HERO_HP := 600 const HERO_DAMAGE := 60 const HERO_RANGE := 250.0 const HERO_COOLDOWN_TICKS := 36 ## How long a slain hero stays down before respawning at its spawn point, full health. A flat ## timer for now (8 s at the tick rate) — short enough that a death is a setback, not a sit-out; ## scaling it with match time is a later tuning pass. A dead hero is kept in the world (not ## erased like a creep) so its id, and this countdown, persist for the client's death screen. const HERO_RESPAWN_TICKS := 8 * TICK_RATE var state: SimState = SimState.new() ## Whether `step` spawns creep waves on its own clock. On for live play and the ## integration tests; focused unit tests switch it off to stay isolated from the ## wave schedule and creep tuning. var spawn_creeps: bool = true var _next_id: int = 1 ## Creates a mobile entity, registers it in the world, and returns its id. ## `hp` of 0 (the default) leaves the entity outside the combat system — it ## cannot be targeted, damaged, or killed. func add_entity(team: int, position: Vector2, move_speed: float, hp: int = 0) -> int: var entity := SimEntity.new(_next_id, team, position, move_speed) entity.max_hp = hp entity.hp = hp return _register(entity) ## Creates a static structure (a tower or nexus) and returns its id. func add_structure( team: int, position: Vector2, hp: int, attack_damage: int, attack_range: float, attack_cooldown_ticks: int, is_nexus: bool = false, ) -> int: var entity := SimEntity.new(_next_id, team, position, 0.0) entity.max_hp = hp entity.hp = hp entity.attack_damage = attack_damage entity.attack_range = attack_range entity.attack_cooldown_ticks = attack_cooldown_ticks entity.is_structure = true entity.is_nexus = is_nexus return _register(entity) ## Populates the arena's structures from the map — each team's four towers (two ringing the ## nexus, two forward down the lanes) plus its destructible nexus. Both teams' structures mirror ## across the map's y = x axis, so the match starts mirror-fair. func spawn_structures() -> void: for team in MapData.NEXUS_POSITIONS.size(): for slot in MapData.tower_positions(team): add_structure(team, slot, TOWER_HP, TOWER_DAMAGE, TOWER_RANGE, TOWER_COOLDOWN_TICKS) add_structure(team, MapData.nexus_for_team(team), NEXUS_HP, 0, 0.0, 0, true) ## Creates a hero — a player- or bot-driven mobile unit that fights with the ## shared combat primitive (it auto-strikes the nearest enemy in range) — and ## returns its id. `move_speed` is set by the driver; combat is fixed tuning. func add_hero(team: int, position: Vector2, move_speed: float) -> int: var entity := SimEntity.new(_next_id, team, position, move_speed) entity.is_hero = true # a hero from birth, so death downs-and-respawns it even before a kit entity.max_hp = HERO_HP entity.hp = HERO_HP entity.spawn_position = position # where it returns after a death entity.attack_damage = HERO_DAMAGE entity.attack_range = HERO_RANGE entity.attack_cooldown_ticks = HERO_COOLDOWN_TICKS return _register(entity) ## Turns an already-spawned hero into an ability caster by equipping a kit from the ## catalog. The hero starts in human form with that form's resource pool full; the ## animal pool waits for the first transform. Kept separate from `add_hero` so a ## bare walking-skeleton hero — and the netcode that spawns one — is unchanged until ## a kit is equipped. A no-op for an unknown hero id or kit. func equip_kit(hero_id: int, kit_id: String) -> void: var hero := state.get_entity(hero_id) if hero == null: return var kit_def := AbilityData.kit(kit_id) if kit_def.is_empty(): return var res: Dictionary = kit_def["resource"] hero.is_hero = true hero.form = AbilitySpec.FORM_HUMAN hero.stance = kit_def.get("stance", AbilityData.STANCE_BRAWL) hero.kit_id = kit_id hero.kit = (kit_def["abilities"] as Dictionary).duplicate(true) hero.form_resource_max = PackedInt32Array( [res[AbilitySpec.FORM_HUMAN]["max"], res[AbilitySpec.FORM_ANIMAL]["max"]] ) hero.form_resource_regen = PackedInt32Array( [res[AbilitySpec.FORM_HUMAN]["regen_ticks"], res[AbilitySpec.FORM_ANIMAL]["regen_ticks"]] ) hero.resource_max = hero.form_resource_max[AbilitySpec.FORM_HUMAN] hero.resource_regen_ticks = hero.form_resource_regen[AbilitySpec.FORM_HUMAN] hero.resource = hero.resource_max hero.resource_regen_counter = 0 hero.ability_cooldowns = {} ## Creates a lane creep at `position` and returns its id. The creep marches ## `lane` toward the enemy nexus and fights with the shared combat primitive. func add_creep(team: int, lane: int, position: Vector2) -> int: var entity := SimEntity.new(_next_id, team, position, CREEP_SPEED) entity.max_hp = CREEP_HP entity.hp = CREEP_HP entity.attack_damage = CREEP_DAMAGE entity.attack_range = CREEP_RANGE entity.attack_cooldown_ticks = CREEP_COOLDOWN_TICKS entity.is_creep = true entity.lane = lane entity.waypoint_index = 1 # heading for the second waypoint; the first is the spawn nexus return _register(entity) ## Advances the world by exactly one tick: spawn waves, revive the dead, move the ## input-driven units, march the creeps, resolve combat, then deaths. `inputs` maps an ## entity id to its InputCommand; an entity with no command holds still. Pure: the ## result is a function of the prior state and `inputs` only (creep waves spawn ## off `state.tick`). Once a nexus has fallen the match is over and step no-ops. func step(inputs: Dictionary) -> void: state.fx_events.clear() # this tick's cast FX only — cleared even on a no-op tick state.hit_events.clear() # this tick's damage numbers state.attack_events.clear() # this tick's auto-attack strikes if state.is_match_over(): return _step_spawning() _step_respawns() _step_movement(inputs) _step_creeps() _step_statuses() _step_abilities(inputs) _step_combat() _resolve_deaths() state.tick += 1 func _register(entity: SimEntity) -> int: var id := _next_id _next_id += 1 state.add_entity(entity) return id func _step_movement(inputs: Dictionary) -> void: for id in state.entities: var entity: SimEntity = state.entities[id] apply_movement(entity, inputs.get(id, null)) ## Advances one entity by a single tick of movement intent: the pure movement ## sub-step, with the diagonal-speed clamp and the bounds clamp. A `null` command ## holds the entity still. The authoritative `_step_movement` runs it over every ## entity; the client's prediction/replay runs it over its own hero alone — so the ## server and a predicting client move a unit by byte-identical math, which is what ## lets client-side reconciliation land exactly on the authoritative position. static func apply_movement(entity: SimEntity, command: InputCommand) -> void: if entity.is_dead(): return # a downed hero holds where it fell — server and the client's prediction alike var move_dir := Vector2.ZERO if command != null: move_dir = command.move_dir if move_dir.length() > 1.0: move_dir = move_dir.normalized() var from := entity.position entity.position += move_dir * entity.current_move_speed() * TICK_DELTA entity.position = MapData.clamp_to_bounds(entity.position) # Resolve a moving unit out of the solid obstacles, keeping the tangential slide along them. The # gate is the same "mobile, non-creep" predicate the client identifies its hero by (main.gd # `_local_hero`), so the decoded snapshot the client predicts on — which carries no is_hero flag — # runs byte-identical math to the server and reconciliation lands exactly. Lane creeps march # uncollided (so a wave never jams on its own forward tower) and a still unit is never shoved off # its spot — collision resolves movement, not placement. if move_dir != Vector2.ZERO and not entity.is_structure and not entity.is_creep: entity.position = MapData.slide(from, entity.position, UNIT_RADIUS) ## On a wave tick, spawns one creep wave per team per lane. Driven off ## `state.tick` so wave timing is part of the authoritative, replayable state. func _step_spawning() -> void: if not spawn_creeps: return if state.tick % CREEP_WAVE_INTERVAL_TICKS != 0: return for team in MapData.NEXUS_POSITIONS.size(): for lane in MapData.lane_count(): _spawn_wave(team, lane) ## Spawns `CREEP_PER_WAVE` creeps for `team` on `lane`, strung forward along the ## first lane segment so they file out of the base instead of stacking. Because ## each lane is its own reflection across the y = x axis, the two teams' waves ## mirror across that axis. func _spawn_wave(team: int, lane: int) -> void: var path := MapData.lane_path(lane, team) var origin := path[0] var forward := Vector2.ZERO if path.size() > 1: forward = (path[1] - origin).normalized() for i in CREEP_PER_WAVE: add_creep(team, lane, origin + forward * (CREEP_SPAWN_SPACING * float(i + 1))) ## Marches every creep along its lane toward the enemy nexus. A creep holds ## position while any enemy is within its attack range (the combat step then ## strikes), otherwise it advances toward its current waypoint, switching to the ## next once it arrives. Movement is capped at the per-tick step so it never ## overshoots — deterministic and replayable like the rest of the core. func _step_creeps() -> void: for id in state.entities: var creep: SimEntity = state.entities[id] if not creep.is_creep: continue if creep.is_stunned(): continue # a stunned creep holds its ground — no march this tick if _nearest_enemy_in_range(creep) != null: continue var path := MapData.lane_path(creep.lane, creep.team) if creep.waypoint_index >= path.size(): creep.waypoint_index = path.size() - 1 var target := path[creep.waypoint_index] var to_target := target - creep.position var dist := to_target.length() var step_dist := creep.move_speed * TICK_DELTA if dist > 0.0: creep.position += to_target / dist * minf(step_dist, dist) if creep.position.distance_to(target) <= WAYPOINT_ARRIVE_RADIUS: if creep.waypoint_index < path.size() - 1: creep.waypoint_index += 1 creep.position = MapData.clamp_to_bounds(creep.position) ## Ages every active status by one tick and applies a venom DOT's bite. For each ## entity carrying a status: a DOT advances its interval counter and, on each interval, ## subtracts its damage; every status counts its duration down and is dropped when it ## expires. A SLOW and a STUN do nothing here — the movement, cast, and combat steps read ## the live status off the entity each tick — they only age out. Runs before the cast step ## (upkeep first, like resource ## regen) so a status applied this tick begins aging next tick, and before ## `_resolve_deaths` so a lethal DOT, an auto-attack, and an ability all reconcile in ## the one death pass. Pure and insertion-ordered over entities and each entity's ## statuses, so it replays identically. func _step_statuses() -> void: for id in state.entities: var entity: SimEntity = state.entities[id] if entity.statuses.is_empty(): continue var expired: Array[int] = [] for kind in entity.statuses: var s: Dictionary = entity.statuses[kind] if kind == AbilitySpec.STATUS_DOT: s["counter"] += 1 if s["counter"] >= s["interval"]: s["counter"] = 0 entity.hp -= s["power"] _record_damage(entity, s["power"]) s["remaining"] -= 1 if s["remaining"] <= 0: expired.append(kind) for kind in expired: entity.statuses.erase(kind) ## Advances the ability layer one tick. First every hero's passive upkeep — ## resource regen and cooldown decay — which runs regardless of input so pools refill ## and cooldowns elapse while idle. Then any casts requested this tick: a cast is ## gated through AbilityExecutor.can_cast (form, resource, cooldown) and, on success, ## applied and its cost booked. Runs before `_step_combat` so an ability and an ## auto-attack that both finish a unit this tick are reconciled in one death pass. ## Pure and insertion-ordered like the rest of the step. func _step_abilities(inputs: Dictionary) -> void: for id in state.entities: var hero: SimEntity = state.entities[id] if not hero.is_hero or hero.is_dead(): continue # a dead hero neither regens nor decays cooldowns until it respawns _regen_resource(hero) _tick_cooldowns(hero) for id in inputs: var command: InputCommand = inputs[id] if command == null or command.ability_slot < 0: continue var hero: SimEntity = state.get_entity(id) if hero != null and hero.is_hero and not hero.is_dead(): _try_cast(hero, command) ## Restores one resource point once `resource_regen_ticks` ticks have elapsed, ## capped at the form's max. Integer regen on a tick interval keeps the pool ## deterministic; a form with no regen (or a full pool) is left alone. func _regen_resource(hero: SimEntity) -> void: if hero.resource_regen_ticks <= 0 or hero.resource >= hero.resource_max: return hero.resource_regen_counter += 1 if hero.resource_regen_counter >= hero.resource_regen_ticks: hero.resource_regen_counter = 0 hero.resource = mini(hero.resource + 1, hero.resource_max) ## Ticks every live ability cooldown down by one. Keyed by ability id, so a cooldown ## set in one form keeps elapsing while the hero is in the other. func _tick_cooldowns(hero: SimEntity) -> void: for ability_id in hero.ability_cooldowns: var remaining: int = hero.ability_cooldowns[ability_id] if remaining > 0: hero.ability_cooldowns[ability_id] = remaining - 1 ## Resolves the requested slot to an ability of the hero's active form and casts it ## if it is castable. An empty slot, an off-form slot, or a failed gate is a no-op. func _try_cast(hero: SimEntity, command: InputCommand) -> void: var slots: Dictionary = hero.kit.get(hero.form, {}) var ability_id: int = slots.get(command.ability_slot, 0) if ability_id == 0 or not AbilityData.has_ability(ability_id): return var spec := AbilityData.spec(ability_id) if AbilityExecutor.can_cast(hero, spec): AbilityExecutor.execute(state, hero, spec, command) ## Every attacker ticks its cooldown down; when it hits 0 and an enemy is in ## range, it strikes the nearest one and the cooldown resets. Damage is applied ## to the shared entity in deterministic insertion order, so two attackers can ## both land on a target this tick and it dies once, in `_resolve_deaths`. func _step_combat() -> void: for id in state.entities: var attacker: SimEntity = state.entities[id] if attacker.attack_damage <= 0: continue if attacker.is_dead(): continue # a downed hero stops fighting until it respawns if attacker.is_stunned(): continue # a locked unit neither strikes nor ticks its cooldown down if attacker.cooldown > 0: attacker.cooldown -= 1 if attacker.cooldown > 0: continue var target := _nearest_enemy_in_range(attacker) if target == null: continue target.hp -= attacker.attack_damage attacker.cooldown = attacker.attack_cooldown_ticks _record_attack_fx(attacker, target) ## Records an auto-attack for the renderer: a strike from `attacker` to `target`, flagged ## ranged (the renderer flies a projectile) or melee (a close-in impact), plus the damage ## number over the target. A structure or a kiting hero fires; everything else — creeps and ## brawler heroes — hits melee. func _record_attack_fx(attacker: SimEntity, target: SimEntity) -> void: var ranged := ( attacker.is_structure or (attacker.is_hero and attacker.stance == AbilityData.STANCE_KITE) ) state.attack_events.append( {"origin": attacker.position, "target": target.position, "ranged": ranged} ) _record_damage(target, attacker.attack_damage) ## Notes `amount` of damage on a struck entity for the floating-number renderer. A pure ## presentation hint — like `fx_events`, it never feeds the sim or crosses the wire. func _record_damage(entity: SimEntity, amount: int) -> void: state.hit_events.append({"position": entity.position, "amount": amount}) func _nearest_enemy_in_range(attacker: SimEntity) -> SimEntity: var nearest: SimEntity = null var nearest_dist := INF for id in state.entities: var other: SimEntity = state.entities[id] if other.team == attacker.team: continue if other.max_hp <= 0 or other.is_dead(): continue # non-combat entities and downed heroes are not valid targets var dist := attacker.position.distance_to(other.position) if dist <= attacker.attack_range and dist < nearest_dist: nearest_dist = dist nearest = other return nearest ## Reconciles every unit brought to 0 hp this tick. A creep or a structure is erased (a ## felled nexus first deciding the match); a hero is kept in the world but downed — ## marked dead and put on the respawn clock — so its id, position, and countdown persist ## for the client's death screen and `_step_respawns` can revive it in place. A hero ## already counting down is skipped, so it is downed once, not re-killed every tick. func _resolve_deaths() -> void: var dead: Array[int] = [] for id in state.entities: var entity: SimEntity = state.entities[id] if entity.max_hp > 0 and entity.hp <= 0 and not entity.is_dead(): dead.append(id) for id in dead: var entity: SimEntity = state.entities[id] if entity.is_hero: _down_hero(entity) continue if entity.is_nexus and not state.is_match_over(): state.winner = 1 - entity.team state.entities.erase(id) ## Puts a slain hero on the respawn clock instead of erasing it: hp pinned to 0, the ## respawn timer started, and any lingering statuses and auto-attack cooldown cleared so ## nothing carries over the death. `is_dead` now reads true, which makes every acting and ## targeting step skip it until `_step_respawns` revives it. func _down_hero(hero: SimEntity) -> void: hero.hp = 0 hero.respawn_ticks = HERO_RESPAWN_TICKS hero.statuses.clear() hero.cooldown = 0 ## Counts every downed hero's respawn timer down by one tick and revives the hero the tick ## it elapses. Runs near the top of the step so a hero that comes back this tick is alive for ## the rest of it. Pure and insertion-ordered like every other step. func _step_respawns() -> void: for id in state.entities: var hero: SimEntity = state.entities[id] if not hero.is_dead(): continue hero.respawn_ticks -= 1 if hero.respawn_ticks <= 0: _respawn_hero(hero) ## Revives a hero at its spawn point with a full health bar, back in human form with a full ## resource pool and every cooldown cleared — a clean slate, as if freshly seated. `respawn_ticks` ## lands at 0, so `is_dead` reads false and the hero acts again from this tick. A hero with no kit ## (the bare walking skeleton) has empty resource tuning, so the pool simply stays 0. func _respawn_hero(hero: SimEntity) -> void: hero.respawn_ticks = 0 hero.position = hero.spawn_position hero.hp = hero.max_hp hero.cooldown = 0 hero.statuses.clear() hero.ability_cooldowns.clear() hero.form = AbilitySpec.FORM_HUMAN hero.resource_max = hero.form_resource_max[AbilitySpec.FORM_HUMAN] hero.resource_regen_ticks = hero.form_resource_regen[AbilitySpec.FORM_HUMAN] hero.resource = hero.resource_max hero.resource_regen_counter = 0