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

794 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.
```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 |