Files
tekton/docs/gauntlet-technical-implementation.md
T
2026-06-11 18:28:25 +08:00

26 KiB
Raw Permalink Blame History

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:

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 68 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]

  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