26 KiB
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:
- Remove
_fire_volley(), cannon timer, volley size, projectile spawning - Add growth tick timer, candidate scoring, weighted cell selection
- Add candy bubble system (spawn, grow, explode)
- Add movement buffer detection and decay
- Add layer-based priority logic
- 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:
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):
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:
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.
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.
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.
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.
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.
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
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
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
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
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
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
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
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
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
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
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.
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:
# 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)
- Update arena to 24×24 — Modify
_setup_arena(), updateNPC_CENTER, update_build_arena_layers() - Replace cannon with growth tick — Remove
_fire_volley(), add_process_growth_tick(),_generate_candidates(),_calculate_candidate_score() - Weighted cell selection —
_select_cells_weighted(), sticky application, A* cost update - Movement buffer system —
_detect_movement_buffers(),_decay_movement_buffers(), buffer penalty in scoring - Path safety check —
_apply_path_safety(),_has_reachable_safe_cell(), replace unsafe selections - Candy bubble system — Bubble timer,
_try_spawn_bubble(), bubble scoring,_explode_bubble() - Camping detection —
_update_camping_tracker(), camping score in candidate and bubble scoring - Update HUD — Growth tick indicator, bubble warning, phase label
- Network sync — New RPCs for growth telegraph, bubble spawn/explode
- Bot AI — Sticky avoidance, pathfinding through sticky, cleanser usage
- Polish — VFX for growth ticks, bubble animations, screen shake on explosion, sound effects
- Update lobby settings — Replace cannon/volley settings with growth settings in
lobby_manager.gdandmode_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 |