# Candy Pump Survival (Gauntlet) — Technical Implementation Plan ## 1. Feasibility Summary **Verdict: Feasible.** The existing codebase provides ~70% of the infrastructure needed. The game mode architecture is modular — each mode has its own manager (`StopNGoManager`, `PortalModeManager`) that handles arena setup, HUD, phase logic, and win conditions. A new `GauntletManager` follows this identical pattern. ### Reuse Breakdown | GDD Feature | Existing System | Reuse Level | New Work | |---|---|---|---| | Game Mode registration | `GameMode.gd` enum + `LobbyManager` | **Direct** | Already registered (`GAUNTLET = 3`) | | 24×24 Arena setup | `StopNGoManager._setup_arena()` pattern | **Heavy** | Custom layout, same GridMap API | | Tile collection / scoring | `GoalsCycleManager` | **Direct** | Reuse goal completion + scoring | | Mission system (goals) | `GoalManager` + `goals_cycle_manager.gd` | **Direct** | Same 3×3 pattern matching | | Timed match (3 min) | `GoalsCycleManager.start_match()` | **Direct** | Pass 180s duration | | Player movement | `PlayerMovementManager` | **Direct** | Add sticky checks to `simple_move_to()` | | Sticky cells | `StopNGoManager` safe zone overlay (Layer 2) | **Pattern** | New tile type, same GridMap layer approach | | Telegraph VFX | Existing GauntletManager telegraph system | **Direct** | Adapt for growth ticks instead of cannon | | Smack mechanic | Existing GauntletManager smack system | **Direct** | Already implemented | | Cleanser power-up | Existing GauntletManager cleanser system | **Direct** | Already implemented | | HUD | `StopNGoManager._setup_hud()` pattern | **Direct** | Mode-specific labels | | Network sync | RPC patterns throughout codebase | **Direct** | Same `rpc()` / `sync_*` patterns | | Bot AI | `BotController` + `BotStrategicPlanner` | **Adapt** | New strategy for sticky avoidance | | Candy bubbles | **NEW** | **New** | Bubble spawn, grow, explode system | | Candidate scoring | **NEW** | **New** | Cellular-automation growth algorithm | | Movement buffers | **NEW** | **New** | Hidden safe zone detection/decay | ### What Changes from Current Implementation The current `GauntletManager` uses a **cannon shooting** model (NPC fires projectiles at targets). The new GDD replaces this with a **ground growth** model (candy spreads from the ground via cellular-automation scoring). This requires: 1. **Remove** `_fire_volley()`, cannon timer, volley size, projectile spawning 2. **Add** growth tick timer, candidate scoring, weighted cell selection 3. **Add** candy bubble system (spawn, grow, explode) 4. **Add** movement buffer detection and decay 5. **Add** layer-based priority logic 6. **Change** arena from 20×20 to 24×24 --- ## 2. Architecture Overview ``` main.gd ├── _init_managers() ← GauntletManager instantiation (existing) ├── _setup_host_game() ← GauntletManager._setup_arena() ├── _start_game() ← GauntletManager.start_game_mode() │ GauntletManager (MODIFY EXISTING) ├── _setup_arena() ← 24×24 grid, center 3×3 NPC zone ├── _setup_hud() ← Mission label, cleanser indicator ├── start_game_mode() ← Start growth timer, spawn tiles ├── _process() ← Growth tick timer, bubble timer, phase escalation ├── GrowthTick system ← Candidate scoring, weighted selection, telegraph ├── CandyBubble system ← Bubble spawn, grow, explode ├── StickyCell system ← Layer 2 overlay, trap logic ├── MovementBuffer system ← Hidden safe zone detection, decay, camping override ├── Cleanser system ← Existing powerup ├── Smack system ← Existing modified push └── Win condition ← Highest score at timer end ``` --- ## 3. File-by-File Implementation ### 3.1 Game Mode Registration — Already Done The existing `game_mode.gd` already has: ```gdscript enum Mode { FREEMODE = 0, STOP_N_GO = 1, TEKTON_DOORS = 2, GAUNTLET = 3 # Already registered } ``` And `LobbyManager` already has `"Candy Cannon Survival"` in `available_game_modes`. The mode name string can remain as-is or be updated to `"Candy Pump Survival"` if desired. --- ### 3.2 Core Manager — `gauntlet_manager.gd` (MODIFY EXISTING) **Location:** `scripts/managers/gauntlet_manager.gd` **Major structural changes:** #### Remove (cannon-based system): ``` var cannon_timer: float var cannon_interval: float var volley_size: int var last_targeted_player_id: int func _fire_volley() func _select_targets() func _get_near_player_target() func _get_route_blocking_target() func _get_random_non_sticky_target() func _get_random_target() ``` #### Add (growth-based system): ```gdscript class_name GauntletManager extends Node # Signals signal phase_changed(phase_index: int) signal growth_tick(targets: Array) signal player_trapped(player_id: int) signal cleanser_granted(player_id: int) signal bubble_spawned(center: Vector2i) signal bubble_exploded(center: Vector2i, area: Array[Vector2i]) # Constants const ARENA_SIZE = 24 const NPC_SIZE = 3 const NPC_CENTER = Vector2i(11, 11) # Center of 24×24 const TILE_STICKY = 17 const TILE_TELEGRAPH = 18 const TILE_WALKABLE = 0 const TILE_OBSTACLE = 4 # Phase timing enum Phase { OUTER_PRESSURE, MIDDLE_PRESSURE, INNER_SURVIVAL } var current_phase: Phase = Phase.OUTER_PRESSURE var elapsed_time: float = 0.0 # Growth tick state var growth_timer: float = 0.0 var growth_interval: float = 3.0 var telegraph_duration: float = 1.0 var sticky_cells: Dictionary = {} # Vector2i -> true var telegraphed_cells: Dictionary = {} # Vector2i -> true # Phase-based growth config var phase_growth_config: Array = [ {"cells_per_tick": [4, 6], "distribution": {"outer": 0.75, "middle": 0.10, "inner": 0.00, "near_player": 0.10, "random": 0.05}}, {"cells_per_tick": [6, 8], "distribution": {"outer": 0.20, "middle": 0.50, "inner": 0.00, "near_player": 0.15, "sticky_expansion": 0.10, "random": 0.05}}, {"cells_per_tick": [8, 10], "distribution": {"outer": 0.10, "middle": 0.25, "inner": 0.35, "near_player": 0.15, "sticky_expansion": 0.15, "random": 0.10}}, ] # Candy bubble state var bubble_timer: float = 0.0 var bubbles_this_phase: int = 0 var max_bubbles_per_phase: Array = [0, 2, 3] var active_bubbles: Array = [] # [{center, grow_timer, warning_area}] var recent_bubble_positions: Array = [] # For RepetitionPenalty # Movement buffer state var movement_buffers: Dictionary = {} # Vector2i -> {penalty: float, created_at: float} var camping_tracker: Dictionary = {} # player_id -> {position: Vector2i, since: float} # Smack state (per-player) — unchanged var smack_cooldowns: Dictionary = {} var smack_charged: Dictionary = {} # Cleanser tracking — unchanged var player_mission_completions: Dictionary = {} var player_cleansers: Dictionary = {} # Trapped players — unchanged var trapped_players: Dictionary = {} # Arena layer cache var arena_layers: Dictionary = {} # Vector2i -> "outer"/"middle"/"inner" ``` --- ### 3.3 Arena Setup — `_setup_arena()` **Pattern source:** `StopNGoManager._setup_arena()` Key changes from 20×20 to 24×24: ```gdscript func _setup_arena(): if not multiplayer.is_server(): return # Resize gridmap to 24×24 enhanced_gridmap.columns = ARENA_SIZE enhanced_gridmap.rows = ARENA_SIZE enhanced_gridmap.floors = 3 # Clear all layers enhanced_gridmap.clear_floor(0) enhanced_gridmap.clear_floor(1) enhanced_gridmap.clear_floor(2) # Fill Floor 0 with walkable tiles for x in range(ARENA_SIZE): for z in range(ARENA_SIZE): enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) # Block center 3×3 for Candy Pump NPC for x in range(NPC_CENTER.x - 1, NPC_CENTER.x + 2): for z in range(NPC_CENTER.y - 1, NPC_CENTER.y + 2): enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE) # Build arena layer map _build_arena_layers() # Sync to clients rpc("sync_arena_setup", ARENA_SIZE, NPC_CENTER) enhanced_gridmap.initialize_astar() enhanced_gridmap.update_astar_costs() ``` --- ### 3.4 Layer Calculation — `_build_arena_layers()` **New method.** Precomputes the layer for every cell based on edge distance. ```gdscript func _build_arena_layers(): arena_layers.clear() for x in range(ARENA_SIZE): for z in range(ARENA_SIZE): var edge_dist = mini(x, z, ARENA_SIZE - 1 - x, ARENA_SIZE - 1 - z) var layer: String if edge_dist <= 3: layer = "outer" elif edge_dist <= 7: layer = "middle" else: layer = "inner" arena_layers[Vector2i(x, z)] = layer ``` --- ### 3.5 Growth Tick System — `_process_growth_tick()` **Replaces** `_fire_volley()`. Called every 3 seconds. ```gdscript func _process_growth_tick(): if not multiplayer.is_server(): return var config = phase_growth_config[current_phase] var cell_count = randi_range(config.cells_per_tick[0], config.cells_per_tick[1]) var candidates = _generate_candidates() var selected = _select_cells_weighted(candidates, cell_count) # Path safety check selected = _apply_path_safety(selected) # Movement buffer check selected = _apply_movement_buffer_check(selected) # Telegraph _telegraph_cells(selected) # After telegraph_duration: apply sticky get_tree().create_timer(telegraph_duration).timeout.connect(func(): _apply_sticky_cells(selected) ) ``` --- ### 3.6 Candidate Generation — `_generate_candidates()` **New method.** Builds scored list of all SAFE cells. ```gdscript func _generate_candidates() -> Array: var candidates: Array = [] var players = get_tree().get_nodes_in_group("Players") for x in range(ARENA_SIZE): for z in range(ARENA_SIZE): var pos = Vector2i(x, z) if not _is_cell_valid_for_growth(pos): continue var score = _calculate_candidate_score(pos, players) candidates.append({"pos": pos, "score": score}) return candidates ``` --- ### 3.7 Candidate Scoring — `_calculate_candidate_score()` **New method.** Implements the full Candidate Score formula from the GDD. ```gdscript func _calculate_candidate_score(pos: Vector2i, players: Array) -> float: var score: float = 0.0 # LayerPriority var layer = arena_layers.get(pos, "outer") var layer_scores = { Phase.OUTER_PRESSURE: {"outer": 60.0, "middle": 15.0, "inner": -40.0}, Phase.MIDDLE_PRESSURE: {"outer": 20.0, "middle": 60.0, "inner": 5.0}, Phase.INNER_SURVIVAL: {"outer": 10.0, "middle": 35.0, "inner": 60.0}, } score += layer_scores[current_phase].get(layer, 0.0) # StickyNeighborScore (+8 per sticky neighbor, max +64) var neighbors = _get_8_neighbors(pos) for n in neighbors: if sticky_cells.has(n): score += 8.0 # InwardPressureScore var center_dist = pos.distance_to(Vector2(NPC_CENTER)) var max_dist = Vector2(ARENA_SIZE, ARENA_SIZE).length() / 2.0 var inward_ratio = 1.0 - (center_dist / max_dist) match current_phase: Phase.OUTER_PRESSURE: score += lerpf(0.0, 10.0, inward_ratio) Phase.MIDDLE_PRESSURE: score += lerpf(5.0, 20.0, inward_ratio) Phase.INNER_SURVIVAL: score += lerpf(10.0, 30.0, inward_ratio) # PlayerPressureScore var min_player_dist = INF for p in players: var p_pos = Vector2i(p.grid_position.x, p.grid_position.z) if p.has_method("get_grid_position") else Vector2i(p.position.x, p.position.z) var dist = pos.distance_to(p_pos) min_player_dist = mini(min_player_dist, int(dist)) if min_player_dist >= 2 and min_player_dist <= 4: score += 20.0 elif min_player_dist == 0: if elapsed_time < 150.0: # Before final 30s score -= 50.0 else: score += 10.0 # ClusterGrowthScore if _connects_sticky_clusters(pos): score += 25.0 elif _expands_sticky_cluster(pos): score += 15.0 # RoutePressureScore if _is_high_traffic_route(pos): score += randf_range(10.0, 25.0) # CampingPressureScore for pid in camping_tracker: var camp = camping_tracker[pid] if pos.distance_to(camp.position) <= 4: var camp_duration = elapsed_time - camp.since if camp_duration > 10.0 and player_cleansers.get(pid, 0) > 0: score += 60.0 elif camp_duration > 8.0: score += 40.0 elif camp_duration > 5.0: score += 20.0 # RandomNoise score += randf_range(-20.0, 20.0) # MovementBufferPenalty if movement_buffers.has(pos): var buffer = movement_buffers[pos] var penalty = _get_buffer_penalty(buffer.penalty) score += penalty # PathSafetyPenalty if _would_trap_player(pos) and elapsed_time < 150.0: score -= 100.0 elif _removes_last_exit(pos): score -= 60.0 elif _makes_route_too_narrow(pos): score -= 20.0 # RepetitionPenalty if _was_recently_targeted(pos): score -= 30.0 elif _region_targeted_repeatedly(pos): score -= 15.0 return score ``` --- ### 3.8 Weighted Cell Selection — `_select_cells_weighted()` **New method.** Selects cells using weighted randomness from scored candidates. ```gdscript func _select_cells_weighted(candidates: Array, count: int) -> Array[Vector2i]: # Sort by score descending candidates.sort_custom(func(a, b): return a.score > b.score) # Build weight array var weights: Array[float] = [] var total_weight: float = 0.0 for c in candidates: var w = maxf(c.score + 100.0, 1.0) # Offset to ensure positive weights weights.append(w) total_weight += w # Weighted random selection without replacement var selected: Array[Vector2i] = [] var available = candidates.duplicate() var available_weights = weights.duplicate() for i in range mini(count, available.size()): var roll = randf() * total_weight var cumulative = 0.0 for j in range(available.size()): cumulative += available_weights[j] if roll <= cumulative: selected.append(available[j].pos) total_weight -= available_weights[j] available.remove_at(j) available_weights.remove_at(j) break return selected ``` --- ### 3.9 Candy Bubble System #### Bubble Spawn Timer ```gdscript func _process_bubbles(delta: float): if not multiplayer.is_server(): return # Tick active bubbles for i in range(active_bubbles.size() - 1, -1, -1): var bubble = active_bubbles[i] bubble.grow_timer -= delta if bubble.grow_timer <= 0: _explode_bubble(bubble) active_bubbles.remove_at(i) ``` #### Bubble Spawn Logic ```gdscript func _try_spawn_bubble(): var max_bubbles = max_bubbles_per_phase[current_phase] if bubbles_this_phase >= max_bubbles: return var candidates = _generate_bubble_candidates() if candidates.is_empty(): return # Weighted selection var selected = _select_bubble_target(candidates) _spawn_bubble(selected) bubbles_this_phase += 1 ``` #### Bubble Candidate Scoring ```gdscript func _generate_bubble_candidates() -> Array: var candidates: Array = [] var players = get_tree().get_nodes_in_group("Players") for x in range(ARENA_SIZE): for z in range(ARENA_SIZE): var pos = Vector2i(x, z) if not _is_cell_valid_for_bubble(pos): continue var score = _calculate_bubble_score(pos, players) candidates.append({"pos": pos, "score": score}) return candidates func _calculate_bubble_score(pos: Vector2i, players: Array) -> float: var score: float = 0.0 # CampingScore for pid in camping_tracker: var camp = camping_tracker[pid] if pos.distance_to(camp.position) <= 4: var camp_duration = elapsed_time - camp.since if camp_duration > 10.0 and player_cleansers.get(pid, 0) > 0: score += 80.0 elif camp_duration > 8.0: score += 60.0 elif camp_duration > 5.0: score += 40.0 # UntouchedAreaScore if _is_near_untouched_cluster(pos): score += 30.0 # PlayerClusterScore var nearby_players = 0 for p in players: var p_pos = Vector2i(p.position.x, p.position.z) if pos.distance_to(p_pos) <= 5: nearby_players += 1 if nearby_players >= 2: score += 20.0 # MissionRouteScore if _is_important_for_scoring(pos): score += randf_range(10.0, 20.0) # RandomNoise score += randf_range(-20.0, 20.0) # DirectHitPenalty for p in players: var p_pos = Vector2i(p.position.x, p.position.z) if pos == p_pos: score -= 60.0 break # RecentBubblePenalty for recent in recent_bubble_positions: if pos.distance_to(recent) <= 5: score -= 50.0 break # UnfairTrapPenalty if _would_create_unfair_trap(pos): score -= 100.0 return score ``` #### Bubble Explosion ```gdscript func _explode_bubble(bubble: Dictionary): var center = bubble.center var explosion_area: Array[Vector2i] = [] for dx in range(-1, 2): for dz in range(-1, 2): var pos = Vector2i(center.x + dx, center.y + dz) if _is_cell_valid_for_growth(pos): explosion_area.append(pos) # Telegraph 3×3 area briefly, then apply sticky _telegraph_cells(explosion_area) get_tree().create_timer(0.5).timeout.connect(func(): _apply_sticky_cells(explosion_area) rpc("sync_bubble_explode", center, explosion_area) recent_bubble_positions.append(center) if recent_bubble_positions.size() > 5: recent_bubble_positions.remove_at(0) ) rpc("sync_bubble_explode_vfx", center) ``` --- ### 3.10 Movement Buffer System #### Buffer Detection ```gdscript func _detect_movement_buffers(): # Find all connected clusters of SAFE cells var visited: Dictionary = {} var clusters: Array = [] for x in range(ARENA_SIZE): for z in range(ARENA_SIZE): var pos = Vector2i(x, z) if visited.has(pos) or not _is_cell_safe(pos): continue var cluster = _flood_fill_safe_cluster(pos, visited) clusters.append(cluster) # Apply buffer penalties to clusters that are critical for movement for cluster in clusters: if _is_critical_for_movement(cluster): for pos in cluster: if not movement_buffers.has(pos): movement_buffers[pos] = {"penalty": 1.0, "created_at": elapsed_time} ``` #### Buffer Decay ```gdscript func _decay_movement_buffers(): var to_remove: Array = [] for pos in movement_buffers: var buffer = movement_buffers[pos] # Every 5 seconds: reduce penalty by 25% var age = elapsed_time - buffer.created_at var decay_cycles = int(age / 5.0) buffer.penalty *= pow(0.75, decay_cycles) # Phase change: reduce by 50% # (Applied once at phase transition, tracked separately) # Final 30s: remove most if elapsed_time > 150.0: buffer.penalty *= 0.1 if buffer.penalty < 0.05: to_remove.append(pos) for pos in to_remove: movement_buffers.erase(pos) ``` #### Camping Detection ```gdscript func _update_camping_tracker(): var players = get_tree().get_nodes_in_group("Players") for p in players: var pid = p.get_multiplayer_authority() var p_pos = Vector2i(p.position.x, p.position.z) if camping_tracker.has(pid): var camp = camping_tracker[pid] if p_pos == camp.position: pass # Still camping else: camping_tracker[pid] = {"position": p_pos, "since": elapsed_time} else: camping_tracker[pid] = {"position": p_pos, "since": elapsed_time} ``` --- ### 3.11 Sticky Cell Application ```gdscript func _apply_sticky_cells(positions: Array[Vector2i]): for pos in positions: if not _is_cell_valid_for_growth(pos): continue sticky_cells[pos] = true telegraphed_cells.erase(pos) # Set Layer 2 overlay enhanced_gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY) # Check if any player is now on sticky _check_players_on_sticky() # Update A* costs enhanced_gridmap.update_astar_costs() # Sync to clients rpc("sync_sticky_cells", sticky_cells.keys()) ``` --- ### 3.12 Player Sticky Check ```gdscript func _check_players_on_sticky(): var players = get_tree().get_nodes_in_group("Players") for p in players: var p_pos = Vector2i(p.position.x, p.position.z) if sticky_cells.has(p_pos): var pid = p.get_multiplayer_authority() if is_cleanser_active(pid): clear_sticky_cell(p_pos) use_cleanser_cell(pid) else: _trap_player(p) ``` --- ### 3.13 Path Safety Check ```gdscript func _apply_path_safety(selected: Array[Vector2i]) -> Array[Vector2i]: if elapsed_time > 150.0: # Final 30s: softer rules return selected var players = get_tree().get_nodes_in_group("Players") var result = selected.duplicate() for p in players: var pid = p.get_multiplayer_authority() if trapped_players.has(pid): continue var p_pos = Vector2i(p.position.x, p.position.z) # Temporarily apply selected cells var temp_sticky = sticky_cells.duplicate() for pos in result: temp_sticky[pos] = true # Check if player has reachable safe cells within 6–8 cells var has_escape = _has_reachable_safe_cell(p_pos, temp_sticky, 8) if not has_escape: # Replace some cells with safer alternatives result = _replace_with_safer_candidates(result, 2) return result ``` --- ### 3.14 Telegraph System (Modified) The existing telegraph system works but needs adaptation for growth ticks instead of cannon volleys. ```gdscript func _telegraph_cells(positions: Array[Vector2i]): for pos in positions: telegraphed_cells[pos] = true enhanced_gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH) rpc("sync_growth_telegraph", positions) # Animate telegraph _animate_growth_telegraph(positions) ``` **Reuse existing** `_animate_telegraph()` tween pattern from current GauntletManager. --- ### 3.15 Network Sync | Data | Sync Method | Pattern | |---|---|---| | Sticky cells | `rpc("sync_sticky_cells", positions)` | Same as `sync_grid_item` | | Growth telegraph | `rpc("sync_growth_telegraph", positions)` | Same as `sync_telegraph` | | Phase changes | `rpc("sync_gauntlet_phase", phase_idx, elapsed)` | Same as `sync_phase` | | Bubble spawn | `rpc("sync_bubble_spawn", center, grow_duration)` | New RPC | | Bubble explode | `rpc("sync_bubble_explode", center, area)` | New RPC | | Trap state | `player.rpc("sync_trapped", true)` | Same as `sync_stop_freeze` | | Cleanser grant | `rpc("sync_cleanser", peer_id, count)` | Same as `sync_goal_count` | | Smack state | `player.rpc("sync_smack_state", charged)` | Same as `sync_modulate` | --- ### 3.16 Integration Points in `main.gd` The existing integration in `main.gd` already handles GauntletManager. No changes needed unless the mode name string is updated. --- ## 4. New Files Summary | File | Type | Purpose | |---|---|---| | (none) | — | All changes are modifications to existing `gauntlet_manager.gd` | ## 5. Modified Files Summary | File | Changes | |---|---| | `scripts/managers/gauntlet_manager.gd` | **Major rewrite:** Replace cannon system with growth tick system, add candidate scoring, add candy bubble system, add movement buffer system, add layer calculation, change arena to 24×24 | | `scripts/game_mode.gd` | Optionally rename string to `"Candy Pump Survival"` | | `scripts/managers/lobby_manager.gd` | Optionally rename mode string; update settings (remove cannon_interval, volley_size; add growth_interval, cells_per_tick) | | `scripts/mode_config.gd` | Update schema: remove `gauntlet_cannon_interval`, `gauntlet_volley_size`; add `gauntlet_growth_interval`, `gauntlet_cells_per_tick_phase1/2/3` | | `scenes/main.gd` | Update mode string match if renamed | --- ## 6. Helper Methods Required These utility methods need to be added to `gauntlet_manager.gd`: ```gdscript # Cell validation func _is_cell_valid_for_growth(pos: Vector2i) -> bool func _is_cell_valid_for_bubble(pos: Vector2i) -> bool func _is_cell_safe(pos: Vector2i) -> bool # Neighbor queries func _get_8_neighbors(pos: Vector2i) -> Array[Vector2i] func _flood_fill_safe_cluster(start: Vector2i, visited: Dictionary) -> Array[Vector2i] # Cluster analysis func _expands_sticky_cluster(pos: Vector2i) -> bool func _connects_sticky_clusters(pos: Vector2i) -> bool func _is_near_untouched_cluster(pos: Vector2i) -> bool func _is_critical_for_movement(cluster: Array) -> bool # Route analysis func _is_high_traffic_route(pos: Vector2i) -> bool func _is_important_for_scoring(pos: Vector2i) -> bool func _would_trap_player(pos: Vector2i) -> bool func _removes_last_exit(pos: Vector2i) -> bool func _makes_route_too_narrow(pos: Vector2i) -> bool func _would_create_unfair_trap(pos: Vector2i) -> bool func _has_reachable_safe_cell(from: Vector2i, temp_sticky: Dictionary, radius: int) -> bool # Repetition tracking func _was_recently_targeted(pos: Vector2i) -> bool func _region_targeted_repeatedly(pos: Vector2i) -> bool # Bubble helpers func _select_bubble_target(candidates: Array) -> Vector2i func _replace_with_safer_candidates(selected: Array[Vector2i], count: int) -> Array[Vector2i] ``` --- ## 7. Implementation Priority (Recommended Order) 1. **Update arena to 24×24** — Modify `_setup_arena()`, update `NPC_CENTER`, update `_build_arena_layers()` 2. **Replace cannon with growth tick** — Remove `_fire_volley()`, add `_process_growth_tick()`, `_generate_candidates()`, `_calculate_candidate_score()` 3. **Weighted cell selection** — `_select_cells_weighted()`, sticky application, A* cost update 4. **Movement buffer system** — `_detect_movement_buffers()`, `_decay_movement_buffers()`, buffer penalty in scoring 5. **Path safety check** — `_apply_path_safety()`, `_has_reachable_safe_cell()`, replace unsafe selections 6. **Candy bubble system** — Bubble timer, `_try_spawn_bubble()`, bubble scoring, `_explode_bubble()` 7. **Camping detection** — `_update_camping_tracker()`, camping score in candidate and bubble scoring 8. **Update HUD** — Growth tick indicator, bubble warning, phase label 9. **Network sync** — New RPCs for growth telegraph, bubble spawn/explode 10. **Bot AI** — Sticky avoidance, pathfinding through sticky, cleanser usage 11. **Polish** — VFX for growth ticks, bubble animations, screen shake on explosion, sound effects 12. **Update lobby settings** — Replace cannon/volley settings with growth settings in `lobby_manager.gd` and `mode_config.gd` --- ## 8. Risk Assessment | Risk | Mitigation | |---|---| | GridMap Layer 2 conflict with existing freeze/safe overlays | Gauntlet mode is exclusive — no freeze/safe tiles in this mode | | 24×24 grid performance (576 cells + scoring every 3s) | Scoring runs on server only; candidate list is max 567 cells; weighted selection is O(n log n) | | Movement buffer creating invisible safe zones that feel unfair | Buffers decay aggressively; camping override removes them; final 30s removes most; players experience it as "uneven growth" not "protected zones" | | Path safety check preventing any arena pressure | Only triggers when a player would be fully trapped; final 30s disables strict check | | Bubble stacking creating unavoidable traps | RecentBubblePenalty (-50) prevents nearby bubbles; max 5 per round; UnfairTrapPenalty (-100) prevents instant failures | | Candidate scoring feeling too complex to tune | Start with simple weights; each component is independent and tunable; playtest to adjust | | A* pathfinding cost updates every 3s causing lag | `update_astar_costs()` is lightweight (updates existing AStar2D); only runs on server |