experimental: remove Tekton Doors entirely

- Delete portal_mode_manager.gd, portal_door.gd, portal_door.tscn
- Strip all Tekton Doors logic from main.gd, player.gd, lobby.gd,
  lobby_room.gd, lobby_manager.gd, camera_context_manager.gd,
  music_manager.gd, tekton.gd, enhanced_gridmap.gd,
  playerboard_manager.gd, special_tiles_manager.gd
- Remove TK enum (TEKTON_DOORS=2), mode_config schema, arena area
- Update tests: 3 modes instead of 4
- Strip HowToPlay tab from main.tscn
This commit is contained in:
god
2026-07-06 00:18:59 +08:00
parent 0ab00afd37
commit 114748a54f
31 changed files with 4493 additions and 1535 deletions
+1 -2
View File
@@ -55,8 +55,7 @@ func _ready():
# Safety check: Don't auto-randomize if game mode manages its own arena
if not (ResourceLoader.exists("res://scripts/managers/lobby_manager.gd") \
and get_node_or_null("/root/LobbyManager") \
and (get_node("/root/LobbyManager").game_mode == "Stop n Go" \
or get_node("/root/LobbyManager").game_mode == "Tekton Doors")):
and (get_node("/root/LobbyManager").game_mode == "Stop n Go")):
randomize_grid()
validate_item_indices()
+159 -376
View File
@@ -1,422 +1,205 @@
# Steamworks Setup Guide for Tekton Armageddon
This guide explains how to set up Steamworks for Windows, Mac, and Linux builds while using Nakama for mobile platforms (Android/iOS) for leaderboards, achievements, and shop functionality.
> **Current status: Steam builds are not active.** All platforms (Windows, Linux, macOS, Android) use Nakama as their sole backend. GodotSteam GDExtension exists in `addons/godotsteam/` but is **not enabled** in the project. This doc explains the current Nakama-only architecture and how to re-enable Steam if needed in the future.
## Overview
---
- **Desktop (Windows/Mac/Linux)**: Single build that detects Steam at runtime
- If launched through Steam: Steam login available for Nakama registration; all features use Nakama
- If launched standalone: Uses Nakama for all features
- **Mobile (Android/iOS)**: Uses Nakama for all backend services
- **Unified Backend**: All platforms use Nakama for achievements, leaderboards, and shop
- **Steam Integration**: Steam is only used for authentication (auth session ticket for Nakama login)
## Current Architecture (Nakama-Only)
## Prerequisites
### Steamworks Setup
1. **Steam Partner Account**
- You need a Steam Partner account to access Steamworks
- Apply at: https://partner.steamgames.com/
2. **Steam App ID**
- Create a new app in Steamworks
- Note your App ID (e.g., 480)
- Update `steam_app_id` in `scripts/services/steamworks_manager.gd`
3. **Steamworks SDK**
- Download the Steamworks SDK from Steamworks
- The GodotSteam plugin includes the SDK, but you may need it for reference
## Installation Steps
### 1. Install GodotSteam GDExtension
**Option A: Via Godot Asset Library (Recommended)**
1. Open Godot Editor
2. Go to `Project > Asset Library`
3. Search for "GodotSteam GDExtension 4.4+"
4. Download and install the plugin
5. Restart Godot Editor
**Option B: Manual Installation**
1. Download from: https://codeberg.org/godotsteam/godotsteam/releases
2. Download version 4.18.1 (compatible with Godot 4.6.2)
3. Extract to `addons/godotsteam/` in your project
4. Enable the plugin in `Project > Project Settings > Plugins`
### 2. Configure Steamworks in Project
1. **Enable the Plugin**
- Go to `Project > Project Settings > Plugins`
- Enable "GodotSteam"
2. **Set Steam App ID**
- Edit `scripts/services/steamworks_manager.gd`
- Change `steam_app_id` to your Steam App ID:
```gdscript
var steam_app_id: int = YOUR_APP_ID_HERE
```
3. **Add BackendService as Autoload**
- Go to `Project > Project Settings > Autoload`
- Add `BackendService` with path: `res://scripts/services/backend_service.gd`
- Enable it as a singleton
### 3. Configure Steamworks Features
#### Achievements
1. **Define Achievements in Steamworks**
- Go to Steamworks > Your App > Achievements
- Create achievements with API names (e.g., "first_win", "level_10")
- Set display names, descriptions, and icons
2. **Use in Code**
```gdscript
# Unlock an achievement
BackendService.unlock_achievement("first_win")
# Set progress (for progress-based achievements)
BackendService.set_achievement_progress("kill_100_enemies", current_kills, 100)
# Check achievement status
var progress = BackendService.get_achievement_progress("first_win")
```
#### Leaderboards
1. **Define Leaderboards in Steamworks**
- Go to Steamworks > Your App > Leaderboards
- Create leaderboards with API names (e.g., "high_score", "fastest_time")
- Set sort order (ascending/descending) and display type
2. **Use in Code**
```gdscript
# Submit a score
BackendService.submit_leaderboard_score("high_score", 1000)
# Get leaderboard entries
BackendService.leaderboard_entries_loaded.connect(_on_leaderboard_loaded)
BackendService.get_leaderboard_entries("high_score", 1, 10)
func _on_leaderboard_loaded(leaderboard_id: String, entries: Array):
for entry in entries:
print("Player: %s, Score: %d" % [entry.player_name, entry.score])
```
#### Shop (Steam Inventory)
**Note**: Steam shop functionality requires additional setup with Steam Inventory Service or Steam Microtransactions. This is a complex feature that requires:
1. **Steam Inventory Service Setup**
- Define items in Steamworks > Your App > Inventory
- Set item types, prices, and properties
- Implement purchase callbacks in `steamworks_manager.gd`
2. **Alternative**: Use external payment processor for desktop and sync with Nakama
### 4. Export Presets
The project includes export presets for all platforms:
#### Desktop Builds (single build for Steam and standalone)
- **Windows Desktop** (preset.0) → `build/tekton_armageddon_v2.1.7.exe`
- **macOS** (preset.2) → `build/tekton_armageddon_v2.1.7.zip`
- **Linux/X11** (preset.3) → `build/tekton_armageddon_v2.1.7.x86_64`
#### Mobile Builds
- **Android** (preset.1) → `build/tekton-dash-armageddon-v.2.1.5.apk`
**Note**: Desktop builds are universal - the same executable works on both Steam and standalone. The game detects whether it's running through Steam at runtime and switches backends accordingly.
#### Configure macOS Export
1. **Code Signing** (for distribution)
- Get an Apple Developer certificate
- Update `codesign/identity` in export preset
- Set `codesign/enable` to `true`
2. **Architecture**
- Currently set to "universal" (Intel + Apple Silicon)
- Can be changed to "x86_64" or "arm64" if needed
#### Configure Linux Export
1. **Architecture**
- Currently set to "x86_64"
- Add ARM64 preset if needed for Linux ARM devices
### 5. Platform Detection
The `BackendService` automatically detects the platform and backend:
```gdscript
# Detection logic in BackendService
if OS.has_feature("android") or OS.has_feature("ios"):
# Mobile → Nakama
elif OS.has_feature("steam"):
# Desktop → Steamworks
else:
# Desktop → Local storage (non-Steam builds)
```
┌──────────────────────────────────────────────────┐
│ Game Client │
│ ┌──────────────────────────────────────────┐ │
│ │ BackendService (autoload) │ │
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ NakamaManager│ │steamworks_manager│ │ │
│ │ │ (active) │ │ (dormant) │ │ │
│ │ └─────────────┘ └──────────────────┘ │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────┘
┌────────▼────────┐
│ Nakama Server │
│ (RPC backend) │
│ achievements │
│ leaderboards │
│ shop/economy │
│ auth │
│ multiplayer │
└─────────────────┘
```
You can check the current platform:
- **All backends use Nakama** — achievements, leaderboards, shop, authentication, multiplayer.
- **Platform detection** (`BackendService._detect_platform()`) distinguishes:
- `MOBILE_NAKAMA` — Android/iOS (`OS.has_feature("android")` or `OS.has_feature("ios")`)
- `DESKTOP_NAKAMA` — Windows/Linux/macOS (default when GodotSteam not loaded)
- `DESKTOP_STEAM` — only activated if `ClassDB.class_exists("Steam")` at runtime
- **GodotSteam GDExtension** is present in `addons/godotsteam/` but **not enabled** in Project Settings > Plugins. It is shipped in the CI export (Windows only, `|| true` on copy failure) but does not execute.
- **No Steam App ID configured.** The `steam_app_id` in `steamworks_manager.gd` defaults to `480` (generic test ID) via `ProjectSettings.get_setting("steam/initialization/app_id", 480)`.
- **No Steam Partner account, no Steamworks app, no Steam build pipelines** are active.
---
## Export Presets
The project has 4 export presets in `export_presets.cfg`:
| Preset | Name | Platform | Export Path |
|--------|------|----------|-------------|
| preset.0 | Windows Desktop | Windows | `build/windows/tekton_armageddon_v2.4.3.exe` |
| preset.1 | Android | Android | `build/tekton-dash-armageddon-v.2.4.3.apk` |
| preset.2 | macOS | macOS | `build/tekton_armageddon_v2.4.3.zip` |
| preset.3 | Linux/X11 | Linux | `build/linux/tekton_armageddon_v2.4.3.x86_64` |
No separate Steam vs. Non-Steam presets. All presets produce the same Nakama-only build.
### CI Export Flow (`.gitea/workflows/ci.yml`)
On tag push (`v*`), the CI pipeline:
1. **Checkout** code at tag
2. **Setup Godot 4.7** (cached)
3. **Export Windows**`build/windows/tekton_armageddon_windows.exe` (copies `libgodotsteam*` DLLs if present, failure ignored with `|| true`)
4. **Export Linux**`build/linux/tekton_armageddon_linux.x86_64`
5. **Export macOS**`build/macos/tekton_armageddon_macos.zip`
6. **Extract changelog** from `CHANGELOG_DRAFT.md`
7. **Create Gitea release** and upload all 3 platform zips
8. **Publish** release (draft → published)
The CI does **not** upload to Steam. All builds are distributed via Gitea releases (https://git.klud.top/danchie/tekton/releases).
---
## Platform Detection
The `BackendService` autoload (`scripts/services/backend_service.gd`) detects platform at startup:
```gdscript
func _detect_platform() -> void:
if OS.has_feature("android") or OS.has_feature("ios"):
current_platform = Platform.MOBILE_NAKAMA
else:
if ClassDB.class_exists("Steam"):
current_platform = Platform.DESKTOP_STEAM
else:
current_platform = Platform.DESKTOP_NAKAMA
```
Since GodotSteam is not enabled, desktop builds always hit `DESKTOP_NAKAMA`. The `steamworks_manager.gd` is only loaded when `DESKTOP_STEAM` is selected.
Check current platform at runtime:
```gdscript
print("Platform: %s" % BackendService.get_platform_name())
print("Initialized: %s" % BackendService.is_initialized())
```
#### Platform Types
### Platform Types
- **DESKTOP_STEAM**: Running through Steam client (Steam login available, all features use Nakama)
- **DESKTOP_NAKAMA**: Desktop build not running through Steam (uses Nakama)
- **MOBILE_NAKAMA**: Android/iOS (uses Nakama)
| Value | Trigger | Backend |
|-------|---------|---------|
| `DESKTOP_NAKAMA` | Desktop build, GodotSteam not loaded | Nakama only |
| `DESKTOP_STEAM` | Desktop build, GodotSteam loaded + Steam running | Nakama + Steam auth |
| `MOBILE_NAKAMA` | Android/iOS feature detected | Nakama only |
#### Runtime Detection
---
The game automatically detects the launch method:
- If `OS.has_feature("steam")` is true → Steam login available, all features use Nakama
- Otherwise → All features use Nakama
## Local Testing
This means the same desktop build can be:
- Uploaded to Steam (Steam login enabled, all data stored in Nakama)
- Distributed standalone (all data stored in Nakama)
### Nakama Backend (All Platforms)
### Steam Login for Nakama
1. Ensure Nakama server is running locally (see `server/docker-compose.yaml`)
2. Launch the game from Godot Editor (press F5)
3. Log in with email/password or device ID
4. Test achievements, leaderboards, shop via game UI
5. Check console for `"BackendService: Initialized Nakama backend"`
When running through Steam, players can use their Steam account to register or log in to Nakama. This is the **only** Steam integration - all game features (achievements, leaderboards, shop) use Nakama.
**How it works:**
1. Player clicks "Sign in with Steam" button on login screen
2. Game retrieves Steam auth session ticket via Steamworks
3. Auth ticket is sent to Nakama for authentication
4. Nakama validates the ticket with Steam backend
5. If valid, player is logged in/registered to Nakama
6. Player's Steam account is linked to their Nakama account
**Benefits:**
- No password needed for Steam users
- Automatic account creation on first login
- Seamless cross-platform progression (all data in Nakama)
- Steam username is used as display name
- Unified backend across all platforms
**Requirements:**
- Nakama server must be configured with Steam API key
- Steamworks must be initialized (game launched through Steam)
- GodotSteam plugin must support `getAuthSessionTicket()`
**Configuration:**
Set your Steam API key in Nakama server configuration:
```yaml
nakama:
social:
steam:
api_key: "your_steam_api_key"
```
## Nakama Integration for Mobile
### Current Setup
Your project already has Nakama integrated via `addons/com.heroiclabs.nakama/` and `NakamaManager` autoload.
### Connecting to BackendService
The `BackendService` will automatically use Nakama on mobile. You need to implement Nakama-specific methods in `NakamaManager`:
### Force-Specific Platform for Testing
Temporarily override platform in `BackendService._detect_platform()`:
```gdscript
# In NakamaManager.gd, add these signals:
signal achievement_unlocked(achievement_id: String)
signal leaderboard_score_submitted(leaderboard_id: String, score: int, success: bool)
# Implement achievement methods
func unlock_achievement(achievement_id: String):
# Use Nakama's achievement system
var achievement = await client.write_storage_object_async(
session,
NakamaWriteStorageObject.new(
"achievements",
achievement_id,
{"unlocked": true, "timestamp": Time.get_unix_time_from_system()}
)
)
achievement_unlocked.emit(achievement_id)
# Implement leaderboard methods
func submit_leaderboard_score(leaderboard_id: String, score: int):
# Use Nakama's leaderboard system
var result = await client.write_leaderboard_record_async(
session,
leaderboard_id,
score
)
leaderboard_score_submitted.emit(leaderboard_id, score, result != null)
func _detect_platform() -> void:
current_platform = Platform.DESKTOP_NAKAMA # Force desktop Nakama mode
# ... or ...
current_platform = Platform.MOBILE_NAKAMA # Force mobile mode
```
## Testing
### Export and Run Standalone
### Testing Steam Builds
```bash
# Windows
godot --headless --export-release "Windows Desktop" build/windows/standalone.exe
./build/windows/standalone.exe
1. **Export Steam Build**
- Use the "Windows Desktop (Steam)" preset (preset.0)
- Export to `build/steam/tekton_armageddon_v2.1.7.exe`
# Linux
godot --headless --export-release "Linux/X11" build/linux/standalone.x86_64
./build/linux/standalone.x86_64
2. **Upload to Steam**
- Upload the exported build to Steamworks
- Set as the default build for your app
# macOS
godot --headless --export-release "macOS" build/macos/standalone.zip
```
3. **Run through Steam**
- Launch the game via Steam (not directly)
- Steam must be running
- Check console for "SteamworksManager: Steam initialized successfully"
---
4. **Test Achievements**
- Call `BackendService.unlock_achievement("test_achievement")`
- Check Steam overlay (Shift+Tab) to see achievement unlock
## Activating Steam Support (Future)
5. **Test Leaderboards**
- Submit scores via `BackendService.submit_leaderboard_score()`
- View in Steamworks backend or Steam overlay
If Steam distribution is needed later, these are the steps:
### Testing Non-Steam Builds
1. **Get a Steam Partner account** at https://partner.steamgames.com/
2. **Create Steam app** and get an App ID
3. **Enable GodotSteam plugin** in Project > Project Settings > Plugins
4. **Set App ID** in Project Settings > steam > initialization > app_id (or in `steamworks_manager.gd`)
5. **Configure Nakama** to accept Steam auth tickets (set Steam API key in Nakama config)
6. **Add a Steam-specific export preset** or modify existing presets to include Steam SDK redistributables
7. **Update CI pipeline** to upload builds to Steamworks SDK pipelines
8. **Test Steam auth flow**: launch through Steam, verify `"SteamworksManager: Steam initialized"`, verify Steam login → Nakama registration
1. **Export Non-Steam Build**
- Use the "Windows Desktop (Non-Steam)" preset (preset.1)
- Export to `build/standalone/tekton_armageddon_v2.1.7.exe`
### What the Dormant Steam Code Does
2. **Run Directly**
- Run the executable directly (not through Steam)
- Check console for "BackendService: Initialized Nakama backend"
The existing `steamworks_manager.gd` provides:
3. **Test Leaderboards**
- Ensure Nakama server is accessible
- Open the leaderboard panel to fetch rankings
- Submit scores via `UserProfileManager.submit_to_leaderboard()`
- Leaderboards are global (same as mobile)
- `get_auth_session_ticket()` — retrieves Steam auth session ticket for Nakama login
- `get_steam_user_name()` — returns Steam persona name
- `get_steam_user_id()` — returns Steam ID
4. **Test Shop**
- Ensure Nakama server is accessible
- Open the shop panel to fetch catalog
- Purchase items via `UserProfileManager.purchase_item()`
- Shop functionality works the same as mobile
All game features (achievements, leaderboards, shop) are already implemented purely via Nakama RPCs in `BackendService.api_rpc_async()`. Steam would only be used for **authentication** — the rest stays on Nakama.
### Testing Nakama (Mobile)
1. **Run on Mobile Device**
- Export to Android/iOS using the Android preset (preset.2)
- The game will automatically use Nakama backend
- Check logs for "BackendService: Initialized Nakama backend"
2. **Test in Editor**
- To test Nakama in editor, temporarily modify `_detect_platform()`:
```gdscript
func _detect_platform() -> void:
current_platform = Platform.MOBILE_NAKAMA # Force Nakama
```
## Troubleshooting
### Steamworks Not Initializing
**Problem**: "SteamworksManager: Failed to initialize Steam"
**Solutions**:
1. Ensure game is launched through Steam (not directly)
2. Check Steam is running
3. Verify `steam_app_id` is correct
4. Check GodotSteam plugin is enabled in Project Settings
5. Restart Godot Editor after installing plugin
### Achievements Not Unlocking
**Problem**: Achievements don't appear in Steam overlay
**Solutions**:
1. Ensure achievement API names match Steamworks configuration
2. Check `steam.storeStats()` is called after setting achievements
3. Verify achievement is published in Steamworks (not in draft)
4. Test with Steam overlay open (Shift+Tab)
### Leaderboards Not Working
**Problem**: Leaderboard scores not submitting
**Solutions**:
1. Ensure leaderboard exists in Steamworks
2. Check leaderboard API name matches
3. Verify leaderboard is published
4. Check console for error messages
### Platform Detection Issues
**Problem**: Wrong backend being used
**Solutions**:
1. Check OS features: `print(OS.get_supported_features())`
2. Manually override platform in `_detect_platform()` for testing
3. Ensure `BackendService` is added as autoload
---
## File Structure
```
scripts/services/
├── backend_service.gd # Unified interface (autoload)
── steamworks_manager.gd # Steamworks implementation
scripts/
├── backend_service.gd # Unified interface (autoload) — Nakama-only default
── steamworks_manager.gd # Steamworks implementation (dormant, loads only if GodotSteam present)
└── nakama_manager.gd # NakamaManager autoload (active)
export_presets.cfg # Export presets for all platforms
addons/godotsteam/ # GodotSteam GDExtension (installed but not enabled)
addons/com.heroiclabs.nakama/ # Nakama GDSDK (active)
export_presets.cfg # Export presets — all Nakama-only, no Steam presets
.gitea/workflows/ci.yml # CI pipeline — exports Win/Linux/macOS to Gitea releases
docs/STEAMWORKS_SETUP.md # This documentation
```
---
## Key Differences from Previous State
| Aspect | Before | Now |
|--------|--------|-----|
| Steam builds | Active, separate Steam/Non-Steam presets | Dormant, not shipped |
| Backend | Nakama + Steam | Nakama only |
| Export presets | 6+ (Steam + Non-Steam variants) | 4 (all Nakama-only) |
| Desktop platform detection | Steam if `OS.has_feature("steam")` | Nakama unless GodotSteam loaded |
| CI | Uploaded to Steamworks | Gitea releases only |
| Steam App ID | Configured | Default 480 (test), not set |
---
## Additional Resources
- **GodotSteam Documentation**: https://godotsteam.com/
- **Nakama Documentation**: https://heroiclabs.com/docs/nakama/
- **GodotSteam Documentation**: https://godotsteam.com/ (for future reference)
- **GodotSteam GitHub**: https://codeberg.org/godotsteam/godotsteam
- **Steamworks Documentation**: https://partner.steamgames.com/doc/home
- **Nakama Documentation**: https://heroiclabs.com/docs/nakama/
## Next Steps
1. Complete Steam Partner account setup
2. Create Steam app and get App ID
3. Install GodotSteam plugin
4. Configure achievements and leaderboards in Steamworks
5. Implement Nakama methods in `NakamaManager` for mobile
6. Test on all target platforms
7. Set up code signing for macOS distribution
8. Configure Steam Inventory Service if using in-game shop
## Notes
- **Steam builds** only work when launched through Steam client
- **Non-Steam builds** use Nakama for leaderboards and shop (same as mobile)
- **Shop functionality** is available on both Steam (via Steam Inventory) and non-Steam (via Nakama)
- **Non-Steam builds** sync to Nakama server, not Steam
- **Nakama** is already integrated for multiplayer, leaderboards, and shop
- Export presets are organized in separate folders: `build/steam/` and `build/standalone/`
## Build Workflow
### For Steam Distribution
1. Export using Steam presets (preset.0, preset.3, preset.5)
2. Upload builds to Steamworks
3. Configure achievements and leaderboards in Steamworks backend
4. Set build as default in Steamworks
5. Shop uses Steam Inventory Service (requires additional setup)
### For Standalone Distribution (itch.io, GOG, etc.)
1. Export using Non-Steam presets (preset.1, preset.4, preset.6)
2. Distribute the standalone executables
3. Ensure Nakama server is accessible to players
4. Players get global leaderboards and shop via Nakama
5. No Steam integration required
### For Mobile Stores
1. Export using Android preset (preset.2)
2. Upload to Google Play / App Store
3. Ensure Nakama server is configured and accessible
4. Leaderboards and shop work the same as non-Steam desktop
- **CI Workflow**: `.gitea/workflows/ci.yml`
+1 -6
View File
@@ -86,12 +86,7 @@ var sng_go_option: OptionButton
var sng_stop_option: OptionButton
var sng_goals_option: OptionButton
var doors_settings_container: HBoxContainer
var doors_swap_option: OptionButton
var doors_refresh_option: OptionButton
var doors_goals_option: OptionButton
# UI References - Player Slots
# Gauntlet settings
@onready var players_container = $LobbyPanel/PlayersContainer
@onready var players_container2 = $LobbyPanel/PlayersContainer2
@onready var player_slots: Array[Control] = []
+5 -179
View File
@@ -12,10 +12,8 @@ var touch_controls
var camera_context_manager
var stop_n_go_manager
var stop_n_go_winner_id: int = -1 # Track who finished first in Stop n Go mode
var portal_mode_winner_id: int = -1
var is_match_ended: bool = false
var obstacle_manager
var portal_mode_manager
var gauntlet_manager
var vfx_manager
@@ -146,8 +144,6 @@ func _apply_arena_background():
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" # Fallback texture
_instantiate_3d_arena("res://scenes/arena/freemode.tscn")
_hide_ground_tiles()
"Tekton Doors Arena":
texture_path = "res://assets/graphics/level_bg/placeholder_tekton_doors.jpg"
"Gauntlet Arena":
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg"
_instantiate_3d_arena("res://scenes/arena/gauntlet.tscn")
@@ -207,14 +203,6 @@ func _setup_effect_elevation():
em.mesh_library = ml
print("[Main] MeshLibrary elevation applied: Wall(4) and Freeze(5) at Y=0.8")
@rpc("any_peer", "call_local", "reliable")
func sync_portal_configs(configs: Array):
if portal_mode_manager:
# Temporarily store the configs and trigger spawn
# Note: We use a custom property in manager to pass this
portal_mode_manager.set_meta("door_configs", configs)
portal_mode_manager._spawn_portal_doors()
# Force gridmap cell size to match player logic (1, 0.05, 1) - >0.001 to avoid errors
var em = $EnhancedGridMap
if em:
@@ -245,12 +233,6 @@ func _init_managers():
add_child(stop_n_go_manager)
# No direct initialize() yet, but we'll call start_game_mode later
# Portal manager for Tekton Doors mode
if LobbyManager.game_mode == "Tekton Doors":
portal_mode_manager = load("res://scripts/managers/portal_mode_manager.gd").new()
portal_mode_manager.name = "PortalModeManager"
add_child(portal_mode_manager)
portal_mode_manager.initialize(self , $EnhancedGridMap)
# Gauntlet manager for Candy Pump Survival mode
if LobbyManager.game_mode == "Candy Pump Survival":
@@ -619,8 +601,6 @@ func _setup_host_game():
# Spawning and arena setup
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager:
stop_n_go_manager._setup_arena()
elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
portal_mode_manager.setup_arena_locally()
elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
gauntlet_manager._setup_arena()
else:
@@ -725,10 +705,6 @@ func _setup_client_game():
add_player_character(i, true)
print("Client: Pre-spawned potential bot ", i)
# Initialize arena locally for Tekton Doors
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
portal_mode_manager.setup_arena_locally()
# Initialize arena locally for Candy Pump Survival
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
gauntlet_manager._apply_arena_setup()
@@ -833,14 +809,10 @@ func _start_game():
gauntlet_manager.setup_mission_tiles()
# Spawn Static Tektons and random tiles BEFORE countdown (Free Mode Only)
# Exclude for Stop n Go and Tekton Doors
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Pump Survival":
# Exclude for Stop n Go and Candy Pump Survival
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Pump Survival":
spawn_static_tektons()
# Tekton Doors: Randomize connections BEFORE countdown so colors show
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
portal_mode_manager._randomize_connections()
# STOP N GO: Rotate players to face East BEFORE countdown
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager:
stop_n_go_manager.rotate_players_to_start()
@@ -866,13 +838,6 @@ func _start_game():
if goals_cycle_manager:
var match_duration = LobbyManager.get_match_duration()
goals_cycle_manager.start_match(float(match_duration), false) # No cycles for Stop n Go
elif LobbyManager.game_mode == "Tekton Doors":
if portal_mode_manager:
portal_mode_manager.start_game_mode()
if goals_cycle_manager:
var match_duration = LobbyManager.get_match_duration()
goals_cycle_manager.start_match(float(match_duration))
elif LobbyManager.game_mode == "Candy Pump Survival":
if gauntlet_manager:
gauntlet_manager.start_game_mode()
@@ -986,12 +951,6 @@ func _assign_random_spawn_positions():
_assign_stop_n_go_spawn_positions(all_players)
return
# Tekton Doors Custom Spawn Logic
if LobbyManager.game_mode == "Tekton Doors":
var all_players = get_tree().get_nodes_in_group("Players")
_assign_portal_mode_spawn_positions(all_players)
return
# If static positions were not calculated yet, do it now to avoid players spawning in them
if reserved_static_positions.is_empty() and LobbyManager.game_mode != "Stop n Go":
if not static_tekton_manager:
@@ -1116,77 +1075,6 @@ func _assign_stop_n_go_spawn_positions(all_players: Array):
print("[StopNGo] Assigned spawn %s to player %s" % [assigned_pos, player.name])
func _assign_portal_mode_spawn_positions(all_players: Array):
"""Assigns spawns to different quadrants for Tekton Doors mode, avoiding stands and intersections."""
if not portal_mode_manager:
_assign_random_spawn_positions() # Fallback
return
# Sort players for deterministic assignment
all_players.sort_custom(func(a, b): return a.name.to_int() < b.name.to_int())
# Get baseline quadrant centers (3,3), (10,3), etc.
var base_spawn_points = portal_mode_manager.get_spawn_points()
var spawn_index = 0
var assigned_positions: Array[Vector2i] = []
for player in all_players:
var center_pos = base_spawn_points[spawn_index % base_spawn_points.size()]
var assigned_pos = center_pos # Fallback position
# Spiral search for a valid spot (walkable, not in stand zone, not occupied)
var found = false
for radius in range(0, 5): # Increase search radius
for dx in range(-radius, radius + 1):
for dz in range(-radius, radius + 1):
# Only check the "ring" at the current radius
if abs(dx) != radius and abs(dz) != radius and radius > 0:
continue
var test_pos = center_pos + Vector2i(dx, dz)
# 1. Check map bounds
var em = $EnhancedGridMap
if not em or test_pos.x < 0 or test_pos.x >= em.columns or test_pos.y < 0 or test_pos.y >= em.rows:
continue
# 2. Check if walkable floor (Floor 0, ID 0)
if em.get_cell_item(Vector3i(test_pos.x, 0, test_pos.y)) != 0:
continue
# 3. Check if reserved for a Static Tekton Stand (3x3 area, use 2-tile buffer)
var is_reserved = false
for reserved in reserved_static_positions:
if abs(test_pos.x - reserved.x) <= 2 and abs(test_pos.y - reserved.y) <= 2:
is_reserved = true
break
if is_reserved:
continue
# 4. Check if occupied by another already-assigned player
if assigned_positions.has(test_pos):
continue
assigned_pos = test_pos
found = true
break
if found: break
if found: break
assigned_positions.append(assigned_pos)
# Sync and place
player.position = player.grid_to_world(assigned_pos)
player.current_position = assigned_pos
player.is_player_moving = false
player.spawn_point_selected = true
if can_rpc():
player.rpc("set_spawn_position", assigned_pos)
spawn_index += 1
print("[PortalMode] Assigned Quadrant Pos %s to player %s" % [assigned_pos, player.name])
# =============================================================================
# Tekton NPC Management
# =============================================================================
@@ -1639,9 +1527,6 @@ func sync_game_start(player_list: Array, is_turn_based: bool):
stop_n_go_manager.name = "StopNGoManager"
add_child(stop_n_go_manager)
stop_n_go_manager.activate_client_side()
elif LobbyManager.game_mode == "Tekton Doors":
if portal_mode_manager:
portal_mode_manager.activate_client_side()
# Initialize leaderboard for all peers (after a delay to ensure players loaded)
call_deferred("_deferred_init_leaderboard")
@@ -1878,7 +1763,7 @@ func randomize_item_at_position(grid_position: Vector2i):
if is_ground:
var get_mode_specific_tile = func():
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Pump Survival":
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Candy Pump Survival":
# 60% Chance for Common (7-10), 40% for PowerUp
if randf() <= 0.6:
return [7, 8, 9, 10].pick_random()
@@ -1923,14 +1808,7 @@ func sync_grid_item(x: int, y: int, z: int, item: int):
# OR Layer 1 is already a wall (4)
if f0 in [4, -1] or f1 == 4:
return
# TEKTON DOORS: Prevent placing items on portal doors
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS):
var doors = get_tree().get_nodes_in_group("PortalDoors")
for door in doors:
var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position))
if door_grid.x == x and door_grid.z == z:
return
enhanced_gridmap.set_cell_item(Vector3i(x, y, z), item)
# Force visual update
@@ -2098,17 +1976,6 @@ func sync_grid_items_batch(data: Array):
if f0 in [4, -1] or f1 == 4:
continue
# TEKTON DOORS: Prevent placing items on portal doors
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS) and y == 1:
var doors = get_tree().get_nodes_in_group("PortalDoors")
var on_door = false
for door in doors:
var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position))
if door_grid.x == x and door_grid.z == z:
on_door = true
break
if on_door: continue
enhanced_gridmap.set_cell_item(Vector3i(x, y, z), item)
# Force visual update ONCE after batch
@@ -2116,7 +1983,7 @@ func sync_grid_items_batch(data: Array):
enhanced_gridmap.update_grid_data()
func randomize_game_grid():
if LobbyManager.game_mode == "Stop n Go" or LobbyManager.game_mode == "Tekton Doors":
if LobbyManager.game_mode == "Stop n Go":
return # These modes manage their own arena setup and item spawning
var enhanced_gridmap = $EnhancedGridMap
@@ -2177,10 +2044,6 @@ func request_full_grid_sync():
if sender_id in multiplayer.get_peers():
rpc_id(sender_id, "sync_full_grid_data", grid_data)
print("[Main] Server: Sent grid sync rpc_id to %d" % sender_id)
# If Tekton Doors, sync portal connections too
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
portal_mode_manager.sync_to_client(sender_id)
@rpc("authority", "call_local", "reliable")
func sync_full_grid_data(data: PackedInt32Array):
@@ -2197,13 +2060,6 @@ func sync_full_grid_data(data: PackedInt32Array):
stop_n_go_manager.name = "StopNGoManager"
add_child(stop_n_go_manager)
stop_n_go_manager._apply_arena_setup()
elif LobbyManager.game_mode == "Tekton Doors":
if not portal_mode_manager:
portal_mode_manager = load("res://scripts/managers/portal_mode_manager.gd").new()
portal_mode_manager.name = "PortalModeManager"
add_child(portal_mode_manager)
portal_mode_manager.initialize(self , enhanced_gridmap)
portal_mode_manager.setup_arena_locally()
else:
# Freemode: Ensure Floor 0 is entirely walkable (reset stale state from previous modes)
for x in range(enhanced_gridmap.columns):
@@ -2337,27 +2193,6 @@ func sync_game_end_stop_n_go(winner_id: int):
# Trigger match end
_on_match_ended()
@rpc("any_peer", "call_local", "reliable")
func sync_game_end_portal_mode(winner_id: int):
print("[TEKTON DOORS] Game ended! Winner: ", winner_id)
portal_mode_winner_id = winner_id
var winner_name = "Player " + str(winner_id)
var player_node = get_node_or_null(str(winner_id))
if player_node:
winner_name = player_node.display_name
# Broadcast win
add_message_to_bar("MATCH COMPLETE", winner_name + " Wins with 8 Missions!", MessageType.GOAL)
# Stop logic
if portal_mode_manager:
if portal_mode_manager.swap_timer: portal_mode_manager.swap_timer.stop()
if portal_mode_manager.tile_refresh_timer: portal_mode_manager.tile_refresh_timer.stop()
# Trigger match end
_on_match_ended()
func _on_match_ended():
"""Called when the global match timer ends - show game over screen."""
if is_match_ended:
@@ -2422,9 +2257,6 @@ func _show_game_over_panel():
if stop_n_go_manager and stop_n_go_manager.hud_layer:
stop_n_go_manager.hud_layer.hide()
if portal_mode_manager and portal_mode_manager.hud_layer:
portal_mode_manager.hud_layer.hide()
# =========================================================================
# Gather + sort player data
# =========================================================================
@@ -2446,12 +2278,6 @@ func _show_game_over_panel():
if b.peer_id == stop_n_go_winner_id: return false
return a.score > b.score
)
elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_winner_id != -1:
all_player_scores.sort_custom(func(a, b):
if a.peer_id == portal_mode_winner_id: return true
if b.peer_id == portal_mode_winner_id: return false
return a.score > b.score
)
else:
all_player_scores.sort_custom(func(a, b): return a.score > b.score)
-19
View File
@@ -2249,25 +2249,6 @@ text = "[b]Stop n Go[/b]
- Your objective is to reach the mission tiles at the far end of the arena and safely carry them back to your starting zone.
- The first player to complete 8 missions and reach the finish floor wins."
[node name="Tekton Doors" type="MarginContainer" parent="HowToPlayPanel/Panel/VBox/TabContainer" unique_id=123456799]
visible = false
layout_mode = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 10
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 10
metadata/_tab_index = 2
[node name="RichTextLabel" type="RichTextLabel" parent="HowToPlayPanel/Panel/VBox/TabContainer/Tekton Doors" unique_id=123456800]
layout_mode = 2
bbcode_enabled = true
text = "[b]Tekton Doors[/b]
- Navigate a sprawling arena connected by color-coded portal doors.
- Grab tiles and match goal patterns to earn mission completions.
- Use doors to quickly teleport across rooms, but watch out for closures and traps.
- The first player to complete 8 missions and reach the finish room wins."
[node name="Controls" type="MarginContainer" parent="HowToPlayPanel/Panel/VBox/TabContainer" unique_id=123456805]
visible = false
layout_mode = 2
+1 -8
View File
@@ -1767,14 +1767,7 @@ func start_movement_along_path(path: Array, clear_visual: bool = true, force: bo
if sng_manager and sng_manager.has_method("check_win_condition"):
if sng_manager.check_win_condition(name.to_int(), current_position):
sng_main.rpc("sync_game_end_stop_n_go", name.to_int())
# Tekton Doors Win Check
elif LobbyManager.game_mode == "Tekton Doors":
var main_node = get_tree().root.get_node_or_null("Main")
if main_node and main_node.portal_mode_manager:
if main_node.portal_mode_manager.check_win_condition(name.to_int(), current_position):
main_node.rpc("sync_game_end_portal_mode", name.to_int())
# FORCE SNAP: Update target visual position to the perfect grid center
# This ensures that when interpolation resumes (in _process), it pulls to the correct spot
target_visual_position = grid_to_world(current_position)
-92
View File
@@ -1,92 +0,0 @@
[gd_scene load_steps=8 format=3 uid="uid://portal_door_001"]
[ext_resource type="Script" path="res://scripts/portal_door.gd" id="1_script"]
[sub_resource type="BoxMesh" id="BoxMesh_frame"]
size = Vector3(0.15, 2.2, 0.15)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_frame"]
albedo_color = Color(0.1, 0.5, 0.8, 1)
metallic = 0.8
roughness = 0.2
[sub_resource type="PlaneMesh" id="PlaneMesh_ground"]
size = Vector2(1.0, 1.0)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ground"]
transparency = 1
albedo_color = Color(1, 1, 1, 0.4)
emission_enabled = true
emission = Color(1, 1, 1, 1)
emission_energy_multiplier = 1.0
[sub_resource type="PlaneMesh" id="PlaneMesh_vortex"]
size = Vector2(1.4, 2.1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_vortex"]
transparency = 1
albedo_color = Color(0.0, 0.6, 1.0, 0.4)
emission_enabled = true
emission = Color(0.0, 0.4, 1.0, 1)
emission_energy_multiplier = 5.0
[sub_resource type="BoxShape3D" id="BoxShape3D_trigger"]
size = Vector3(1.4, 2.1, 0.8)
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_portal"]
properties/0/path = NodePath(":target_room_id")
properties/0/spawn = true
properties/0/replication_mode = 2
properties/1/path = NodePath(":target_door_id")
properties/1/spawn = true
properties/1/replication_mode = 2
properties/2/path = NodePath(":is_active")
properties/2/spawn = true
properties/2/replication_mode = 2
properties/3/path = NodePath(":portal_color")
properties/3/spawn = true
properties/3/replication_mode = 2
[node name="PortalDoor" type="StaticBody3D"]
script = ExtResource("1_script")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.1, 0)
shape = SubResource("BoxShape3D_trigger")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("SceneReplicationConfig_portal")
[node name="Frame_Left" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.75, 1.1, 0)
mesh = SubResource("BoxMesh_frame")
surface_material_override/0 = SubResource("StandardMaterial3D_frame")
[node name="Frame_Right" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.75, 1.1, 0)
mesh = SubResource("BoxMesh_frame")
surface_material_override/0 = SubResource("StandardMaterial3D_frame")
[node name="Frame_Top" type="MeshInstance3D" parent="."]
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, 0, 2.2, 0)
mesh = SubResource("BoxMesh_frame")
surface_material_override/0 = SubResource("StandardMaterial3D_frame")
[node name="Vortex" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, -4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 1.1, 0)
mesh = SubResource("PlaneMesh_vortex")
surface_material_override/0 = SubResource("StandardMaterial3D_vortex")
[node name="GroundIndicator" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0.4)
mesh = SubResource("PlaneMesh_ground")
surface_material_override/0 = SubResource("StandardMaterial3D_ground")
[node name="Area3D" type="Area3D" parent="."]
collision_layer = 0
collision_mask = 2
[node name="CollisionShape3D" type="CollisionShape3D" parent="Area3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.1, 0)
shape = SubResource("BoxShape3D_trigger")
+2 -18
View File
@@ -51,9 +51,6 @@ func _init(p_lobby: Control):
LobbyManager.sng_go_duration_changed.connect(_on_sng_update)
LobbyManager.sng_stop_duration_changed.connect(_on_sng_update)
LobbyManager.sng_required_goals_changed.connect(_on_sng_update)
LobbyManager.doors_swap_time_changed.connect(_on_doors_update)
LobbyManager.doors_refresh_time_changed.connect(_on_doors_update)
LobbyManager.doors_required_goals_changed.connect(_on_doors_update)
FriendManager.lobby_invite_received.connect(_on_lobby_invite_received)
@@ -136,21 +133,8 @@ func _on_sng_update(_val: int = 0) -> void:
if go_idx != -1: lobby.sng_go_option.selected = go_idx
var stop_idx = [3, 4, 5].find(LobbyManager.sng_stop_duration)
if stop_idx != -1: lobby.sng_stop_option.selected = stop_idx
var goals_idx = [5, 8, 12].find(LobbyManager.sng_required_goals)
if goals_idx != -1: lobby.sng_goals_option.selected = goals_idx
func _on_doors_update(_val: int = 0) -> void:
if not lobby.doors_swap_option: return
var swap_idx = [10, 15, 30].find(LobbyManager.doors_swap_time)
if swap_idx != -1: lobby.doors_swap_option.selected = swap_idx
var refresh_idx = [15, 25, 40].find(LobbyManager.doors_refresh_time)
if refresh_idx != -1: lobby.doors_refresh_option.selected = refresh_idx
var goals_idx = [5, 8, 12].find(LobbyManager.doors_required_goals)
if goals_idx != -1: lobby.doors_goals_option.selected = goals_idx
# =============================================================================
# LobbyManager Signal Handlers
# =============================================================================
var goals_idx2 = [5, 8, 12].find(LobbyManager.sng_required_goals)
if goals_idx2 != -1: lobby.sng_goals_option.selected = goals_idx2
func _on_room_joined(room_data: Dictionary) -> void:
lobby._show_panel("lobby")
+4 -14
View File
@@ -1,12 +1,7 @@
extends RefCounted
class_name GameMode
enum Mode {
FREEMODE = 0,
STOP_N_GO = 1,
TEKTON_DOORS = 2,
GAUNTLET = 3
}
enum Mode { FREEMODE = 0, STOP_N_GO = 1, GAUNTLET = 2 }
static func from_string(mode: String) -> Mode:
match mode:
@@ -14,12 +9,9 @@ static func from_string(mode: String) -> Mode:
return Mode.FREEMODE
"Stop n Go":
return Mode.STOP_N_GO
"Tekton Doors":
return Mode.TEKTON_DOORS
"Candy Pump Survival":
return Mode.GAUNTLET
_:
return Mode.FREEMODE
return Mode.FREEMODE
static func mode_to_string(mode: Mode) -> String:
match mode:
@@ -27,15 +19,13 @@ static func mode_to_string(mode: Mode) -> String:
return "Freemode"
Mode.STOP_N_GO:
return "Stop n Go"
Mode.TEKTON_DOORS:
return "Tekton Doors"
Mode.GAUNTLET:
return "Candy Pump Survival"
_:
return "Freemode"
static func is_restricted(mode: Mode) -> bool:
return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET
return mode == Mode.STOP_N_GO or mode == Mode.GAUNTLET
static func get_all_modes() -> Array[String]:
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival"]
return ["Freemode", "Stop n Go", "Candy Pump Survival"]
+1
View File
@@ -0,0 +1 @@
uid://cosvmn3gh5bxj
@@ -52,9 +52,6 @@ func _calculate_target_position() -> Vector3:
bounds = bounds_gauntlet
elif mode == GameMode.Mode.STOP_N_GO:
bounds = bounds_stop_n_go
elif mode == GameMode.Mode.TEKTON_DOORS:
bounds = bounds_doors
target_y = 32.3 # Doors uses a higher overlook
# Clamp X and Z
target_x = clamp(target_x, bounds.min_x, bounds.max_x)
+1 -1
View File
@@ -2,7 +2,7 @@ extends Node
class_name GauntletManager
# GauntletManager - Handles Candy Pump Survival (Gauntlet) game mode
# Pattern: StopNGoManager + PortalModeManager
# Pattern: StopNGoManager + GauntletManager
signal phase_changed(phase_index: int, phase_name: String)
signal growth_tick(cells: Array)
+1 -3
View File
@@ -62,8 +62,6 @@ func mark_goal_complete(player_id: int):
player_completion_times[player_id].append(duration_sec)
# Reset start time for next goal
player_start_times[player_id] = Time.get_ticks_msec()
# print("Player %s completed goal in %.2fs" % [player_id, duration_sec])
func get_player_average_time(player_id: int) -> float:
if not player_completion_times.has(player_id) or player_completion_times[player_id].is_empty():
@@ -92,7 +90,7 @@ func get_boost_multiplier(player_id: int) -> float:
var p_avg = get_player_average_time(player_id)
var g_avg = get_global_average_time()
if p_avg > g_avg:
if p_avg > g_avg:
# Player is slower than average -> Boost fills faster
# Scale up to 1.5x based on how much slower (capped)
var ratio = p_avg / max(g_avg, 0.1)
-54
View File
@@ -26,11 +26,6 @@ signal sng_go_duration_changed(duration: int)
signal sng_stop_duration_changed(duration: int)
signal sng_required_goals_changed(goals: int)
# Tekton Doors settings signals
signal doors_swap_time_changed(time: int)
signal doors_refresh_time_changed(time: int)
signal doors_required_goals_changed(goals: int)
# Gauntlet settings signals
signal gauntlet_round_duration_changed(duration: int)
signal gauntlet_growth_interval_changed(interval: float)
@@ -74,11 +69,6 @@ var sng_go_duration: int = 20
var sng_stop_duration: int = 4
var sng_required_goals: int = 8
# Tekton Doors settings
var doors_swap_time: int = 15
var doors_refresh_time: int = 25
var doors_required_goals: int = 8
# Gauntlet settings
var gauntlet_round_duration: int = 180
var gauntlet_growth_interval: float = 3.0 # seconds between growth ticks
@@ -522,37 +512,6 @@ func sync_sng_required_goals(goals: int) -> void:
sng_required_goals = goals
emit_signal("sng_required_goals_changed", goals)
# =============================================================================
# Tekton Doors Settings
# =============================================================================
func set_doors_swap_time(time: int) -> void:
doors_swap_time = time
if is_host: rpc("sync_doors_swap_time", time)
@rpc("authority", "call_local", "reliable")
func sync_doors_swap_time(time: int) -> void:
doors_swap_time = time
emit_signal("doors_swap_time_changed", time)
func set_doors_refresh_time(time: int) -> void:
doors_refresh_time = time
if is_host: rpc("sync_doors_refresh_time", time)
@rpc("authority", "call_local", "reliable")
func sync_doors_refresh_time(time: int) -> void:
doors_refresh_time = time
emit_signal("doors_refresh_time_changed", time)
func set_doors_required_goals(goals: int) -> void:
doors_required_goals = goals
if is_host: rpc("sync_doors_required_goals", goals)
@rpc("authority", "call_local", "reliable")
func sync_doors_required_goals(goals: int) -> void:
doors_required_goals = goals
emit_signal("doors_required_goals_changed", goals)
# =============================================================================
# Gauntlet Settings
# =============================================================================
@@ -740,8 +699,6 @@ func set_game_mode(mode: String) -> void:
set_area("Free Mode Area")
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
set_area("Stop n Go Area")
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
set_area("Tekton Doors Area")
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas:
set_area("Gauntlet Arena")
@@ -756,8 +713,6 @@ func sync_game_mode(mode: String) -> void:
selected_area = "Free Mode Area"
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
selected_area = "Stop n Go Area"
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
selected_area = "Tekton Doors Area"
elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas:
selected_area = "Gauntlet Arena"
elif selected_area not in available_areas:
@@ -785,9 +740,6 @@ func start_game(force: bool = false) -> void:
rpc("sync_sng_go_duration", sng_go_duration)
rpc("sync_sng_stop_duration", sng_stop_duration)
rpc("sync_sng_required_goals", sng_required_goals)
rpc("sync_doors_swap_time", doors_swap_time)
rpc("sync_doors_refresh_time", doors_refresh_time)
rpc("sync_doors_required_goals", doors_required_goals)
# Sync gauntlet settings
rpc("sync_gauntlet_round_duration", gauntlet_round_duration)
rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval)
@@ -864,9 +816,6 @@ func request_room_info(requester_id: int, requester_name: String, requester_char
rpc_id(requester_id, "sync_sng_go_duration", sng_go_duration)
rpc_id(requester_id, "sync_sng_stop_duration", sng_stop_duration)
rpc_id(requester_id, "sync_sng_required_goals", sng_required_goals)
rpc_id(requester_id, "sync_doors_swap_time", doors_swap_time)
rpc_id(requester_id, "sync_doors_refresh_time", doors_refresh_time)
rpc_id(requester_id, "sync_doors_required_goals", doors_required_goals)
rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration)
rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval)
rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
@@ -1018,6 +967,3 @@ func reset() -> void:
sng_go_duration = 20
sng_stop_duration = 4
sng_required_goals = 8
doors_swap_time = 15
doors_refresh_time = 25
doors_required_goals = 8
+1 -1
View File
@@ -47,7 +47,7 @@ func start_music():
match game_mode:
"Stop n Go":
track_path = "res://assets/sounds/stop_n_go.wav"
"Freemode", "Tekton Doors", _:
"Freemode", _:
track_path = "res://assets/sounds/level_bridge.wav"
play_track(track_path)
+1 -16
View File
@@ -230,10 +230,6 @@ func _check_and_refill_grid_if_needed(server_gridmap: Node):
break
if not has_items:
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS):
# Tekton Doors handles its own wall-aware refill in PortalModeManager
return
print("[PlayerboardManager] Floor 1 empty! Respawning tiles with Scarcity...")
# Call randomize_floor on floor 1 using ScarcityController
# ScarcityController is a global class, so we can pass its static function as a Callable
@@ -372,18 +368,7 @@ func auto_put_item() -> bool:
var pos = neighbor.position
var cell_3d = Vector3i(pos.x, 1, pos.y)
if enhanced_gridmap.get_cell_item(cell_3d) == -1 and not player.is_position_occupied(pos):
# TEKTON DOORS: Avoid portal doors
var is_on_portal = false
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS):
var doors = get_tree().get_nodes_in_group("PortalDoors")
for door in doors:
var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position))
if Vector2i(door_grid.x, door_grid.z) == pos:
is_on_portal = true
break
if not is_on_portal:
valid_put_positions.append(pos)
valid_put_positions.append(pos)
if valid_put_positions.is_empty():
return false
-585
View File
@@ -1,585 +0,0 @@
extends Node
# PortalModeManager - Handles "Tekton Doors" mode logic
# Manages room partitioning, portal connections, and mode-specific timers.
var main: Node
var gridmap: Node
# Room layout config
const ROOM_COUNT = 4
const GRID_SIZE = 14
const ROOM_DIM = 7
# State
var connections = {} # room_id -> {door_id -> {target_room, target_door}}
var doors = [] # List of PortalDoor nodes
var swap_timer: Timer
var tile_refresh_timer: Timer
var finish_spawned: bool = false
var arena_setup_done: bool = false
var player_portal_cooldowns: Dictionary = {}
var hud_layer: CanvasLayer
var mission_label: Label
var _has_notified_mission_complete: bool = false
func initialize(p_main: Node, p_gridmap: Node):
main = p_main
gridmap = p_gridmap
if gridmap:
# Ensure walls (4) are strictly treated as non-walkable for all internal checks
# Use explicit type to avoid Array vs Array[int] mismatch error
var non_walkable: Array[int] = [4]
gridmap.non_walkable_items = non_walkable
# Create Stands container if it doesn't exist
print("[PortalModeManager] Initialized")
# Connection Swap Timer (15s)
swap_timer = Timer.new()
swap_timer.name = "PortalSwapTimer"
# Initial wait time; gets reset when started based on game mode settings
swap_timer.wait_time = 15.0
swap_timer.timeout.connect(_on_swap_timer_timeout)
add_child(swap_timer)
# Tile Refresh Timer (25s)
tile_refresh_timer = Timer.new()
tile_refresh_timer.name = "TileRefreshTimer"
# Initial wait time; gets reset when started based on game mode settings
tile_refresh_timer.wait_time = 25.0
tile_refresh_timer.timeout.connect(_on_tile_refresh_timer_timeout)
add_child(tile_refresh_timer)
# Connect to mission tracking
var gcm = main.get_node_or_null("GoalsCycleManager")
if gcm:
gcm.global_timer_updated.connect(_on_global_timer_updated)
gcm.goal_count_updated.connect(_on_goal_count_updated)
_setup_hud()
func _on_global_timer_updated(time_remaining: float):
if not multiplayer.is_server(): return
# Last 30 seconds: Reveal Finish Room
if time_remaining <= 30.0 and not finish_spawned:
_spawn_finish_room()
func start_game_mode():
if not multiplayer.is_server(): return
if arena_setup_done and not doors.is_empty():
print("[PortalModeManager] Arena already setup, starting timers and refresh only.")
else:
print("[PortalModeManager] Starting Portal Game Mode with full setup...")
setup_arena_locally()
_randomize_connections()
# Configure dynamic timings from LobbyManager before starting
swap_timer.wait_time = float(LobbyManager.doors_swap_time)
tile_refresh_timer.wait_time = float(LobbyManager.doors_refresh_time)
# Start Timers
if swap_timer.is_stopped():
swap_timer.start()
if tile_refresh_timer.is_stopped():
tile_refresh_timer.start()
# Initial Tile Spawn
_refresh_tiles()
# Show HUD
_activate_hud()
func _activate_hud():
if hud_layer:
hud_layer.visible = true
_update_hud_visuals()
func activate_client_side():
"""Called on clients to show HUD and prepare local state."""
print("[PortalModeManager] Activating client-side HUD")
_activate_hud()
# Initial update to catch any missed goal counts
_update_hud_visuals()
func setup_arena_locally():
"""Sets up GridMap size and walls. Called on host and clients."""
if arena_setup_done:
print("[PortalModeManager] Arena already setup locally, skipping.")
return
print("[PortalModeManager] Setting up arena locally...")
_setup_arena_size()
_setup_room_partitions()
_spawn_portal_doors()
# PRE-FILL TILES: Ensure all floor tiles have items before the countdown starts
if multiplayer.is_server():
_refresh_tiles()
arena_setup_done = true
func _setup_arena_size():
if not gridmap: return
gridmap.columns = GRID_SIZE
gridmap.rows = GRID_SIZE
gridmap.clear()
# Explicitly clear Floor 1 to prevent legacy tiles from previous rounds
if gridmap.has_method("clear_grid"):
gridmap.clear_grid(1)
# Fill Floor 0 with standard floor (Item ID 0)
for x in range(GRID_SIZE):
for z in range(GRID_SIZE):
gridmap.set_cell_item(Vector3i(x, 0, z), 0)
func get_spawn_points() -> Array[Vector2i]:
# One point per quadrant
return [
Vector2i(3, 3), # Room 0
Vector2i(10, 3), # Room 1
Vector2i(3, 10), # Room 2
Vector2i(10, 10) # Room 3
]
func _setup_hud():
hud_layer = CanvasLayer.new()
hud_layer.layer = 5
hud_layer.visible = false
add_child(hud_layer)
var bottom_container = CenterContainer.new()
bottom_container.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
bottom_container.grow_horizontal = Control.GROW_DIRECTION_BOTH
bottom_container.grow_vertical = Control.GROW_DIRECTION_BEGIN
bottom_container.offset_bottom = -50
hud_layer.add_child(bottom_container)
mission_label = Label.new()
mission_label.text = "GOALS (0/8)"
mission_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
var custom_font = load("res://assets/fonts/Nougat-ExtraBlack.ttf")
if custom_font: mission_label.add_theme_font_override("font", custom_font)
mission_label.add_theme_font_size_override("font_size", 28)
mission_label.add_theme_color_override("font_outline_color", Color.BLACK)
mission_label.add_theme_constant_override("outline_size", 8)
bottom_container.add_child(mission_label)
# Initial update
_update_hud_visuals()
func _update_hud_visuals():
if not mission_label: return
var my_id = multiplayer.get_unique_id()
var gcm = main.get_node_or_null("GoalsCycleManager")
var completed_count = gcm.player_goal_counts.get(my_id, 0) if gcm else 0
mission_label.text = "GOALS (%d/%d)" % [completed_count, LobbyManager.doors_required_goals]
if completed_count >= LobbyManager.doors_required_goals:
mission_label.text = "ALL GOALS COMPLETE!\nFIND THE FINISH ROOM!"
mission_label.add_theme_color_override("font_color", Color.GOLD)
if not _has_notified_mission_complete:
_has_notified_mission_complete = true
var player_node = main.get_node_or_null(str(my_id))
if player_node:
NotificationManager.send_message(player_node, "ALL GOALS COMPLETE!", NotificationManager.MessageType.GOAL)
else:
mission_label.add_theme_color_override("font_color", Color.WHITE)
_has_notified_mission_complete = false
func is_mission_complete(peer_id: int) -> bool:
var gcm = main.get_node_or_null("GoalsCycleManager")
if not gcm: return false
return gcm.player_goal_counts.get(peer_id, 0) >= LobbyManager.doors_required_goals
func check_win_condition(player_id: int, pos: Vector2i) -> bool:
# 1. Check if on finish tile
var tile = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
if tile != 3: return false
# 2. Check missions
return is_mission_complete(player_id)
func _setup_room_partitions():
for i in range(GRID_SIZE):
# Vertical wall (middle columns)
gridmap.set_cell_item(Vector3i(6, 0, i), 4) # Wall item
gridmap.set_cell_item(Vector3i(7, 0, i), 4)
# Horizontal wall (middle rows)
gridmap.set_cell_item(Vector3i(i, 0, 6), 4)
gridmap.set_cell_item(Vector3i(i, 0, 7), 4)
var _pending_sync_data = null
func _spawn_portal_doors():
# 1. Use synced configs if they exist (passed via main.rpc("sync_portal_configs"))
var door_configs = get_meta("door_configs") if has_meta("door_configs") else []
# 2. If no synced configs (e.g. Server start), generate base + extras
if door_configs.is_empty():
if not multiplayer.is_server():
print("[PortalModeManager] Client waiting for portal configs sync...")
return
door_configs = [
# BASE DOORS (2 per room)
{"room": 0, "pos": Vector2i(6, 2), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East
{"room": 0, "pos": Vector2i(2, 6), "rot": 0, "offset": Vector2i(0, -1)}, # South
{"room": 1, "pos": Vector2i(7, 2), "rot": PI / 2, "offset": Vector2i(1, 0)}, # West
{"room": 1, "pos": Vector2i(11, 6), "rot": 0, "offset": Vector2i(0, -1)}, # South
{"room": 2, "pos": Vector2i(2, 7), "rot": 0, "offset": Vector2i(0, 1)}, # North
{"room": 2, "pos": Vector2i(6, 11), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East
{"room": 3, "pos": Vector2i(11, 7), "rot": 0, "offset": Vector2i(0, 1)}, # North
{"room": 3, "pos": Vector2i(7, 11), "rot": PI / 2, "offset": Vector2i(1, 0)} # West
]
# Server adds extras
var extra_options = [
{"room": 0, "pos": Vector2i(6, 5), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East (Gap from 6,2)
{"room": 1, "pos": Vector2i(7, 5), "rot": PI / 2, "offset": Vector2i(1, 0)}, # West (Gap from 7,2)
{"room": 2, "pos": Vector2i(6, 8), "rot": PI / 2, "offset": Vector2i(-1, 0)}, # East (Gap from 6,11)
{"room": 3, "pos": Vector2i(7, 8), "rot": PI / 2, "offset": Vector2i(1, 0)} # West (Gap from 7,11)
]
extra_options.shuffle()
door_configs.append(extra_options[0])
door_configs.append(extra_options[1])
# Broadcast to clients
main.rpc("sync_portal_configs", door_configs)
# 3. Spawn the doors
if not doors.is_empty(): return # Guard against double spawn
print("[PortalModeManager] Spawning %d doors. Peer: %d" % [door_configs.size(), multiplayer.get_unique_id()])
var portal_scene = load("res://scenes/portal_door.tscn")
var stands_container = main.get_node_or_null("Stands")
if not stands_container:
stands_container = Node3D.new()
stands_container.name = "Stands"
main.add_child(stands_container)
for i in range(door_configs.size()):
var cfg = door_configs[i]
if not portal_scene:
print("[PortalModeManager] Error: Failed to load portal_door.tscn")
break
var door = portal_scene.instantiate()
door.name = "Portal_%d" % i
door.room_id = cfg["room"]
door.door_id = i
door.set_meta("spawn_offset", cfg["offset"]) # Store offset for teleport
# Position
var world_pos = gridmap.map_to_local(Vector3i(cfg["pos"].x, 0, cfg["pos"].y))
door.transform.origin = world_pos
door.rotation.y = cfg["rot"]
stands_container.add_child(door, true)
doors.append(door)
# Server-only interaction logic
if multiplayer.is_server():
door.player_entered_portal.connect(handle_portal_interaction)
gridmap.set_cell_item(Vector3i(cfg["pos"].x, 0, cfg["pos"].y), 0) # Normal floor
print("[PortalModeManager] Finished spawning %d doors" % doors.size())
# Apply pending sync if it arrived early
if _pending_sync_data:
print("[PortalModeManager] Applying pending sync data...")
sync_portal_data(_pending_sync_data)
_pending_sync_data = null
const PORTAL_COLORS = [
Color(0, 1, 1), # Cyan
Color(1, 0, 1), # Magenta
Color(1, 0, 0), # Red
Color(0, 1, 0), # Green
Color(1, 0.5, 0) # Orange
]
func _randomize_connections():
if not multiplayer.is_server(): return
print("[PortalModeManager] Swapping portal connections...")
connections.clear()
var door_indices = []
for i in range(doors.size()):
door_indices.append(i)
# Shuffle and Validate: ensure no pairs are in the same room
var valid_pairing = false
var attempts = 0
while not valid_pairing and attempts < 100:
attempts += 1
door_indices.shuffle()
valid_pairing = true
for i in range(0, door_indices.size(), 2):
var a = door_indices[i]
var b = door_indices[i + 1]
if doors[a].room_id == doors[b].room_id:
valid_pairing = false
break
# Prepare sync data
var sync_data = [] # [[door_a_id, door_b_id, color], ...]
# Pair them up and assign colors
for i in range(0, door_indices.size(), 2):
var a = door_indices[i]
var b = door_indices[i + 1]
connections[a] = b
connections[b] = a
var color = PORTAL_COLORS[int(i / 2.0) % PORTAL_COLORS.size()]
sync_data.append([a, b, color])
doors[a].target_door_id = b
doors[a].portal_color = color
doors[b].target_door_id = a
doors[b].portal_color = color
# Sync to all clients
rpc("sync_portal_data", sync_data)
main.rpc("display_message", "PORTALS SWITCHED!")
func sync_to_client(peer_id: int):
"""Syncs current portal connections to a specific client."""
var sync_data = []
# connections is id -> id
# We need to rebuild the pair-based data for the RPC
var handled = []
for a_id in connections:
if a_id in handled: continue
var b_id = connections[a_id]
var color = doors[a_id].portal_color
sync_data.append([a_id, b_id, color])
handled.append(a_id)
handled.append(b_id)
rpc_id(peer_id, "sync_portal_data", sync_data)
@rpc("authority", "call_local", "reliable")
func sync_portal_data(data: Array):
"""Syncs portal connections and colors to all clients."""
print("[PortalModeManager] Received portal sync data. Peed ID: ", multiplayer.get_unique_id())
# If doors array is empty on client, try to repopulate from Stands group
if doors.is_empty():
var stands = get_tree().get_nodes_in_group("PortalDoors")
# Sort by name to ensure consistent indexing
stands.sort_custom(func(a, b): return a.name < b.name)
doors = stands
# If still empty, defer sync until doors are spawned locally
if doors.is_empty():
print("[PortalModeManager] Doors not yet ready, deferring sync data...")
_pending_sync_data = data
return
connections.clear()
for pair in data:
var a_id = pair[0]
var b_id = pair[1]
var color = pair[2]
connections[a_id] = b_id
connections[b_id] = a_id
if a_id < doors.size() and b_id < doors.size():
if is_instance_valid(doors[a_id]):
doors[a_id].target_door_id = b_id
doors[a_id].portal_color = color
if is_instance_valid(doors[b_id]):
doors[b_id].target_door_id = a_id
doors[b_id].portal_color = color
else:
print("[PortalModeManager] Warning: Door index %d or %d out of range during sync" % [a_id, b_id])
func _on_global_goal_count_updated(_peer_id: int, _count: int):
# Mission requirement removed in favor of time-based finish reveal
pass
func _on_goal_count_updated(peer_id: int, _count: int):
# Update HUD if relevant (always check if it's the local player whose count changed)
if peer_id == multiplayer.get_unique_id():
_update_hud_visuals()
func _spawn_finish_room():
print("[PortalModeManager] Time is running out! Revealing Finish Room...")
finish_spawned = true
# Choose a random room quadrant index (0 to 3)
var room_idx = randi() % 4
# Determine center for the selected room quadrant (7x7 rooms)
var x_center = 3 if (room_idx == 0 or room_idx == 2) else 10
var z_center = 3 if (room_idx == 0 or room_idx == 1) else 10
# Determine 3x3 bounds around the center
var x_start = x_center - 1
var x_end = x_center + 2 # exclusive for range()
var z_start = z_center - 1
var z_end = z_center + 2 # exclusive for range()
print("[PortalModeManager] Converting 3x3 area in Room %d (X:%d-%d, Z:%d-%d) to Finish Tiles" % [room_idx, x_start, x_end-1, z_start, z_end-1])
# Iterate through the 3x3 area
for x in range(x_start, x_end):
for z in range(z_start, z_end):
# Only convert walkable floor tiles (Item ID 0) on Floor 0
var floor_0_item = gridmap.get_cell_item(Vector3i(x, 0, z))
if floor_0_item == 0:
# Change Floor 0 tile to Finish Tile (ID 3)
main.rpc("sync_grid_item", x, 0, z, 3)
# Clear any item on Floor 1 above this tile
main.rpc("sync_grid_item", x, 1, z, -1)
# Visual update for server
if gridmap.has_method("update_grid_data"):
gridmap.update_grid_data()
main.rpc("display_message", "[ALARM] THE FINISH ROOM HAS APPEARED!")
main.rpc("broadcast_message", "SYSTEM", "A 3x3 Finish Zone has appeared in Room %d!" % room_idx, 4) # 4 = MessageType.WARNING
func _get_room_index(pos: Vector2i) -> int:
if pos.x < 7 and pos.y < 7: return 0
if pos.x >= 7 and pos.y < 7: return 1
if pos.x < 7 and pos.y >= 7: return 2
return 3
func _on_swap_timer_timeout():
_randomize_connections()
func _on_tile_refresh_timer_timeout():
_refresh_tiles()
main.rpc("display_message", "TILES REPLENISHED!")
func _refresh_tiles():
# GridMap Floor 0 has the walls (ID 4) and floors (ID 0)
# GridMap Floor 1 should have the items (Heart, Star, etc)
# Cache door positions to avoid spawning under them
var door_positions = []
for door in doors:
if is_instance_valid(door):
var local_pos = gridmap.local_to_map(gridmap.to_local(door.global_position))
door_positions.append(Vector2i(local_pos.x, local_pos.z))
for x in range(GRID_SIZE):
for z in range(GRID_SIZE):
# 1. Check if Floor 0 is a wall or void
var floor_0_item = gridmap.get_cell_item(Vector3i(x, 0, z))
if floor_0_item in [4, -1]:
continue
# 1.5. Prevent spawning directly under portal doors
if door_positions.has(Vector2i(x, z)):
continue
# 2. Check if Floor 1 is already occupied
if gridmap.get_cell_item(Vector3i(x, 1, z)) != -1:
continue
# 3. Spawn a tile (60% chance per valid floor cell)
if randf() < 0.6:
var weights = ScarcityModel.get_tile_weights()
var tile_id = _pick_weighted_tile(weights)
# Update GridMap Floor 1 via RPC for sync
main.rpc("sync_grid_item", x, 1, z, tile_id)
func _pick_weighted_tile(weights: Dictionary) -> int:
var total_weight = 0
for w in weights.values(): total_weight += w
var r = randi() % total_weight
var cumulative = 0
for tile in weights:
cumulative += weights[tile]
if r < cumulative:
return tile
return 7 # Default Heart
func handle_portal_interaction(player, door):
if not multiplayer.is_server(): return
var current_time = Time.get_ticks_msec()
if player_portal_cooldowns.has(player.name):
# Reduce cooldown to 200ms (more responsive than 1s, but enough to avoid jitter)
if current_time - player_portal_cooldowns[player.name] < 200:
return
player_portal_cooldowns[player.name] = current_time
var source_id = door.door_id
if not connections.has(source_id): return
var target_id = connections[source_id]
var target_door = doors[target_id]
# Use stored offset to avoid infinite loop (spawn inside the target room)
var offset = target_door.get_meta("spawn_offset") if target_door.has_meta("spawn_offset") else Vector2i(0, 0)
var target_world = target_door.global_position
var target_grid_3d = gridmap.local_to_map(target_world)
var target_grid = Vector2i(target_grid_3d.x, target_grid_3d.z) + offset
# Check for overlaps at the target_grid
var final_target = target_grid
var all_players = get_tree().get_nodes_in_group("Players")
var is_occupied = true
var search_radius = 0
var max_search_radius = 2
while is_occupied and search_radius <= max_search_radius:
is_occupied = false
for p in all_players:
if p != player and p.current_position == final_target:
is_occupied = true
break
if is_occupied:
# Try to find an adjacent cell
search_radius += 1
var found_empty = false
# Check immediate neighbors first
var offsets = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1),
Vector2i(1, 1), Vector2i(-1, 1), Vector2i(1, -1), Vector2i(-1, -1)]
for offset_vec in offsets:
var test_pos = final_target + offset_vec
# Check if it's strictly a floor tile (ID 0) on Floor 0, not a wall
if gridmap.get_cell_item(Vector3i(test_pos.x, 0, test_pos.y)) == 0:
# Verify no player is on this test_pos
var test_occupied = false
for p in all_players:
if p != player and p.current_position == test_pos:
test_occupied = true
break
if not test_occupied:
final_target = test_pos
found_empty = true
break
if found_empty:
is_occupied = false
print("[Portal] Teleporting %s to Room %d, Pos %s (via Door %d)" % [player.name, target_door.room_id, final_target, target_id])
# Snap player
if player.has_method("set_spawn_position"):
player.rpc("set_spawn_position", final_target)
+1 -5
View File
@@ -558,11 +558,7 @@ func spawn_powerups_around(center: Vector2i, force_powerups: bool = true, only_c
item_id = rng.randi_range(7, 10)
else:
# 20% Chance for PowerUp
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS):
# Restrict to Speed (11) and Ghost (14) for Tekton Doors
item_id = [11, 14].pick_random()
else:
item_id = rng.randi_range(11, 14)
item_id = rng.randi_range(11, 14)
var cell = Vector3i(pos.x, 1, pos.y)
-6
View File
@@ -18,12 +18,6 @@ const SCHEMA = {
"sng_stop_duration": {"type": TYPE_INT, "default": 4, "min": 2, "max": 10},
"sng_required_goals": {"type": TYPE_INT, "default": 8, "min": 3, "max": 20}
},
"Tekton Doors": {
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
"doors_swap_time": {"type": TYPE_INT, "default": 15, "min": 10, "max": 30},
"doors_refresh_time": {"type": TYPE_INT, "default": 25, "min": 15, "max": 40},
"doors_required_goals": {"type": TYPE_INT, "default": 8, "min": 5, "max": 12}
},
"Candy Pump Survival": {
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
"gauntlet_growth_interval": {"type": TYPE_FLOAT, "default": 3.0, "min": 1.0, "max": 10.0},
-136
View File
@@ -1,136 +0,0 @@
extends StaticBody3D
# PortalDoor.gd
# Specialized door for "Tekton Doors" mode.
# Teleports players to a target room/door when they step into it.
signal player_entered_portal(player_node, door_node)
@export var room_id: int = 0
@export var door_id: int = 0 # 0: North, 1: South, 2: East, 3: West
# State synced by PortalModeManager
var target_room_id: int = -1
var target_door_id: int = -1
var is_active: bool = true
var portal_color: Color = Color.WHITE: set = set_portal_color
func set_portal_color(value: Color):
portal_color = value
_update_visuals()
@onready var detection_area: Area3D = $Area3D
func _ready():
add_to_group("PortalDoors")
if detection_area:
detection_area.body_entered.connect(_on_body_entered)
# Visual feedback: indicate door is active
_update_visuals()
# Adjust GroundIndicator position based on spawn_offset metadata
_adjust_indicator_position()
func _on_body_entered(body: Node3D):
if not is_active: return
if body.is_in_group("Players") or body.get("is_bot"):
var current_time = Time.get_ticks_msec()
if body.has_meta("last_portal_time"):
# Reduce cooldown to 200ms to match manager logic and allow fast re-entry
if current_time - body.get_meta("last_portal_time") < 200:
return
body.set_meta("last_portal_time", current_time)
print("[PortalDoor] Player %s entered Door %d in Room %d" % [body.name, door_id, room_id])
emit_signal("player_entered_portal", body, self )
var _materials_initialized: bool = false
func _update_visuals():
# Removed is_node_ready() check to allow early setter calls to prepare variables,
# but we still need the nodes to exist to apply them.
if not is_inside_tree(): return
var vortex = get_node_or_null("Vortex")
var frame_left = get_node_or_null("Frame_Left")
# If children aren't there yet, we can't update visuals.
# This usually happens if called before or during early _ready.
if not vortex or not frame_left: return
if not _materials_initialized:
_initialize_unique_materials()
_materials_initialized = true
if vortex:
var mat = vortex.get_surface_override_material(0)
if mat:
mat.albedo_color = portal_color
mat.albedo_color.a = 0.5
if mat.has_method("set_emission"):
mat.set("emission", portal_color)
for part_name in ["Frame_Left", "Frame_Right", "Frame_Top"]:
var frame = get_node_or_null(part_name)
if frame:
var mat = frame.get_surface_override_material(0)
if mat:
mat.albedo_color = portal_color.lerp(Color.BLACK, 0.4)
var ground = get_node_or_null("GroundIndicator")
if ground:
var mat = ground.get_surface_override_material(0)
if mat:
mat.albedo_color = portal_color
mat.albedo_color.a = 0.4
mat.emission = portal_color
mat.emission_energy_multiplier = 2.0
func _initialize_unique_materials():
var vortex = get_node_or_null("Vortex")
if vortex:
var mat = vortex.get_surface_override_material(0)
if not mat:
mat = vortex.mesh.surface_get_material(0)
if mat:
vortex.set_surface_override_material(0, mat.duplicate())
for part_name in ["Frame_Left", "Frame_Right", "Frame_Top"]:
var frame = get_node_or_null(part_name)
if frame:
var mat = frame.get_surface_override_material(0)
if not mat:
mat = frame.mesh.surface_get_material(0)
if mat:
frame.set_surface_override_material(0, mat.duplicate())
var ground = get_node_or_null("GroundIndicator")
if ground:
var mat = ground.get_surface_override_material(0)
if not mat:
mat = ground.mesh.surface_get_material(0)
if mat:
ground.set_surface_override_material(0, mat.duplicate())
func _adjust_indicator_position():
# This uses the spawn_offset metadata set by PortalModeManager
# to push the ground indicator "into" the room.
if not has_meta("spawn_offset"): return
var ground = get_node_or_null("GroundIndicator")
if not ground: return
var offset_2d = get_meta("spawn_offset") # Vector2i
var offset_3d = Vector3(offset_2d.x, 0, offset_2d.y)
# Convert the global direction (into the room) to local coordinates
var local_dir = to_local(global_position + offset_3d).normalized()
# Nudge the indicator in that direction
ground.position = local_dir * 0.5 # Reduced from 0.6 to close the gap
ground.position.y = 0.05 # Keep it just above the floor
+1 -3
View File
@@ -514,9 +514,7 @@ func spawn_tiles_around(count: int = 4):
if LobbyManager:
mode = LobbyManager.get_game_mode()
if LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.TEKTON_DOORS:
item_id = [11, 14].pick_random()
elif mode == GameMode.Mode.FREEMODE:
if mode == GameMode.Mode.FREEMODE:
item_id = rng.randi_range(7, 10) # No powerups in freemode either, just normal tiles
else:
item_id = rng.randi_range(11, 14)
+4
View File
@@ -0,0 +1,4 @@
extends SceneTree
func _init():
print("Testing gauntlet multiplayer")
quit()
+1
View File
@@ -0,0 +1 @@
uid://ddniv6k6aj2u
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/expect -f
spawn ssh admin@193.180.213.215 "ls -la /home/admin/nakama/data/modules/"
expect "password:"
send "Mieayamtelur17\r"
expect eof
+5 -8
View File
@@ -18,11 +18,10 @@ func after_each():
func test_gauntlet_enum_exists():
assert_eq(GameMode.Mode.GAUNTLET, 3, "GAUNTLET should be enum value 3")
# Test 2: All 4 modes are present in enum
# Test 2: All 3 modes are present in enum
func test_all_modes_in_enum():
assert_eq(GameMode.Mode.FREEMODE, 0, "FREEMODE should be 0")
assert_eq(GameMode.Mode.STOP_N_GO, 1, "STOP_N_GO should be 1")
assert_eq(GameMode.Mode.TEKTON_DOORS, 2, "TEKTON_DOORS should be 2")
assert_eq(GameMode.Mode.GAUNTLET, 3, "GAUNTLET should be 3")
# =============================================================================
@@ -47,7 +46,7 @@ func test_round_trip_conversion():
# Test 6: All existing modes still round-trip correctly
func test_existing_modes_round_trip():
for mode in [GameMode.Mode.FREEMODE, GameMode.Mode.STOP_N_GO, GameMode.Mode.TEKTON_DOORS]:
for mode in [GameMode.Mode.FREEMODE, GameMode.Mode.STOP_N_GO]:
var s = GameMode.mode_to_string(mode)
var back = GameMode.from_string(s)
assert_eq(back, mode, "Round-trip failed for %s" % s)
@@ -66,18 +65,17 @@ func test_get_all_modes_includes_gauntlet():
var modes = GameMode.get_all_modes()
assert_has(modes, "Candy Pump Survival", "get_all_modes should include 'Candy Pump Survival'")
# Test 9: get_all_modes returns exactly 4 entries
# Test 9: get_all_modes returns exactly 3 entries
func test_get_all_modes_count():
var modes = GameMode.get_all_modes()
assert_eq(modes.size(), 4, "get_all_modes should return 4 modes")
assert_eq(modes.size(), 3, "get_all_modes should return 3 modes")
# Test 10: get_all_modes order is correct
func test_get_all_modes_order():
var modes = GameMode.get_all_modes()
assert_eq(modes[0], "Freemode", "First mode should be Freemode")
assert_eq(modes[1], "Stop n Go", "Second mode should be Stop n Go")
assert_eq(modes[2], "Tekton Doors", "Third mode should be Tekton Doors")
assert_eq(modes[3], "Candy Pump Survival", "Fourth mode should be Candy Pump Survival")
assert_eq(modes[2], "Candy Pump Survival", "Third mode should be Candy Pump Survival")
# =============================================================================
# is_restricted Tests
@@ -96,7 +94,6 @@ func test_freemode_not_restricted():
# Test 13: All restricted modes are confirmed
func test_all_restricted_modes():
assert_true(GameMode.is_restricted(GameMode.Mode.STOP_N_GO), "STOP_N_GO should be restricted")
assert_true(GameMode.is_restricted(GameMode.Mode.TEKTON_DOORS), "TEKTON_DOORS should be restricted")
assert_true(GameMode.is_restricted(GameMode.Mode.GAUNTLET), "GAUNTLET should be restricted")
# =============================================================================
+2707
View File
@@ -0,0 +1,2707 @@
<a id="top"></a>
# Tekton Armageddon - Client Architecture (Full Function Reference)
[Back to Home](./Home)
Complete per-function reference for the Godot 4.7 client codebase. Every script, signal, autoload dependency, and cross-file relationship documented.
[Back to top](#top)
## Table of Contents
[Back to top](#top)
1. [Project Structure Overview](#1-project-structure-overview)
2. [Autoloads / Singletons Index](#2-autoloads--singletons-index)
3. [Service Layer](#3-service-layer)
- [3.1 NakamaManager](#31-nakamamanaager)
- [3.2 BackendService](#32-backendservice)
- [3.3 SteamworksManager](#33-steamworksmanager)
4. [Core Managers](#4-core-managers)
- [4.1 AuthManager](#41-authmanager)
- [4.2 LobbyManager](#42-lobbymanager)
- [4.3 GameStateManager](#43-gamestatemanager)
- [4.4 PlayerManager](#44-playermanager)
- [4.5 EventBus](#45-eventbus)
- [4.6 GameMode / ModeConfig](#46-gamemode--modeconfig)
5. [Player Subsystem Managers](#5-player-subsystem-managers)
- [5.1 PlayerMovementManager](#51-playermovementmanager)
- [5.2 PlayerInputManager](#52-playerinputmanager)
- [5.3 PlayerActionManager](#53-playeractionmanager)
- [5.4 PlayerboardManager](#54-playerboardmanager)
- [5.5 PowerupManager](#55-powerupmanager)
6. [Game Mode Managers](#6-game-mode-managers)
- [6.1 StopNGoManager](#61-stopngomanager)
- [6.2 GauntletManager](#62-gauntletmanager)
- [6.3 PortalModeManager](#63-portalmode_manager)
- [6.4 GoalManager](#64-goalmanager)
- [6.5 GoalsCycleManager](#65-goalscyclemanager)
- [6.6 PlayerRaceManager](#66-playerracemanager)
- [6.7 TurnManager](#67-turnmanager)
7. [Gameplay Managers](#7-gameplay-managers)
- [7.1 ObstacleManager](#71-obstaclemanager)
- [7.2 SpecialTilesManager](#72-specialtilesmanager)
- [7.3 StaticTektonManager](#73-statictektonmanager)
8. [UI / Presentation Managers](#8-ui--presentation-managers)
- [8.1 UIManager](#81-uimanager)
- [8.2 SfxManager](#82-sfxmanager)
- [8.3 MusicManager](#83-musicmanager)
- [8.4 NotificationManager](#84-notificationmanager)
- [8.5 ScreenShake](#85-screenshake)
- [8.6 CameraContextManager](#86-cameracontextmanager)
- [8.7 TouchControls](#87-touchcontrols)
- [8.8 TutorialManager / TutorialOverlay](#88-tutorialmanager--tutorialoverlay)
9. [Social / Economy Managers](#9-social--economy-managers)
- [9.1 UserProfileManager](#91-userprofilemanager)
- [9.2 GachaManager](#92-gachamanager)
- [9.3 SkinManager](#93-skinmanager)
- [9.4 ShopManager](#94-shopmanager)
- [9.5 JoinManager](#95-joinmanager)
- [9.6 FriendManager](#96-friendmanager)
- [9.7 MailManager](#97-mailmanager)
- [9.8 DailyRewardManager](#98-dailyrewardmanager)
- [9.9 AdminManager](#99-adminmanager)
10. [System Managers](#10-system-managers)
- [10.1 SettingsManager](#101-settingsmanager)
- [10.2 SessionManager](#102-sessionmanager)
- [10.3 GameUpdateManager](#103-gameupdatemanager)
11. [Core Scene Scripts](#11-core-scene-scripts)
- [11.1 main.gd (Main game scene controller)](#111-maingd-main-game-scene-controller)
- [11.2 player.gd](#112-playergd)
- [11.3 lobby.gd](#113-lobbygd)
- [11.4 animation.gd](#114-animationgd)
12. [UI Helper Classes (RefCounted)](#12-ui-helper-classes-refcounted)
- [12.1 LobbyMainMenu](#121-lobbymainmenu)
- [12.2 LobbyRoom](#122-lobbyroom)
- [12.3 LobbyRoomList](#123-lobbyroomlist)
- [12.4 LobbyChat](#124-lobbychat)
13. [Dependency Graph](#13-dependency-graph)
- [13.1 Manager Autoload Dependencies](#131-manager-autoload-dependencies)
- [13.2 Cross-Manager Signal Wiring](#132-cross-manager-signal-wiring)
14. [Scene Node Trees](#14-scene-node-trees)
- [14.1 main.tscn](#141-maintscn)
- [14.2 player.tscn](#142-playertscn)
- [14.3 lobby.tscn](#143-lobbytscn)
[Back to top](#top)
## 1. Project Structure Overview
[Back to top](#top)
```
/home/dev/tekton/
project.godot -- Godot 4.7 project file
scripts/
main.gd -- (NOT USED; logic lives in scenes/main.gd)
nakama_manager.gd -- Nakama network layer (autoload)
event_bus.gd -- Central observer pattern bus (autoload)
game_mode.gd -- GameMode enum + string utils (RefCounted)
mode_config.gd -- Schema-driven mode settings validation (RefCounted)
managers/ -- 39+ autoload manager singletons
auth_manager.gd
lobby_manager.gd
game_state_manager.gd
player_manager.gd
player_movement_manager.gd
player_input_manager.gd
player_action_manager.gd
user_profile_manager.gd
gacha_manager.gd
skin_manager.gd
ui_manager.gd
sfx_manager.gd
music_manager.gd
game_update_manager.gd
stop_n_go_manager.gd
gauntlet_manager.gd
portal_mode_manager.gd
turn_manager.gd
goal_manager.gd
goals_cycle_manager.gd
player_race_manager.gd
shop_manager.gd
join_manager.gd
powerup_manager.gd
notification_manager.gd
obstacle_manager.gd
friend_manager.gd
admin_manager.gd
mail_manager.gd
session_manager.gd
settings_manager.gd
tutorial_manager.gd
tutorial_overlay.gd
playerboard_manager.gd
camera_context_manager.gd
screen_shake.gd
special_tiles_manager.gd
static_tekton_manager.gd
touch_controls.gd
daily_reward_manager.gd
services/
backend_service.gd -- Unified RPC interface (autoload)
steamworks_manager.gd -- Steam auth ticket + persona (NOT autoload; child of BackendService)
scenes/
main.gd -- Core game scene controller (~2956 lines)
main.tscn -- Main game scene
player.gd -- Player character controller (~2751 lines)
player.tscn -- Player scene
lobby.gd -- Lobby/home screen controller (~583 lines)
lobby.tscn -- Lobby scene
animation.gd -- Stop n Go animation player (41 lines)
ui/
lobby_main_menu.gd -- RefCounted; main menu button wiring
lobby_room.gd -- RefCounted; room/player slot management
lobby_room_list.gd -- RefCounted; room list display + join
lobby_chat.gd -- RefCounted; global + DM chat
login_screen.tscn -- Login screen scene
boot_screen.tscn -- Boot splash scene
shop_panel.tscn -- Shop panel scene
gacha_panel.tscn -- Gacha panel scene
daily_reward_panel.tscn -- Daily reward panel scene
admin_panel.tscn -- Admin panel scene
profile_panel.tscn -- Profile panel scene
leaderboard_panel.tscn -- Leaderboard panel scene
mailbox_panel.tscn -- Mailbox panel scene
settings_menu.tscn -- Settings scene
lobby_invite_popup.tscn -- Invite popup scene
invite_friends_dialog.tscn -- Invite dialog scene
social_panel.tscn -- Social panel scene
game/
main.tscn -- (actual main game scene)
loading_screen/
loading_screen.tscn -- Level loading screen
```
[Back to top](#top)
## 2. Autoloads / Singletons Index
[Back to top](#top)
All managers are registered as autoloads in project.godot and accessible globally via `/root/<ManagerName>`. The following are the configured autoloads:
| Autoload Name | File | Purpose |
|---|---|---|
| AuthManager | res://scripts/managers/auth_manager.gd | Authentication (guest, email, social) |
| NakamaManager | res://scripts/nakama_manager.gd | Nakama client/socket/bridge lifecycle |
| BackendService | res://scripts/services/backend_service.gd | Unified RPC API wrapper |
| EventBus | res://scripts/event_bus.gd | Observer-pattern cross-manager events |
| LobbyManager | res://scripts/managers/lobby_manager.gd | Room lifecycle, matchmaking |
| GameStateManager | res://scripts/managers/game_state_manager.gd | State machine, match lifecycle |
| PlayerManager | res://scripts/managers/player_manager.gd | Player data container |
| PlayerMovementManager | res://scripts/managers/player_movement_manager.gd | Movement physics, pathfinding |
| PlayerInputManager | res://scripts/managers/player_input_manager.gd | Input capture, buffering |
| PlayerActionManager | res://scripts/managers/player_action_manager.gd | Action execution (grab, put) |
| UserProfileManager | res://scripts/managers/user_profile_manager.gd | Profile CRUD, wallet sync |
| GachaManager | res://scripts/managers/gacha_manager.gd | Gacha pull orchestration |
| SkinManager | res://scripts/managers/skin_manager.gd | Cosmetics, skins, loadout |
| UIManager | res://scripts/managers/ui_manager.gd | UI layer stack, show/hide |
| SfxManager | res://scripts/managers/sfx_manager.gd | Sound effect pool |
| MusicManager | res://scripts/managers/music_manager.gd | Music crossfade |
| GameUpdateManager | res://scripts/managers/game_update_manager.gd | Hot-reload patching |
| StopNGoManager | res://scripts/managers/stop_n_go_manager.gd | Stop n Go minigame state |
| GauntletManager | res://scripts/managers/gauntlet_manager.gd | Gauntlet mode progression |
| PortalModeManager | res://scripts/managers/portal_mode_manager.gd | Portal race mode |
| TurnManager | res://scripts/managers/turn_manager.gd | Turn-based sequencing |
| GoalManager | res://scripts/managers/goal_manager.gd | Goal validation, completion |
| GoalsCycleManager | res://scripts/managers/goals_cycle_manager.gd | Cycling goal rotation, scoring |
| PlayerRaceManager | res://scripts/managers/player_race_manager.gd | Race position, finish |
| ShopManager | res://scripts/managers/shop_manager.gd | Shop data layer |
| JoinManager | res://scripts/managers/join_manager.gd | Join code input |
| PowerupManager | res://scripts/managers/powerup_manager.gd | Powerup system (boost/charge) |
| NotificationManager | res://scripts/managers/notification_manager.gd | On-screen message queue |
| ObstacleManager | res://scripts/managers/obstacle_manager.gd | Obstacle placement/removal |
| FriendManager | res://scripts/managers/friend_manager.gd | Friends list, DMs |
| AdminManager | res://scripts/managers/admin_manager.gd | Admin panel state |
| MailManager | res://scripts/managers/mail_manager.gd | Mail CRUD |
| SessionManager | res://scripts/managers/session_manager.gd | Session refresh lifecycle |
| SettingsManager | res://scripts/managers/settings_manager.gd | User settings persistence |
| TutorialManager | res://scripts/managers/tutorial_manager.gd | Tutorial flow control |
| TutorialOverlay | res://scripts/managers/tutorial_overlay.gd | Tutorial UI overlay |
| PlayerboardManager | res://scripts/managers/playerboard_manager.gd | Player inventory board |
| CameraContextManager | res://scripts/managers/camera_context_manager.gd | Camera zoom/context |
| ScreenShake | res://scripts/managers/screen_shake.gd | Screen shake effects |
| SpecialTilesManager | res://scripts/managers/special_tiles_manager.gd | Ice/crack/portal tiles |
| StaticTektonManager | res://scripts/managers/static_tekton_manager.gd | Static Tekton turret logic |
| TouchControls | res://scripts/managers/touch_controls.gd | Mobile touch input overlay |
| DailyRewardManager | res://scripts/managers/daily_reward_manager.gd | Daily reward claims |
[Back to top](#top)
## 3. Service Layer
[Back to top](#top)
### 3.1 NakamaManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/nakama_manager.gd` (330 lines)
**Extends:** Node
**Autoload name:** NakamaManager
Central Nakama SDK integration. Manages the Nakama client, session, socket, and multiplayer bridge. All network communication flows through this singleton.
**Properties:**
| Name | Type | Description |
|---|---|---|
| nakama_server_key | String | From env var NAKAMA_SERVER_KEY or ProjectSettings |
| nakama_host | String | Default: `tektondash.vps.webdock.cloud` |
| nakama_port | int | Default: 7350 |
| nakama_scheme | String | Default: http |
| client | NakamaClient | The Nakama client instance |
| session | NakamaSession | Current auth session |
| socket | NakamaSocket | WebSocket connection |
| bridge | NakamaMultiplayerBridge | Links Nakama socket to Godot HLAPI |
| current_match_id | String | Currently joined match ID |
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `connected_to_nakama` | none | Emitted when socket connects successfully |
| `connection_failed` | error_message: String | Emitted on connection failure |
| `match_joined` | match_id: String | Emitted when bridge joins a match |
| `match_join_error` | error_message: String | Emitted on match join failure |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `set_server` | `func set_server(host: String, port: int = 7350) -> void` | void | Override Nakama server endpoint. Auto-detects scheme (https for .ts.net, http for 100.x IPs). Recreates client if no active session. |
| `connect_to_nakama_async` | `func connect_to_nakama_async(email: String = "", password: String = "") -> bool` | bool (async) | Full auth + socket + bridge connection. Empty email = device auth. Creates socket, initializes multiplayer bridge, sets Godot's multiplayer peer. |
| `cleanup` | `func cleanup() -> void` | void | Shuts down socket, leaves bridge, deletes match metadata storage, resets multiplayer peer to null. |
| `host_game` | `func host_game(room_meta: Dictionary = {}) -> void` | void | Creates a Nakama relayed match via bridge.create_match(). Optionally stores room metadata to Nakama storage. Has re-entry guard for double-click protection. |
| `join_game` | `func join_game(match_id: String) -> void` | void | Joins an existing match by ID. Leaves current match first if connected. |
| `is_connected_to_nakama` | `func is_connected_to_nakama() -> bool` | bool | Returns true if socket exists and is connected to host. |
| `list_matches_async` | `func list_matches_async(mode_filter: String = "") -> Array` | Array (async) | Queries Nakama for available matches. Batch-reads room metadata from storage. Returns array of room dicts. |
| `_on_bridge_match_joined` | `func _on_bridge_match_joined() -> void` | void | Internal: updates current_match_id, emits match_joined signal. |
| `_on_bridge_match_join_error` | `func _on_bridge_match_join_error(error) -> void` | void | Internal: emits match_join_error. |
**Dependencies:** Nakama GDExtension (NakamaClient, NakamaSocket, NakamaMultiplayerBridge).
**Depended by:** AuthManager, BackendService, LobbyManager, LobbyRoom, LobbyChat, LobbyMainMenu, main.gd.
[Back to top](#top)
### 3.2 BackendService
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/services/backend_service.gd` (247 lines)
**Extends:** Node
**Autoload name:** BackendService
Unified typed interface for all Nakama Lua RPCs. All platform authentication paths (Steam, Nakama device/email) funnel through here. Provides retry logic with exponential backoff.
**Properties:**
| Name | Type | Description |
|---|---|---|
| current_platform | Platform (enum) | DESKTOP_STEAM, DESKTOP_NAKAMA, or MOBILE_NAKAMA |
| steamworks_manager | Node | Only for auth ticket retrieval |
| nakama_backend | Node | Reference to NakamaManager autoload |
**Enums:**
- `Platform { DESKTOP_STEAM, DESKTOP_NAKAMA, MOBILE_NAKAMA }`
- `ErrorCode { NONE, NETWORK_ERROR, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, INTERNAL_ERROR, UNKNOWN_ERROR, INSUFFICIENT_FUNDS }`
**Signals:** None.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `_ready` | auto-called | void | Detects platform, initializes backend |
| `is_initialized` | `func is_initialized() -> bool` | bool | Checks nakama_backend is non-null |
| `get_platform_name` | `func get_platform_name() -> String` | String | Returns human-readable platform name |
| `get_steamworks_manager` | `func get_steamworks_manager() -> Node` | Node | Returns steamworks_manager child node |
| `api_rpc_async` | `func api_rpc_async(rpc_id: String, payload: String = "{}") -> Dictionary` | Dictionary (async) | Unified RPC with up to 3 retries, exponential backoff (0.5s base). Returns `{success, error, message, data}`. |
| `admin_clear_global_chat` | `func admin_clear_global_chat(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
| `admin_get_chat_config` | `func admin_get_chat_config() -> Dictionary` | Dictionary | RPC wrapper |
| `admin_set_chat_config` | `func admin_set_chat_config(config: Dictionary) -> Dictionary` | Dictionary | RPC wrapper |
| `admin_purge_old_messages` | `func admin_purge_old_messages(channel_id: String, max_age_days: int) -> Dictionary` | Dictionary | RPC wrapper |
| `admin_list_channel_messages` | `func admin_list_channel_messages(channel_id: String, limit: int = 50, cursor: String = "", forward: bool = true) -> Dictionary` | Dictionary | RPC wrapper |
| `admin_delete_channel_message` | `func admin_delete_channel_message(channel_id: String, message_id: String) -> Dictionary` | Dictionary | RPC wrapper |
| `send_friend_request` | `func send_friend_request(target_id: String) -> Dictionary` | Dictionary | RPC wrapper |
| `respond_friend_request` | `func respond_friend_request(target_id: String, accept: bool) -> Dictionary` | Dictionary | RPC wrapper |
| `perform_gacha_pull` | `func perform_gacha_pull(gacha_id: String, count: int) -> Dictionary` | Dictionary | RPC wrapper |
| `get_mail` | `func get_mail(payload: String = "{}") -> Dictionary` | Dictionary | RPC wrapper |
| `claim_mail_reward` | `func claim_mail_reward(mail_id: String) -> Dictionary` | Dictionary | RPC wrapper |
| `delete_mail` | `func delete_mail(mail_id: String) -> Dictionary` | Dictionary | RPC wrapper |
| `send_mail` | `func send_mail(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
| `change_avatar` | `func change_avatar(avatar_url: String) -> Dictionary` | Dictionary | RPC wrapper |
| `change_username` | `func change_username(new_username: String) -> Dictionary` | Dictionary | RPC wrapper |
| `change_status` | `func change_status(new_status: String) -> Dictionary` | Dictionary | RPC wrapper |
| `change_bio` | `func change_bio(new_bio: String) -> Dictionary` | Dictionary | RPC wrapper |
| `query_users` | `func query_users(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
| `admin_give_currency` | `func admin_give_currency(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
| `get_daily_reward_config_admin` | `func get_daily_reward_config_admin() -> Dictionary` | Dictionary | RPC wrapper |
| `set_daily_reward_config` | `func set_daily_reward_config(req: Dictionary) -> Dictionary` | Dictionary | RPC wrapper |
| `get_daily_reward_state` | `func get_daily_reward_state() -> Dictionary` | Dictionary | RPC wrapper |
| `claim_daily_reward` | `func claim_daily_reward() -> Dictionary` | Dictionary | RPC wrapper |
| `sync_leaderboard` | `func sync_leaderboard() -> Dictionary` | Dictionary | RPC wrapper |
| `get_leaderboard_stats` | `func get_leaderboard_stats() -> Dictionary` | Dictionary | RPC wrapper |
| `debug_add_exp` | `func debug_add_exp(exp_amount: int) -> Dictionary` | Dictionary | RPC wrapper |
| `reset_stats` | `func reset_stats() -> Dictionary` | Dictionary | RPC wrapper |
| `search_users` | `func search_users(payload: String) -> Dictionary` | Dictionary | RPC wrapper |
| `send_lobby_invite` | `func send_lobby_invite(to_user_id: String, match_id: String) -> Dictionary` | Dictionary | RPC wrapper |
**Dependencies:** NakamaManager (autoload), SteamworksManager (child node).
**Depended by:** AuthManager, LobbyManager, LobbyChat, lobby.gd (admin), FriendManager, MailManager, GachaManager, DailyRewardManager, AdminManager, SkinManager.
[Back to top](#top)
### 3.3 SteamworksManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/services/steamworks_manager.gd` (72 lines)
**Extends:** Node
**class_name:** SteamworksManager
NOT an autoload. Created as a child of BackendService. Provides Steam auth session tickets for Nakama login. GodotSteam GDExtension required.
**Properties:**
| Name | Type | Description |
|---|---|---|
| is_steam_initialized | bool | Whether Steam API initialized successfully |
| steam_app_id | int | From ProjectSettings or default 480 |
**Signals:** None.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `_ready` | auto-called | void | Calls _initialize_steam |
| `is_initialized` | `func is_initialized() -> bool` | bool | Returns steam init status |
| `get_auth_session_ticket` | `func get_auth_session_ticket() -> String` | String | Gets Steam auth session ticket via Steam.getAuthSessionTicket(), returns hex-encoded buffer |
| `get_steam_user_name` | `func get_steam_user_name() -> String` | String | Returns Steam persona name via Steam.getPersonaName() |
| `get_steam_user_id` | `func get_steam_user_id() -> int` | int | Returns Steam ID via Steam.getSteamID() |
**Dependencies:** GodotSteam GDExtension (ClassDB.class_exists("Steam")).
**Depended by:** BackendService, AuthManager.
[Back to top](#top)
## 4. Core Managers
[Back to top](#top)
### 4.1 AuthManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/auth_manager.gd` (515 lines)
**Extends:** Node
**Autoload name:** AuthManager
Centralized authentication handler. Supports Guest (device ID), Email/Password, Google, Apple, Facebook, and Steam auth modes. Persists sessions to encrypted file storage.
**Properties:**
| Name | Type | Description |
|---|---|---|
| current_user | Dictionary | {user_id, username, display_name, avatar_url, email} |
| is_authenticated | bool | Whether fully authenticated |
| is_guest | bool | Whether using guest mode |
| auth_mode | AuthMode (enum) | GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, STEAM, CUSTOM |
**Enums:** `AuthMode { GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, STEAM, CUSTOM }`
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `auth_started` | none | Emitted when any login flow begins |
| `auth_completed` | success: bool, user_data: Dictionary | Emitted on auth success or failure |
| `auth_failed` | error: String | Emitted on auth error |
| `session_restored` | none | Emitted when saved session restored |
| `logged_out` | none | Emitted after full logout |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `_ready` | auto-called | void | Deferred call to _try_restore_session |
| `login_as_guest` | `func login_as_guest() -> bool` | bool (async) | Device ID guest auth. Generates/persists device ID. |
| `login_with_email` | `func login_with_email(email: String, password: String, remember: bool = true) -> bool` | bool (async) | Email/password authentication |
| `register_with_email` | `func register_with_email(email: String, password: String, username: String = "") -> bool` | bool (async) | Email registration (create if not exists) |
| `login_with_google` | `func login_with_google(id_token: String) -> bool` | bool (async) | Google auth via ID token |
| `login_with_apple` | `func login_with_apple(id_token: String) -> bool` | bool (async) | Apple auth via ID token |
| `login_with_facebook` | `func login_with_facebook(access_token: String) -> bool` | bool (async) | Facebook auth via access token |
| `login_with_steam` | `func login_with_steam() -> bool` | bool (async) | Steam ticket auth via BackendService.steamworks_manager |
| `link_email` | `func link_email(email: String, password: String) -> bool` | bool (async) | Link email to existing guest account |
| `link_google` | `func link_google(id_token: String) -> bool` | bool (async) | Link Google to existing account |
| `logout` | `func logout() -> void` | void | Full cleanup: NakamaManager.cleanup(), clear session files, reset state, emit logged_out |
| `clear_session` | `func clear_session() -> void` | void | Deletes SESSION_FILE and CREDENTIALS_FILE from user:// |
| `_try_restore_session` | internal | void | Attempts to load encrypted session file. Skips guest session auto-restore. |
| `_connect_socket` | internal | bool (async) | Creates Nakama socket, connects, initializes multiplayer bridge |
| `_load_user_profile` | internal | void (async) | Loads account data from Nakama into current_user |
**Dependencies:** NakamaManager, BackendService.
**Depended by:** LobbyMainMenu, lobby.gd, UserProfileManager, login_screen.tscn.
[Back to top](#top)
### 4.2 LobbyManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/lobby_manager.gd` (1023 lines)
**Extends:** Node
**Autoload name:** LobbyManager
Room/lobby lifecycle manager. Handles both Nakama (online) and LAN (direct ENet) modes. Manages room creation, joining, player list, ready states, game mode settings, and character/area selection.
**Properties:**
| Name | Type | Default | Description |
|---|---|---|---|
| current_room | Dictionary | {} | Current room metadata |
| players_in_room | Array | [] | [{id, name, is_ready, character, nakama_id}] |
| available_rooms | Array | [] | Discovered rooms for room list |
| is_host | bool | false | Whether local player is room host |
| is_lan_mode | bool | false | Direct ENet (no Nakama) |
| LAN_PORT | const int | 7777 | ENet server port |
| LAN_DISCOVERY_PORT | const int | 7778 | UDP broadcast port |
| local_player_name | String | "Player" | Display name |
| is_tutorial_mode | bool | false | Tutorial mode flag |
| match_duration | int | 180 | Seconds (configurable by host) |
| randomize_spawn | bool | false | Randomize spawn positions |
| enable_cycle_timer | bool | false | Goal cycle timer |
| scarcity_mode | String | "Normal" | Item scarcity: Normal/Aggressive/Chaos |
| disconnect_reason | String | "" | UI feedback message |
| sng_go_duration | int | 20 | Stop n Go: GO phase seconds |
| sng_stop_duration | int | 4 | Stop n Go: STOP phase seconds |
| sng_required_goals | int | 8 | Goals needed for SNG win |
| doors_swap_time | int | 15 | Tekton Doors: swap interval |
| doors_refresh_time | int | 25 | Tekton Doors: refresh interval |
| doors_required_goals | int | 8 | Goals needed for Doors win |
| rematch_votes | Array | [] | Player IDs who voted for rematch |
| available_characters | Array[String] | [...] | ["Copper", "Dabro", "Gatot", "Pip", "Random"] |
| available_areas | Array[String] | [] | Mode-specific area list |
| available_game_modes | Array[String] | [...] | ["Freemode", "Stop n Go", "Candy Pump Survival"] |
| selected_area | String | "Freemode Arena" | Currently selected area |
| game_mode | String | "Freemode" | Current game mode |
| local_character_index | int | 0 | Local player's character index |
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `room_list_updated` | rooms: Array | Room list refreshed |
| `room_joined` | room_data: Dictionary | Joined a room |
| `room_left` | none | Left current room |
| `player_joined` | player_data: Dictionary | Player entered room |
| `player_left` | player_id: int | Player left room |
| `ready_state_changed` | player_id: int, is_ready: bool | Player ready status changed |
| `all_players_ready` | none | All players ready |
| `host_disconnected` | none | Host left/disconnected |
| `game_starting` | none | Game countdown started |
| `match_duration_changed` | duration_seconds: int | Duration setting changed |
| `randomize_spawn_changed` | enabled: bool | Random spawn toggled |
| `character_changed` | player_id: int, character_name: String | Character selection changed |
| `area_changed` | area_name: String | Map area changed |
| `player_list_changed` | none | Player list should re-render |
| `rematch_votes_updated` | count: int, required: int | Rematch vote progress |
| `game_mode_changed` | mode: String | Game mode changed |
| `scarcity_mode_changed` | mode: String | Scarcity setting changed |
| `enable_cycle_timer_changed` | enabled: bool | Timer toggle changed |
| `sng_go_duration_changed` | duration: int | SNG Go duration changed |
| `sng_stop_duration_changed` | duration: int | SNG Stop duration changed |
| `sng_required_goals_changed` | goals: int | SNG required goals changed |
| `doors_swap_time_changed` | time: int | Doors swap interval changed |
| `doors_refresh_time_changed` | time: int | Doors refresh interval changed |
| `doors_required_goals_changed` | goals: int | Doors required goals changed |
| `gauntlet_round_duration_changed` | duration: int | Gauntlet round duration changed |
| `gauntlet_growth_interval_changed` | interval: float | Gauntlet growth interval changed |
| `gauntlet_cells_per_tick_changed` | cells: Dictionary | Cells per tick changed |
**Key Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `start_tutorial` | `func start_tutorial(mode: String = "Freemode") -> void` | void | Sets tutorial flags, calls create_room_lan("Tutorial") |
| `create_room` | `func create_room(room_name: String) -> void` | void | Hosts Nakama room: connects, calls NakamaManager.host_game |
| `join_room` | `func join_room(match_id: String) -> void` | void | Joins Nakama room by match ID |
| `create_room_lan` | `func create_room_lan(room_name: String = "LAN Game") -> bool` | bool | Creates ENet server on LAN_PORT, broadcasts UDP discovery |
| `join_room_lan` | `func join_room_lan(host_ip: String) -> bool` | bool | Creates ENet client to host IP:LAN_PORT |
| `leave_room` | -- | void | Leaves current room, cleans up peers |
| `start_game` | `func start_game(is_tutorial: bool = false) -> void` | void | Transitions from lobby to main game scene |
| `refresh_room_list` | `func refresh_room_list() -> void` | void | Queries Nakama for available rooms or broadcasts LAN |
| `set_ready` | `func set_ready(is_ready: bool) -> void` | void | Updates ready state via RPC |
| `set_match_duration` | `func set_match_duration(seconds: int) -> void` | void | Host sets match duration |
| `set_randomize_spawn` | `func set_randomize_spawn(enabled: bool) -> void` | void | Host toggles random spawn |
| `set_enable_cycle_timer` | `func set_enable_cycle_timer(enabled: bool) -> void` | void | Host toggles timer |
| `set_scarcity_mode` | `func set_scarcity_mode(mode: String) -> void` | void | Host sets scarcity |
| `set_game_mode` | `func set_game_mode(mode: String) -> void` | void | Host sets game mode |
| `cycle_character` | `func cycle_character(direction: int) -> void` | void | Change character selection |
| `cycle_area` | `func cycle_area(direction: int) -> void` | void | Change selected area |
| `get_players` | `func get_players() -> Array` | Array | Returns players_in_room |
| `is_all_ready` | `func is_all_ready() -> bool` | bool | All players ready check |
| `set_sng_go_duration` | -- | void | Host sets SNG go time |
| `set_sng_stop_duration` | -- | void | Host sets SNG stop time |
| `set_sng_required_goals` | -- | void | Host sets SNG goals |
| `get_selected_area` | `func get_selected_area() -> String` | String | Returns current area name |
| `get_game_mode` | `func get_game_mode() -> GameMode.Mode` | GameMode.Mode | Converts string to GameMode enum |
| `is_game_mode` | `func is_game_mode(mode: GameMode.Mode) -> bool` | bool | Mode comparison helper |
**Internal Functions:** `_on_match_joined`, `_on_peer_connected`, `_on_peer_disconnected`, `_on_server_disconnected`, `_update_available_areas`, `_start_lan_broadcast`, `_broadcast_lan_room`, `_stop_lan_broadcast`, `_update_lan_room_list`, `_listen_for_lan_discovery`, `_update_ready_state_rpc`, `_request_rematch`, `rpc_set_*`, `rpc_*`.
**Dependencies:** NakamaManager, GameStateManager.
**Depended by:** LobbyRoom, LobbyRoomList, LobbyMainMenu, main.gd, player.gd, lobby.gd, SceneManager (loading screen).
[Back to top](#top)
### 4.3 GameStateManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/game_state_manager.gd` (66 lines)
**Extends:** Node
**Autoload name:** GameStateManager
Simple state machine and match configuration constants.
**Properties:**
| Name | Type | Default | Description |
|---|---|---|---|
| current_state | GameState (enum) | LOBBY | Current application state |
| max_players | int | 8 | Max players in a match |
| enable_bots | bool | false | Bot fill toggle |
| local_player_id | int | 0 | Local peer ID |
**Enums:** `GameState { LOBBY, LOADING, GAME, RESULT }`
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `state_changed` | new_state: GameState | Emitted on state transition |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `change_state` | `func change_state(new_state: GameState) -> void` | void | Transitions state, emits state_changed |
**Dependencies:** None.
**Depended by:** LobbyManager, main.gd, tutorial_manager.gd, many managers.
[Back to top](#top)
### 4.4 PlayerManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/player_manager.gd` (37 lines)
**Extends:** Node
**Autoload name:** PlayerManager
Lightweight data container for player metadata. Stores display name and peer ID for the local player. Used as a quick reference by various subsystems.
**Properties:**
| Name | Type | Description |
|---|---|---|
| display_name | String | Local player's display name |
| peer_id | int | Local player's multiplayer unique ID |
**Signals:** None.
**Public Functions:** None (data-only container).
**Dependencies:** None.
**Depended by:** UIManager, player.gd, various managers needing player identity.
[Back to top](#top)
### 4.5 EventBus
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/event_bus.gd` (73 lines)
**Extends:** Node
**Autoload name:** EventBus
Centralized observer pattern for inter-manager communication. Replaces direct cross-references between managers.
**Constants (event names):**
| Constant | Value | Description |
|---|---|---|
| EVENT_PLAYER_JOINED | "player_joined" | Player entered match |
| EVENT_PLAYER_LEFT | "player_left" | Player left match |
| EVENT_PLAYER_READY | "player_ready" | Player ready state changed |
| EVENT_MATCH_STARTED | "match_started" | Match began |
| EVENT_MATCH_ENDED | "match_ended" | Match ended |
| EVENT_GAME_MODE_CHANGED | "game_mode_changed" | Game mode switched |
| EVENT_CURRENCY_CHANGED | "currency_changed" | Wallet balance changed |
| EVENT_ITEM_PURCHASED | "item_purchased" | Item bought from shop |
| EVENT_GACHA_PULL | "gacha_pull" | Gacha rolled |
| EVENT_PROFILE_LOADED | "profile_loaded" | Profile loaded from server |
| EVENT_PROFILE_UPDATED | "profile_updated" | Profile updated |
| EVENT_AVATAR_CHANGED | "avatar_changed" | Avatar changed |
| EVENT_SESSION_REFRESHED | "session_refreshed" | Nakama session refreshed |
| EVENT_SESSION_EXPIRED | "session_expired" | Nakama session expired |
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `event_emitted` | event_name: String, data: Variant | Fired on every emit |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `emit` | `func emit(event_name: String, data: Variant = null) -> void` | void | Emit event to all registered listeners and the signal bus |
| `on` | `func on(event_name: String, callback: Callable) -> void` | void | Subscribe to event |
| `off` | `func off(event_name: String, callback: Callable) -> void` | void | Unsubscribe from event |
| `clear` | `func clear() -> void` | void | Remove all listeners (scene transition cleanup) |
**Dependencies:** None.
**Depended by:** UserProfileManager, GachaManager, ShopManager, many managers for loose coupling.
[Back to top](#top)
### 4.6 GameMode / ModeConfig
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/game_mode.gd` (41 lines)
**Extends:** RefCounted
**class_name:** GameMode
Enum and string conversion utilities for game modes.
**Enum:** `Mode { FREEMODE = 0, STOP_N_GO = 1, TEKTON_DOORS = 2, GAUNTLET = 3 }`
**Public Static Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `from_string` | `static func from_string(mode: String) -> Mode` | Mode | Converts "Freemode"/"Stop n Go"/"Tekton Doors"/"Candy Pump Survival" to enum |
| `mode_to_string` | `static func mode_to_string(mode: Mode) -> String` | String | Converts enum back to string |
| `is_restricted` | `static func is_restricted(mode: Mode) -> bool` | bool | Returns true for SNG, Doors, or Gauntlet |
| `get_all_modes` | `static func get_all_modes() -> Array[String]` | Array[String] | Returns all mode names |
**File:** `/home/dev/tekton/scripts/mode_config.gd` (108 lines)
**Extends:** RefCounted
**class_name:** ModeConfig
Schema-driven validation for game mode settings. Consolidates duplicated/inconsistent option toggles.
**Public Static Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `get_defaults` | `static func get_defaults(mode: String) -> Dictionary` | Dictionary | Returns default config dict for mode |
| `validate_setting` | `static func validate_setting(mode: String, key: String, value: Variant) -> Dictionary` | Dictionary | Validates type, range, and allowed values for a single setting |
| `validate_config` | `static func validate_config(mode: String, config: Dictionary) -> Dictionary` | Dictionary | Validates entire config, returns errors array |
| `get_mode_settings` | `static func get_mode_settings(mode: String) -> Array` | Array | Returns list of setting keys for mode |
| `get_setting_schema` | `static func get_setting_schema(mode: String, key: String) -> Dictionary` | Dictionary | Returns schema for specific setting |
| `has_setting` | `static func has_setting(mode: String, key: String) -> bool` | bool | Checks if setting exists for mode |
| `get_supported_modes` | `static func get_supported_modes() -> Array` | Array | Returns all supported mode strings |
**Dependencies:** None (standalone utility classes).
**Depended by:** LobbyManager, LobbyRoom, mode-specific managers.
[Back to top](#top)
## 5. Player Subsystem Managers
[Back to top](#top)
### 5.1 PlayerMovementManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/player_movement_manager.gd` (33,053 chars)
**Extends:** Node
**Autoload name:** PlayerMovementManager
Handles player movement physics, grid-based pathfinding, movement range highlighting, position syncing, and obstacle-aware navigation. Delegated from player.gd.
**Signals:** (custom signals listed; full list from code)
| Signal | Params | Description |
|---|---|---|
| `movement_started` | path: Array | Emitted when player begins moving |
| `movement_completed` | none | Emitted when movement tween finishes |
| `movement_interrupted` | none | Emitted when movement is cancelled |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `move_along_path` | `func move_along_path(player: Node, path: Array) -> void` | void | Tweens player along grid path |
| `find_path` | `func find_path(from: Vector2i, to: Vector2i, gridmap: Node) -> Array` | Array | A* or BFS pathfinding on grid |
| `highlight_movement_range` | `func highlight_movement_range(player: Node) -> void` | void | Shows reachable cells |
| `highlight_adjacent_cells` | `func highlight_adjacent_cells(player: Node) -> void` | void | Shows cardinal-adjacent cells |
| `rotate_towards_target` | `func rotate_towards_target(target_pos: Vector2i) -> void` | void | Smooth rotation to face target |
| `can_move_to` | `func can_move_to(pos: Vector2i, gridmap: Node) -> bool` | bool | Cell walkability check |
| `apply_stagger` | `func apply_stagger(duration: float) -> void` | void | Applies stun knockback |
| `sync_bump` | `func sync_bump(target_pos: Vector2i, is_soft: bool) -> void` | void | Visual bump animation |
| `set_player_moving` | `func set_player_moving(is_moving: bool) -> void` | void | Toggle movement state |
**Dependencies:** player.gd (node refs), ObstacleManager, SpecialTilesManager, EnhancedGridMap.
**Depended by:** player.gd, main.gd.
[Back to top](#top)
### 5.2 PlayerInputManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/player_input_manager.gd` (7,292 chars)
**Extends:** Node
**Autoload name:** PlayerInputManager
Captures and buffers player input events. Supports keyboard, mouse, gamepad, and touch inputs. Provides input state query API.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `input_received` | event: InputEvent | Raw input forwarded |
| `action_pressed` | action: String | Action mapped press (grab, put, move) |
| `action_released` | action: String | Action released |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `is_action_held` | `func is_action_held(action: String) -> bool` | bool | Check if action is currently held |
| `get_movement_direction` | `func get_movement_direction() -> Vector2i` | Vector2i | Grid-aligned movement cardinal |
| `get_look_direction` | `func get_look_direction(camera: Camera3D) -> Vector2` | Vector2 | Mouse-world direction |
| `flush_buffer` | `func flush_buffer() -> void` | void | Clear input buffer |
| `is_touch_active` | `func is_touch_active() -> bool` | bool | Whether touch controls are in use |
**Dependencies:** TouchControls (autoload).
**Depended by:** player.gd, player_action_manager.gd.
[Back to top](#top)
### 5.3 PlayerActionManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/player_action_manager.gd` (8,828 chars)
**Extends:** Node
**Autoload name:** PlayerActionManager
Action execution layer. Manages grab, put, arrange, tekton throw/knock actions. Handles action point consumption, cooldowns, and visual highlighting.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `action_executed` | action_type: String | Action performed |
| `action_failed` | reason: String | Action invalid |
| `action_points_changed` | points: int | AP updated |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `execute_grab` | `func execute_grab(player: Node, grid_pos: Vector2i) -> bool` | bool | Grab item from grid |
| `execute_put` | `func execute_put(player: Node, slot_index: int, grid_pos: Vector2i) -> bool` | bool | Put item from playerboard to grid |
| `execute_arrange` | `func execute_arrange(player: Node, from_slot: int, to_slot: int) -> bool` | bool | Rearrange playerboard slots |
| `consume_action_points` | `func consume_action_points(points: int) -> void` | void | Deduct action points |
| `can_afford_action` | `func can_afford_action() -> bool` | bool | Check AP > 0 |
| `after_action_completed` | `func after_action_completed() -> void` | void | Post-action cleanup: check win, cycle goals |
| `highlight_cells_if_authorized` | `func highlight_cells_if_authorized(cells: Array, item_id: int) -> void` | void | Show valid target cells |
| `highlight_empty_adjacent_cells` | `func highlight_empty_adjacent_cells() -> void` | void | Show empty adjacent cells for put |
| `highlight_occupied_playerboard_slots` | `func highlight_occupied_playerboard_slots() -> void` | void | Show occupied slots for grab |
| `highlight_random_valid_cells` | `func highlight_random_valid_cells() -> void` | void | Show random valid cells |
| `clear_highlights` | `func clear_highlights() -> void` | void | Remove all cell highlights |
| `clear_playerboard_highlights` | `func clear_playerboard_highlights() -> void` | void | Remove playerboard highlights |
**Dependencies:** PlayerboardManager, PlayerInputManager, GoalsCycleManager.
**Depended by:** player.gd, main.gd.
[Back to top](#top)
### 5.4 PlayerboardManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/playerboard_manager.gd` (22,790 chars)
**Extends:** Node
**Autoload name:** PlayerboardManager
Manages each player's inventory board (2x5 or 3x5 grid of item slots). Handles slot selection, item placement, auto-arrange for goal matching, drag-and-drop, and visual updates.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `slot_selected` | slot_index: int | Slot clicked/selected |
| `slot_deselected` | none | Selection cleared |
| `item_placed` | slot_index: int, item_id: int | Item added to slot |
| `item_removed` | slot_index: int | Item removed from slot |
| `playerboard_updated` | player_id: int, board: Array | Full board synced |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `grab_item` | `func grab_item(grid_pos: Vector2i) -> bool` | bool | Auto-place grabbed item into best-fit slot |
| `auto_put_item` | `func auto_put_item() -> bool` | bool | Put goal-matching tile from board to adjacent grid |
| `handle_slot_clicked` | `func handle_slot_clicked(slot_index: int) -> void` | void | Process slot click event |
| `handle_playerboard_slot_selected` | `func handle_playerboard_slot_selected(slot_index: int) -> void` | void | Handle slot selection for action |
| `handle_put_slot_selected` | `func handle_put_slot_selected(slot_index: int) -> void` | void | Handle slot chosen for put action |
| `arrange_playerboard_item` | `func arrange_playerboard_item(slot_index: int) -> void` | void | Move item to better slot |
| `select_playerboard_slot` | `func select_playerboard_slot(slot_index: int) -> void` | void | Mark slot as selected |
| `deselect_playerboard_slot` | `func deselect_playerboard_slot() -> void` | void | Clear slot selection |
| `target_playerboard_slot` | `func target_playerboard_slot(slot_index: int) -> void` | void | Target a slot for move |
| `untarget_playerboard_slot` | `func untarget_playerboard_slot() -> void` | void | Clear target |
| `can_move_to_target_playerboard_slot` | `func can_move_to_target_playerboard_slot() -> bool` | bool | Check if target slot is valid |
| `bot_grab_item` | `func bot_grab_item(pos: Vector2i, slot: int, x: int, y: int, z: int) -> void` | void | Bot performs grab |
| `bot_put_item` | `func bot_put_item(pos: Vector2i, slot: int, x: int, y: int, z: int) -> void` | void | Bot performs put |
| `bot_arrange_item` | `func bot_arrange_item(from_slot: int, to_slot: int) -> void` | void | Bot rearranges board |
**Dependencies:** GoalsCycleManager, GoalManager, EnhancedGridMap (scene ref).
**Depended by:** player.gd, PlayerActionManager.
[Back to top](#top)
### 5.5 PowerupManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/powerup_manager.gd` (9,417 chars)
**Extends:** Node
**Autoload name:** PowerupManager
Powerup/boost system. Tracks boost charge level, special ability availability, and consumes boost for charged actions.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `boost_changed` | amount: float | Boost level changed |
| `boost_full` | none | Boost reached 100% |
| `powerup_activated` | type: String | Powerup used |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `add_boost` | `func add_boost(amount: float) -> void` | void | Increment boost |
| `consume_boost` | `func consume_boost(amount: float) -> void` | void | Deduct boost |
| `can_use_special` | `func can_use_special() -> bool` | bool | Boost >= 100 |
| `get_boost_pct` | `func get_boost_pct() -> float` | float | 0.0 to 1.0 |
| `reset_boost` | `func reset_boost() -> void` | void | Set to 0 |
**Dependencies:** None.
**Depended by:** player.gd (charged strike, knock), PlayerActionManager.
[Back to top](#top)
## 6. Game Mode Managers
[Back to top](#top)
### 6.1 StopNGoManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/stop_n_go_manager.gd` (21,884 chars)
**Extends:** Node
**Autoload name:** StopNGoManager
State machine for the Stop n Go game mode. Alternates between GO (movement allowed) and STOP (frozen) phases. Tracks winner via first player to complete required goals during GO phases.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `phase_changed` | phase: String ("go"/"stop") | GO/STOP transition |
| `countdown_tick` | seconds: int | Phase countdown tick |
| `sng_winner` | player_id: int | Winner determined |
| `sng_ended` | none | Minigame concluded |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `start_sng` | `func start_sng(go_duration: int, stop_duration: int, required_goals: int) -> void` | void | Initialize SNG with params |
| `stop_sng` | `func stop_sng() -> void` | void | End SNG minigame |
| `start_go_phase` | `func start_go_phase() -> void` | void | Begin GO timer |
| `start_stop_phase` | `func start_stop_phase() -> void` | void | Begin STOP timer, freeze all |
| `freeze_player` | `func freeze_player(player_id: int) -> void` | void | Stop player movement |
| `unfreeze_player` | `func unfreeze_player(player_id: int) -> void` | void | Resume player movement |
| `check_winner` | `func check_winner() -> int` | int | Returns winner peer_id or -1 |
| `get_phase` | `func get_phase() -> String` | String | Current phase |
**Dependencies:** TurnManager, GoalManager, GoalsCycleManager, animation.gd (scene).
**Depended by:** main.gd, player.gd.
[Back to top](#top)
### 6.2 GauntletManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/gauntlet_manager.gd` (5,467 chars)
**Extends:** Node
**Autoload name:** GauntletManager
Manages the Candy Pump Survival / Gauntlet game mode. Handles round progression, danger zone growth (flood fill), and elimination.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `round_started` | round: int | New round began |
| `danger_zone_grown` | cells: Array | New tiles flooded |
| `player_eliminated` | player_id: int | Player fell off/eliminated |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `start_gauntlet` | `func start_gauntlet(duration: int, growth_interval: float) -> void` | void | Initialize gauntlet mode |
| `stop_gauntlet` | `func stop_gauntlet() -> void` | void | End gauntlet mode |
| `eliminate_player` | `func eliminate_player(player_id: int) -> void` | void | Mark player as eliminated |
| `get_alive_players` | `func get_alive_players() -> Array` | Array | Returns non-eliminated player IDs |
| `get_round` | `func get_round() -> int` | int | Current round number |
**Dependencies:** TurnManager, EnhancedGridMap.
**Depended by:** main.gd.
[Back to top](#top)
### 6.3 PortalModeManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/portal_mode_manager.gd` (20,072 chars)
**Extends:** Node
**Autoload name:** PortalModeManager
Manages portal race mode (Tekton Doors variant). Tracks portal positions, door swapping, refresh cycles, and race completion.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `portals_swapped` | portal_pairs: Array | Doors swapped positions |
| `portals_refreshed` | portals: Array | New portal set spawned |
| `player_teleported` | player_id: int, from: Vector2i, to: Vector2i | Player used portal |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `start_portal_mode` | `func start_portal_mode(swap_time: int, refresh_time: int) -> void` | void | Initialize portal mode |
| `stop_portal_mode` | `func stop_portal_mode() -> void` | void | End portal mode |
| `teleport_player` | `func teleport_player(player: Node, portal_enter: Vector2i) -> void` | void | Teleport player through portal pair |
| `swap_portals` | `func swap_portals() -> void` | void | Randomize portal positions |
| `refresh_portals` | `func refresh_portals() -> void` | void | Spawn new portal set |
| `get_portal_pair` | `func get_portal_pair(portal_id: int) -> Array` | Array | Returns [entry, exit] positions |
**Dependencies:** SpecialTilesManager, EnhancedGridMap.
**Depended by:** main.gd.
[Back to top](#top)
### 6.4 GoalManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/goal_manager.gd` (3,857 chars)
**Extends:** Node
**Autoload name:** GoalManager
Goal definitions, validation rules, and completion detection. Checks if a player's board arrangement matches the current goal pattern.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `goal_completed` | player_id: int, goal_id: int | Player completed a goal |
| `goal_failed` | player_id: int, reason: String | Goal became impossible |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `validate_goal` | `func validate_goal(player_board: Array, goal: Dictionary) -> bool` | bool | Check board matches goal pattern |
| `get_goal_type` | `func get_goal_type(goal: Dictionary) -> String` | String | Goal category (row, col, set, pattern) |
| `is_goal_possible` | `func is_goal_possible(player_board: Array, goal: Dictionary) -> bool` | bool | Whether goal is still achievable |
| `find_best_slot_for_item` | `func find_best_slot_for_item(board: Array, item: int, goal: Dictionary) -> int` | int | Auto-place item into best slot |
**Dependencies:** None.
**Depended by:** GoalsCycleManager, PlayerboardManager, StopNGoManager.
[Back to top](#top)
### 6.5 GoalsCycleManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/goals_cycle_manager.gd` (20,175 chars)
**Extends:** Node
**Autoload name:** GoalsCycleManager
Manages cycling goal rotation. Tracks per-player score, cycles active goals on timer or action trigger, and determines when a player reaches the goal threshold to win.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `goals_cycled` | new_goals: Array | Active goals changed |
| `player_scored` | player_id: int, points: int | Player earned points |
| `player_won` | player_id: int | Player reached win threshold |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `start_cycle` | `func start_cycle(timer_enabled: bool) -> void` | void | Begin goal cycling |
| `stop_cycle` | `func stop_cycle() -> void` | void | Stop cycling |
| `cycle_goals` | `func cycle_goals() -> void` | void | Generate new goal set |
| `add_score` | `func add_score(player_id: int, points: int) -> void` | void | Award points to player |
| `get_player_score` | `func get_player_score(player_id: int) -> int` | int | Get player's current score |
| `get_current_goals` | `func get_current_goals() -> Array` | Array | Get active goals |
| `set_goal_threshold` | `func set_goal_threshold(goals_needed: int) -> void` | void | Set goals to win |
**Dependencies:** GoalManager, TurnManager, Timer (scene).
**Depended by:** main.gd, PlayerActionManager, StopNGoManager.
[Back to top](#top)
### 6.6 PlayerRaceManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/player_race_manager.gd` (4,757 chars)
**Extends:** Node
**Autoload name:** PlayerRaceManager
Race-specific logic. Tracks player race position, finish locations, lap progression, and race completion.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `position_changed` | player_id: int, pos: int | Player moved in race order |
| `lap_completed` | player_id: int, lap: int | Player finished a lap |
| `race_completed` | results: Array | Final standings [{id, position}] |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `start_race` | `func start_race() -> void` | void | Initialize race state |
| `end_race` | `func end_race() -> void` | void | Finalize race |
| `on_race_completed` | `func on_race_completed(final_pos: int) -> void` | void | Player crossed finish line |
| `get_current_finish_locations` | `func get_current_finish_locations() -> Array` | Array | Active finish positions |
| `update_finish_availability` | `func update_finish_availability() -> void` | void | Recalculate finish positions |
| `get_player_position` | `func get_player_position(player_id: int) -> int` | int | Current race order index |
| `add_second_lap_goals` | `func add_second_lap_goals(goals: Array) -> void` | void | Set lap 2 goals |
**Dependencies:** GoalsCycleManager.
**Depended by:** player.gd, main.gd.
[Back to top](#top)
### 6.7 TurnManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/turn_manager.gd` (849 chars)
**Extends:** Node
**Autoload name:** TurnManager
Turn-based sequencing for game modes that use round-robin or ordered turns (e.g., Stop n Go, Tekton Doors).
**Properties:**
| Name | Type | Description |
|---|---|---|
| current_turn | int | Index in turn order |
| turn_order | Array | Player peer IDs in sequence |
| is_my_turn | bool | Whether local player is active |
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `turn_changed` | player_id: int | Active turn changed |
| `turn_order_set` | order: Array | Turn order established |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `set_turn_order` | `func set_turn_order(order: Array) -> void` | void | Establish turn sequence |
| `next_turn` | `func next_turn() -> void` | void | Advance to next player |
| `get_current_player` | `func get_current_player() -> int` | int | Current player peer ID |
**Dependencies:** None.
**Depended by:** StopNGoManager, GauntletManager, GoalsCycleManager.
[Back to top](#top)
## 7. Gameplay Managers
[Back to top](#top)
### 7.1 ObstacleManager
[Back to top](#up)
**File:** `/home/dev/tekton/scripts/managers/obstacle_manager.gd` (5,662 chars)
**Extends:** Node
**Autoload name:** ObstacleManager
Obstacle placement and removal on the game grid. Handles wall tiles, blocking tiles, and destructible barriers.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `obstacle_placed` | cell: Vector3i, item_id: int | New obstacle added |
| `obstacle_removed` | cell: Vector3i | Obstacle destroyed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `place_obstacle` | `func place_obstacle(cell: Vector3i, item_id: int) -> void` | void | Set obstacle on grid layer |
| `remove_obstacle` | `func remove_obstacle(cell: Vector3i) -> void` | void | Clear obstacle |
| `is_cell_blocked` | `func is_cell_blocked(cell: Vector3i, gridmap: Node) -> bool` | bool | Check if cell has blocking tile |
| `get_blocked_cells` | `func get_blocked_cells(gridmap: Node) -> Array` | Array | All blocked cells |
| `clear_all_obstacles` | `func clear_all_obstacles() -> void` | void | Remove all obstacles |
**Dependencies:** EnhancedGridMap (scene ref).
**Depended by:** PlayerMovementManager, SpecialTilesManager.
[Back to top](#top)
### 7.2 SpecialTilesManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/special_tiles_manager.gd` (23,090 chars)
**Extends:** Node
**Autoload name:** SpecialTilesManager
Manages special floor tiles: ice (slippery), crack (breakable), portal tiles, teleporters, and other interactive terrain.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `tile_activated` | pos: Vector2i, tile_type: String | Tile effect triggered |
| `ice_slide_started` | player_id: int | Player started sliding |
| `crack_broke` | pos: Vector2i | Crack tile collapsed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `apply_tile_effect` | `func apply_tile_effect(player: Node, pos: Vector2i) -> void` | void | Activate tile effect on player |
| `get_tile_at` | `func get_tile_at(pos: Vector2i, gridmap: Node) -> int` | int | Item ID at position |
| `set_tile` | `func set_tile(pos: Vector2i, item_id: int, gridmap: Node) -> void` | void | Set tile item |
| `is_ice_tile` | `func is_ice_tile(item_id: int) -> bool` | bool | Check ice type |
| `is_crack_tile` | `func is_crack_tile(item_id: int) -> bool` | bool | Check crack type |
| `is_portal_tile` | `func is_portal_tile(item_id: int) -> bool` | bool | Check portal type |
| `spawn_portal_pair` | `func spawn_portal_pair(pos_a: Vector2i, pos_b: Vector2i) -> void` | void | Create portal entry/exit |
| `remove_portal_pair` | `func remove_portal_pair(pos_a: Vector2i, pos_b: Vector2i) -> void` | void | Remove portal tiles |
**Dependencies:** EnhancedGridMap, ObstacleManager, PortalModeManager.
**Depended by:** PlayerMovementManager.
[Back to top](#top)
### 7.3 StaticTektonManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/static_tekton_manager.gd` (7,416 chars)
**Extends:** Node
**Autoload name:** StaticTektonManager
Manages stationary Tekton turret behavior. Handles targeting, projectile spawning, and stun zones.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `turret_fired` | turret_id: int, target_pos: Vector2i | Turret shot |
| `turret_stunned` | turret_id: int | Turret disabled |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `activate_turret` | `func activate_turret(turret: Node) -> void` | void | Start turret behavior |
| `deactivate_turret` | `func deactivate_turret(turret: Node) -> void` | void | Stop turret |
| `fire_at_player` | `func fire_at_player(turret: Node, target: Vector2i) -> void` | void | Fire projectile at grid pos |
**Dependencies:** EnhancedGridMap, ObstacleManager.
**Depended by:** main.gd.
[Back to top](#top)
## 8. UI / Presentation Managers
[Back to top](#top)
### 8.1 UIManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/ui_manager.gd` (21,645 chars)
**Extends:** Node
**Autoload name:** UIManager
Manages the UI layer stack: show/hide panels, overlay management, HUD elements, and dynamic UI creation.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `panel_opened` | panel_name: String | Panel shown |
| `panel_closed` | panel_name: String | Panel hidden |
| `hud_updated` | data: Dictionary | HUD refresh |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `show_panel` | `func show_panel(panel_name: String, data: Dictionary = {}) -> void` | void | Show named panel |
| `hide_panel` | `func hide_panel(panel_name: String) -> void` | void | Hide named panel |
| `toggle_panel` | `func toggle_panel(panel_name: String) -> void` | void | Toggle panel visibility |
| `show_hud` | `func show_hud() -> void` | void | Display HUD |
| `hide_hud` | `func hide_hud() -> void` | void | Hide HUD |
| `create_dynamic_ui` | `func create_dynamic_ui(scene_path: String) -> Node` | Node | Instantiate UI from tscn |
| `destroy_dynamic_ui` | `func destroy_dynamic_ui(ui_node: Node) -> void` | void | Remove dynamic UI |
| `focus_panel` | `func focus_panel(panel_name: String) -> void` | void | Bring panel to front |
| `get_active_panels` | `func get_active_panels() -> Array` | Array | Currently visible panels |
**Dependencies:** None.
**Depended by:** main.gd, lobby.gd.
[Back to top](#top)
### 8.2 SfxManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/sfx_manager.gd` (2,046 chars)
**Extends:** Node
**Autoload name:** SfxManager
Sound effect playback pool. Manages one-shot SFX with positional audio support.
**Signals:** None.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `play` | `func play(sfx_name: String, position: Vector3 = Vector3.ZERO) -> void` | void | Play SFX by name, optionally 3D positioned |
| `stop` | `func stop(sfx_name: String) -> void` | void | Stop specific SFX |
| `stop_all` | `func stop_all() -> void` | void | Silence all SFX |
| `set_volume` | `func set_volume(db: float) -> void` | void | Set master SFX volume |
**Dependencies:** AudioStreamPlayer pool (scene).
**Depended by:** player.gd, StopNGoManager, UIManager, many gameplay managers.
[Back to top](#top)
### 8.3 MusicManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/music_manager.gd` (4,082 chars)
**Extends:** Node
**Autoload name:** MusicManager
Background music controller. Handles crossfade between tracks, playlist sequencing, and volume control.
**Signals:** None.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `start_music` | `func start_music(track_name: String = "") -> void` | void | Begin playing track or playlist |
| `stop_music` | `func stop_music(fade: float = 0.5) -> void` | void | Fade out and stop |
| `crossfade_to` | `func crossfade_to(track_name: String, fade_duration: float = 1.0) -> void` | void | Smooth transition |
| `set_volume` | `func set_volume(db: float) -> void` | void | Set master music volume |
| `set_paused` | `func set_paused(paused: bool) -> void` | void | Pause/resume |
| `get_current_track` | `func get_current_track() -> String` | String | Currently playing track name |
**Dependencies:** AudioStreamPlayer (scene).
**Depended by:** lobby.gd, main.gd.
[Back to top](#top)
### 8.4 NotificationManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/notification_manager.gd` (2,215 chars)
**Extends:** Node
**Autoload name:** NotificationManager
On-screen message queue. Displays transient notification messages with type-based styling.
**Properties:**
| Name | Type | Description |
|---|---|---|
| MessageType (enum) | {NORMAL, WARNING, POWERUP, ERROR, SYSTEM} | Message severity/style |
**Signals:** None.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `send_message` | `func send_message(sender: Node, message: String, msg_type: int = 0) -> void` | void | Queue message for display |
| `clear_messages` | `func clear_messages() -> void` | void | Clear all pending messages |
| `get_message_queue` | `func get_message_queue() -> Array` | Array | Current pending messages |
**Dependencies:** None.
**Depended by:** player.gd, main.gd (unstuck feedback), StopNGoManager, many gameplay managers.
[Back to top](#top)
### 8.5 ScreenShake
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/screen_shake.gd` (1,839 chars)
**Extends:** Node
**Autoload name:** ScreenShake
Camera screen shake effect manager. Applies noise-based displacement to Camera3D.
**Signals:** None.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `shake` | `func shake(intensity: float, duration: float = 0.3) -> void` | void | Trigger camera shake |
| `stop_shake` | `func stop_shake() -> void` | void | Stop ongoing shake |
**Dependencies:** Camera3D (scene).
**Depended by:** player.gd (heavy knock triggers shake), main.gd.
[Back to top](#top)
### 8.6 CameraContextManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/camera_context_manager.gd` (2,543 chars)
**Extends:** Node
**Autoload name:** CameraContextManager
Camera zoom level and context switching. Manages follow-camera behavior, zoom levels for different game phases, and camera transitions.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `zoom_changed` | level: float | Camera zoom level changed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `set_zoom` | `func set_zoom(level: float) -> void` | void | Set camera zoom |
| `get_zoom` | `func get_zoom() -> float` | float | Current zoom |
| `focus_on_player` | `func focus_on_player(player_id: int) -> void` | void | Snap camera to player |
| `focus_on_position` | `func focus_on_position(world_pos: Vector3) -> void` | void | Center camera on position |
**Dependencies:** Camera3D (scene).
**Depended by:** main.gd.
[Back to top](#top)
### 8.7 TouchControls
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/touch_controls.gd` (23,640 chars)
**Extends:** Node
**Autoload name:** TouchControls
Mobile touch input overlay. Provides virtual joystick, action buttons, and gesture recognition for grid-based controls.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `touch_moved` | direction: Vector2i | Grid direction from swipe |
| `action_triggered` | action: String | Touch button pressed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `set_joystick_enabled` | `func set_joystick_enabled(enabled: bool) -> void` | void | Toggle joystick |
| `get_joystick_direction` | `func get_joystick_direction() -> Vector2` | Vector2 | Normalized joystick |
| `_save_settings` | internal | void | Persist touch control settings |
**Dependencies:** InputManager (scene).
**Depended by:** PlayerInputManager.
[Back to top](#top)
### 8.8 TutorialManager / TutorialOverlay
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/tutorial_manager.gd` (22,243 chars)
**Extends:** Node
**Autoload name:** TutorialManager
Tutorial flow controller. Manages step-by-step tutorial sequences, triggers, and completion tracking.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `tutorial_started` | tutorial_id: String | Tutorial began |
| `step_completed` | step: int | Step finished |
| `tutorial_completed` | tutorial_id: String | Tutorial fully complete |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `start_tutorial` | `func start_tutorial(tutorial_id: String) -> void` | void | Begin tutorial sequence |
| `advance_step` | `func advance_step() -> void` | void | Move to next step |
| `skip_tutorial` | `func skip_tutorial() -> void` | void | Exit tutorial early |
| `is_tutorial_active` | `func is_tutorial_active() -> bool` | bool | Tutorial in progress |
| `get_current_step` | `func get_current_step() -> int` | int | Current step index |
| `get_total_steps` | `func get_total_steps() -> int` | int | Total steps in tutorial |
**File:** `/home/dev/tekton/scripts/managers/tutorial_overlay.gd` (11,077 chars)
**Extends:** Node
**Autoload name:** TutorialOverlay
Tutorial UI overlay. Displays step instructions, highlights UI elements, and provides step navigation.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `overlay_closed` | none | Overlay dismissed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `show_step` | `func show_step(step_data: Dictionary) -> void` | void | Display step with text + highlight |
| `hide_overlay` | `func hide_overlay() -> void` | void | Dismiss overlay |
| `highlight_element` | `func highlight_element(node_path: NodePath) -> void` | void | Spotlight a UI element |
| `clear_highlights` | `func clear_highlights() -> void` | void | Remove spotlights |
**Dependencies:** TutorialManager, UIManager.
**Depended by:** TutorialManager.
[Back to top](#top)
## 9. Social / Economy Managers
[Back to top](#top)
### 9.1 UserProfileManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/user_profile_manager.gd` (20,044 chars)
**Extends:** Node
**Autoload name:** UserProfileManager
User profile CRUD operations. Manages display name, avatar, bio, wallet balance, stats, and loadout configuration. Syncs with Nakama storage.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `profile_loaded` | profile: Dictionary | Profile fetched from server |
| `profile_updated` | none | Profile modified locally |
| `wallet_updated` | wallet: Dictionary | Balance changed |
| `stats_updated` | stats: Dictionary | Player stats changed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `load_profile` | `func load_profile() -> void` | void (async) | Fetch profile from Nakama storage |
| `save_profile` | `func save_profile() -> void` | void (async) | Persist profile to Nakama |
| `get_display_name` | `func get_display_name(fallback: String = "Player") -> String` | String | Display name with fallback |
| `set_display_name` | `func set_display_name(name: String) -> void` | void | Update display name |
| `get_avatar_url` | `func get_avatar_url() -> String` | String | Current avatar path |
| `set_avatar` | `func set_avatar(url: String) -> void` | void | Change avatar |
| `get_wallet_balance` | `func get_wallet_balance(currency: String) -> int` | int | Balance for gold/star |
| `get_stats` | `func get_stats() -> Dictionary` | Dictionary | Player stats snapshot |
| `update_stats` | `func update_stats(delta: Dictionary) -> void` | void | Increment stats |
| `get_loadout` | `func get_loadout() -> Dictionary` | Dictionary | Current cosmetics loadout |
| `set_loadout` | `func set_loadout(loadout: Dictionary) -> void` | void | Save cosmetics config |
| `get_loadout_character` | `func get_loadout_character() -> String` | String | Selected character name |
| `sync_wallet` | `func sync_wallet() -> void` | void (async) | Refresh wallet from server |
**Dependencies:** NakamaManager, EventBus, BackendService.
**Depended by:** LobbyMainMenu, lobby.gd, ShopManager, GachaManager, SkinManager, many UI panels.
[Back to top](#top)
### 9.2 GachaManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/gacha_manager.gd` (5,117 chars)
**Extends:** Node
**Autoload name:** GachaManager
Gacha pull orchestration. Calls BackendService.perform_gacha_pull, processes results, updates inventory.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `gacha_result` | items: Array, fragments: Array | Pull results |
| `gacha_error` | error: String | Pull failed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `perform_pull` | `func perform_pull(gacha_id: String, count: int) -> void` | void (async) | Execute gacha pull RPC |
| `get_pity_count` | `func get_pity_count(banner_id: String) -> int` | int | Current pity counter |
**Dependencies:** BackendService, UserProfileManager, EventBus.
**Depended by:** gacha_panel.tscn (scene UI).
[Back to top](#top)
### 9.3 SkinManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/skin_manager.gd` (13,909 chars)
**Extends:** Node
**Autoload name:** SkinManager
Cosmetic skin system. Manages skin definitions, owned skins, equipped loadout, and applies cosmetics to character models.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `skin_equipped` | skin_id: String | Skin applied |
| `skin_unequipped` | skin_id: String | Skin removed |
| `inventory_updated` | owned_skins: Array | Inventory changed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `equip_skin` | `func equip_skin(skin_id: String, slot: String) -> void` | void | Equip skin to slot |
| `unequip_skin` | `func unequip_skin(slot: String) -> void` | void | Unequip from slot |
| `is_skin_owned` | `func is_skin_owned(skin_id: String) -> bool` | bool | Check ownership |
| `get_equipped_skins` | `func get_equipped_skins() -> Dictionary` | Dictionary | Current loadout |
| `apply_loadout` | `func apply_loadout(character_root: Node3D, loadout: Dictionary) -> void` | void | Apply cosmetics to 3D model |
| `get_skins_for_character` | `func get_skins_for_character(char_name: String) -> Array` | Array | Available skins |
| `get_all_skins` | `func get_all_skins() -> Array` | Array | All skin definitions |
**Dependencies:** UserProfileManager.
**Depended by:** lobby.gd (3D preview), SkinShop UI.
[Back to top](#top)
### 9.4 ShopManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/shop_manager.gd` (484 chars)
**Extends:** Node
**Autoload name:** ShopManager
Thin data layer for shop catalog. Currently a stub; full shop logic lives in scene scripts.
**Properties:** Minimal (shop catalog array).
**Signals:** None.
**Public Functions:** None (data container only).
**Dependencies:** BackendService.
**Depended by:** shop_panel.tscn (scene).
[Back to top](#top)
### 9.5 JoinManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/join_manager.gd` (484 chars)
**Extends:** Node
**Autoload name:** JoinManager
Thin manager for join code input and validation. Minimal stub.
**Properties:** Minimal.
**Signals:** None.
**Public Functions:** None (stub).
**Dependencies:** None.
**Depended by:** lobby.gd (join code UI).
[Back to top](#top)
### 9.6 FriendManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/friend_manager.gd` (11,911 chars)
**Extends:** Node
**Autoload name:** FriendManager
Friends list management. Handles friend requests, accept/reject, friend list sync, DM messaging, and lobby invitations.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `friend_list_updated` | friends: Array | Friend list refreshed |
| `friend_request_received` | from_user_id: String | Incoming request |
| `friend_added` | user_id: String | Friendship established |
| `friend_removed` | user_id: String | Friendship ended |
| `dm_message_received` | from_user_id: String, from_name: String, message: String | Direct message |
| `lobby_invite_received` | from_user_id: String, from_name: String, match_id: String | Lobby invitation |
| `friend_online_changed` | user_id: String, online: bool | Presence changed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `add_friend_by_id` | `func add_friend_by_id(nakama_id: String) -> bool` | bool (async) | Send friend request |
| `remove_friend` | `func remove_friend(user_id: String) -> void` | void (async) | Remove friendship |
| `accept_request` | `func accept_request(user_id: String) -> void` | void (async) | Accept friend request |
| `decline_request` | `func decline_request(user_id: String) -> void` | void (async) | Decline request |
| `get_friends` | `func get_friends() -> Array` | Array | Current friends list |
| `get_mutual_friends` | `func get_mutual_friends() -> Array` | Array | Friends also in room |
| `is_friend` | `func is_friend(nakama_id: String) -> bool` | bool | Check friendship |
| `send_dm` | `func send_dm(user_id: String, text: String) -> bool` | bool (async) | Send direct message |
| `get_dm_history` | `func get_dm_history(user_id: String) -> Array` | Array (async) | Fetch DM history |
| `send_lobby_invite` | `func send_lobby_invite(to_user_id: String, match_id: String) -> void` | void (async) | Send invitation |
**Dependencies:** BackendService, NakamaManager.
**Depended by:** LobbyRoom, LobbyChat, lobby.gd.
[Back to top](#top)
### 9.7 MailManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/mail_manager.gd` (5,271 chars)
**Extends:** Node
**Autoload name:** MailManager
Mail/inbox CRUD operations. Calls BackendService RPCs for get, claim, delete mail.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `mail_updated` | mails: Array | Mail list refreshed |
| `unread_count_changed` | count: int | Unread mail count |
| `mail_claimed` | mail_id: String, rewards: Dictionary | Reward collected |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `fetch_mail` | `func fetch_mail() -> void` | void (async) | Fetch mailbox |
| `claim_mail` | `func claim_mail(mail_id: String) -> void` | void (async) | Claim reward |
| `delete_mail` | `func delete_mail(mail_id: String) -> void` | void (async) | Delete mail |
| `get_unread_count` | `func get_unread_count() -> int` | int | Unread count |
**Dependencies:** BackendService.
**Depended by:** lobby.gd, mailbox_panel.tscn (scene).
[Back to top](#top)
### 9.8 DailyRewardManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/daily_reward_manager.gd` (1,009 chars)
**Extends:** Node
**Autoload name:** DailyRewardManager
Daily reward system. Handles claim state, reward config, and streak tracking.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `reward_claimed` | day: int, reward: Dictionary | Daily reward collected |
| `streak_updated` | streak: int | Consecutive days |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `claim_daily_reward` | `func claim_daily_reward() -> void` | void (async) | Claim today's reward |
| `get_reward_state` | `func get_reward_state() -> Dictionary` | Dictionary (async) | Current state + schedule |
| `can_claim_today` | `func can_claim_today() -> bool` | bool | Check if claimable |
**Dependencies:** BackendService.
**Depended by:** daily_reward_panel.tscn (scene).
[Back to top](#top)
### 9.9 AdminManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/admin_manager.gd` (2,538 chars)
**Extends:** Node
**Autoload name:** AdminManager
Admin panel state and permission checks. Determines if local player is admin or moderator.
**Signals:** None.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `_check_admin_status` | `func _check_admin_status() -> bool` | bool (async) | Verify admin via Nakama storage |
| `kick_player` | `func kick_player(player_id: int) -> void` | void (async) | Kick player from match |
| `ban_player` | `func ban_player(player_id: int) -> void` | void (async) | Ban player |
| `give_currency` | `func give_currency(gold: int, star: int) -> void` | void (async) | Admin give currency |
**Dependencies:** BackendService, NakamaManager.
**Depended by:** admin_panel.tscn (scene), LobbyChat (/clear command).
[Back to top](#top)
## 10. System Managers
[Back to top](#top)
### 10.1 SettingsManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/settings_manager.gd` (13,874 chars)
**Extends:** Node
**Autoload name:** SettingsManager
User settings persistence. Reads/writes config to user://settings.cfg. Manages audio, video, gameplay, and control settings.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `setting_changed` | key: String, value: Variant | A setting was modified |
| `settings_reset` | none | All settings restored to defaults |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `get_setting` | `func get_setting(key: String, default: Variant = null) -> Variant` | Variant | Read setting value |
| `set_setting` | `func set_setting(key: String, value: Variant) -> void` | void | Write and persist setting |
| `reset_settings` | `func reset_settings() -> void` | void | Restore defaults |
| `load_settings` | `func load_settings() -> void` | void | Load from config file |
| `save_settings` | `func save_settings() -> void` | void | Write to config file |
| `get_all_settings` | `func get_all_settings() -> Dictionary` | Dictionary | Full settings snapshot |
**Dependencies:** ConfigFile.
**Depended by:** Audio buses, video settings, gameplay UI, settings_menu.tscn.
[Back to top](#top)
### 10.2 SessionManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/session_manager.gd` (4,742 chars)
**Extends:** Node
**Autoload name:** SessionManager
Nakama session refresh lifecycle. Monitors session expiry and auto-refreshes before expiration.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `session_refreshed` | none | Token refreshed |
| `session_expired` | none | Could not refresh |
| `session_warning` | seconds_remaining: int | About to expire |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `start_monitoring` | `func start_monitoring() -> void` | void | Begin session expiry timer |
| `stop_monitoring` | `func stop_monitoring() -> void` | void | Stop timer |
| `refresh_now` | `func refresh_now() -> void` | void (async) | Force refresh |
**Dependencies:** NakamaManager.
**Depended by:** AuthManager.
[Back to top](#top)
### 10.3 GameUpdateManager
[Back to top](#top)
**File:** `/home/dev/tekton/scripts/managers/game_update_manager.gd` (14,405 chars)
**Extends:** Node
**Autoload name:** GameUpdateManager
Hot-reload update system. Checks for patch.pck on the Gitea patches branch and downloads/loads it at runtime.
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `update_available` | version: String, changelog: String | New patch detected |
| `update_downloading` | progress: float | Download progress |
| `update_ready` | path: String | Patch downloaded and verified |
| `update_failed` | error: String | Download error |
| `up_to_date` | none | No update needed |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `check_for_updates` | `func check_for_updates() -> void` | void (async) | Query Gitea for latest patch |
| `download_update` | `func download_update() -> void` | void (async) | Download patch.pck |
| `apply_update` | `func apply_update() -> void` | void | Load patch from ProjectSettings |
| `get_current_version` | `func get_current_version() -> String` | String | Current client version |
| `get_available_version` | `func get_available_version() -> String` | String | Latest available version |
**Dependencies:** HTTPRequest (scene).
**Depended by:** boot_screen.tscn, main.gd.
[Back to top](#top)
## 11. Core Scene Scripts
[Back to top](#top)
### 11.1 main.gd (Main game scene controller)
[Back to top](#top)
**File:** `/home/dev/tekton/scenes/main.gd` (2956 lines)
**Extends:** Node
**Scene:** main.tscn
The core game scene controller. Handles game initialization, player spawn, grid setup, goal cycle start, leaderboard display, pause menu, unstuck system, match cleanup, and result screen flow.
**Key Properties:**
| Name | Type | Description |
|---|---|---|
| enhanced_gridmap | Node | Reference to EnhancedGridMap child |
| player_scene | PackedScene | Player.tscn loaded |
| stop_n_go_winner_id | int | Winner's peer ID (-1 if none) |
| _unstuck_cooldown_remaining | float | Unstuck button cooldown |
| touch_controls | Node | TouchControls autoload ref |
**Signals:**
- (none declared; uses method-based event routing)
**Public Functions (selected key ones):**
| Function | Signature | Return | Description |
|---|---|---|---|
| `_ready` | auto-called | void | Initializes ENet multiplayer, spawns players, starts goals cycle, sets up HUD |
| `_process` | `func _process(delta: float) -> void` | void | Unstuck cooldown tick |
| `_input` | `func _input(event: InputEvent) -> void` | void | ESC pause, F9 debug floor check |
| `initialize_game` | -- | void | Create EnhancedGridMap, spawn player scene instances |
| `spawn_player` | -- | Node | Instantiate player.tscn, position, set authority |
| `_spawn_local_player` | -- | void | Create local player node |
| `add_bot_players_if_needed` | -- | void | Fill remaining slots with bot players |
| `display_message` | `func display_message(message: String, type: int) -> void` | void (RPC) | Broadcast message to local player's UI |
| `request_leaderboard_sync` | `func request_leaderboard_sync() -> void` | void (RPC) | Client requests leaderboard from server |
| `sync_leaderboard_data` | `func sync_leaderboard_data(player_data: Array) -> void` | void (RPC authority) | Receive + render leaderboard |
| `_update_leaderboard_display` | internal | void | Local leaderboard refresh |
| `_render_leaderboard_entries` | internal | void | Populate leaderboard entries |
| `_get_ordinal` | `func _get_ordinal(n: int) -> String` | String | "1st", "2nd", "3rd", etc. |
| `can_rpc` | `func can_rpc() -> bool` | bool | Check multiplayer peer state |
| `check_multiplayer` | `func check_multiplayer() -> bool` | bool | Safety check for peer access |
| `_toggle_pause_menu` | internal | void | Show/hide pause overlay |
| `_on_resume_pressed` | -- | void | Close pause menu |
| `_on_how_to_play_pressed` | -- | void | Open help panel |
| `_on_settings_pressed` | -- | void | Open settings dynamically |
| `_on_quit_match_pressed` | -- | void | Leave match, return to lobby |
| `_on_unstuck_pressed` | -- | void | Teleport local player to safe position |
| `_find_safe_spawn_position` | internal | Vector2i | Scan grid for safe walkable cell |
| `_on_back_to_menu_pressed` | -- | void | Cleanup and transition to lobby |
| `_cleanup_multiplayer` | -- | void | NakamaManager.cleanup() wrapper |
| `_deferred_init_leaderboard` | internal | void | Delayed leaderboard init (1.5s) |
| `_on_rematch_pressed` | -- | void | Request rematch vote |
| `check_all_floors` | `func check_all_floors() -> void` | void | Debug F9: scan missing floor tiles |
| `update_visual_position` | on player | void | Snap player to grid-aligned world position |
| `grid_to_world` | on player | Vector3 | Convert grid Vector2i to world Vector3 |
**RPCs (network-synced functions):**
| Function | RPC Mode | Description |
|---|---|---|
| `request_leaderboard_sync` | any_peer | Client requests data from server |
| `sync_leaderboard_data` | authority, call_local | Server sends leaderboard to client |
| `display_message` | authority, call_local | Broadcast message to player UI |
| `sync_position` (on player) | any_peer, call_local | Sync grid position |
| `sync_grid_item` (on player) | any_peer, call_local | Sync grid cell item |
| `sync_goals` (on player) | any_peer, call_local | Sync active goals |
| `sync_rotation` (on player) | any_peer, call_local | Sync character rotation |
| `sync_bump` (on player) | any_peer, call_local, unreliable | Visual bump animation |
| `sync_knock_tekton` (on player) | any_peer, call_local, reliable | Knock tekton |
| `sync_grab_tekton` (on player) | any_peer, call_local, reliable | Grab roaming tekton |
| `sync_throw_tekton` (on player) | any_peer, call_local, reliable | Throw tekton |
| `sync_drop_tekton` (on player) | any_peer, call_local, reliable | Drop tekton |
| `set_spawn_position` (on player) | any_peer, call_local, reliable | Random spawn position |
| `complete_race` (on player) | any_peer, call_local, reliable | Player finished race |
| `force_action_state_none` (on player) | any_peer, call_local, reliable | Reset UI action state |
| `request_server_grab` (on player) | any_peer, reliable | Server-authoritative grab |
| `request_server_put` (on player) | any_peer, reliable | Server-authoritative put |
| `notify_spawn_selected` (on player) | any_peer, reliable | Occupancy sync for spawn |
| `trigger_screen_shake` | -- | Camera shake RPC |
**Dependencies:** NakamaManager, LobbyManager, GameStateManager, PlayerMovementManager, PlayerActionManager, GoalsCycleManager, StopNGoManager, GauntletManager, PortalModeManager, PlayerRaceManager, PlayerboardManager, UIManager, SfxManager, MusicManager, NotificationManager, ScreenShake, CameraContextManager, TouchControls.
**Depended by:** (this is the root game scene, depends on everything).
[Back to top](#top)
### 11.2 player.gd
[Back to top](#top)
**File:** `/home/dev/tekton/scenes/player.gd` (2751 lines)
**Extends:** CharacterBody3D (assumed from Node3D methods)
**Scene:** player.tscn
The player character controller. Handles movement, action execution (grab/put/arrange), tekton interaction (carry/snatch/throw/knock), grid positioning, bot AI, visual synchronization, and playerboard management delegation.
**Key Properties:**
| Name | Type | Default | Description |
|---|---|---|---|
| current_position | Vector2i | Vector2i(0, 0) | Grid-aligned position |
| cell_size | Vector3 | (1.0, 1.0, 1.0) | Grid cell dimensions |
| cell_offset | Vector3 | Vector3.ZERO | Visual position offset |
| is_player_moving | bool | false | Movement tween active |
| is_carrying_tekton | bool | false | Holding roaming tekton |
| carried_tekton | Node3D | null | Reference to carried tekton |
| is_charged_strike | bool | false | Charged attack mode |
| is_frozen | bool | false | Stun/freeze state |
| is_stop_frozen | bool | false | Stop n Go freeze |
| is_invisible | bool | false | Ghost mode |
| is_bot | bool | false | Bot AI flag |
| display_name | String | "" | Player display name |
| score | int | 0 | Match score |
| action_points | int | 1 | Actions per turn |
| playerboard | Array | [-1, -1, ...] | Item slot board |
| goals | Array | [] | Active goals |
| enhanced_gridmap | Node | null | Grid reference |
| anim_player | AnimationPlayer | null | Character animations |
| movement_manager | PlayerMovementManager | ref | Movement delegation |
| action_manager | PlayerActionManager | ref | Action delegation |
| playerboard_manager | PlayerboardManager | ref | Board delegation |
| race_manager | PlayerRaceManager | ref | Race delegate |
| powerup_manager | PowerupManager | ref | Boost/charge delegate |
**Signals:**
| Signal | Params | Description |
|---|---|---|
| `position_changed` | none | Player grid position changed |
**Public Functions (selected key ones):**
| Function | Signature | Return | Description |
|---|---|---|---|
| `_ready` | auto-called | void | Init references, connect signals, set initial position |
| `_physics_process` | `func _physics_process(delta: float) -> void` | void | Movement smoothing, carry timer, unstuck timer |
| `_input` | `func _input(event: InputEvent) -> void` | void | Click-to-move on gridmap, slot clicks |
| `grid_to_world` | `func grid_to_world(pos: Vector2i) -> Vector3` | Vector3 | Convert grid to world coordinates |
| `move_to_grid_position` | `func move_to_grid_position(target: Vector2i) -> void` | void | Initiate grid movement |
| `grab_item` | `func grab_item(grid_pos: Vector2i = current_position) -> bool` | bool | Delegates to playerboard_manager.grab_item |
| `auto_put_item` | `func auto_put_item() -> bool` | bool | Delegates auto-put |
| `handle_playerboard_slot_selected` | `func handle_playerboard_slot_selected(slot_index: int) -> void` | void | Delegates to playerboard_manager |
| `handle_put_slot_selected` | `func handle_put_slot_selected(slot_index: int) -> void` | void | Delegates put slot |
| `arrange_playerboard_item` | `func arrange_playerboard_item(slot_index: int) -> void` | void | Delegates arrange |
| `_on_slot_clicked` | `func _on_slot_clicked(event: InputEvent, slot_index: int) -> void` | void | Delegates to playerboard_manager |
| `has_item_at_current_position` | `func has_item_at_current_position() -> bool` | bool | Check grid cell occupancy |
| `has_items_in_playerboard` | `func has_items_in_playerboard() -> bool` | bool | Any items in board |
| `playerboard_is_full` | `func playerboard_is_full() -> bool` | bool | All slots filled |
| `highlight_movement_range` | `func highlight_movement_range() -> void` | void | Delegates to movement_manager |
| `highlight_adjacent_cells` | `func highlight_adjacent_cells() -> void` | void | Delegates to movement_manager |
| `highlight_cells_if_authorized` | `func highlight_cells_if_authorized(cells: Array, item_id: int) -> void` | void | Delegates to action_manager |
| `clear_highlights` | `func clear_highlights() -> void` | void | Clear grid highlights |
| `rotate_towards_target` | `func rotate_towards_target(target_pos: Vector2i) -> void` | void | Delegates to movement_manager |
| `select_playerboard_slot` | `func select_playerboard_slot(slot_index: int) -> void` | void | Delegates to playerboard_manager |
| `deselect_playerboard_slot` | `func deselect_playerboard_slot() -> void` | void | Clear selection |
| `target_playerboard_slot` | `func target_playerboard_slot(slot_index: int) -> void` | void | Target slot for move |
| `untarget_playerboard_slot` | `func untarget_playerboard_slot() -> void` | void | Clear target |
| `can_move_to_target_playerboard_slot` | `func can_move_to_target_playerboard_slot() -> bool` | bool | Target slot validity |
| `update_visual_position` | `func update_visual_position() -> void` | void | Snap to grid |
| `grab_tekton` | `func grab_tekton() -> void` | void | Tekton interaction: snatch or grab |
| `snatch_tekton` | `func snatch_tekton(target_carrier: Node3D) -> void` | void | Steal tekton from carrier |
| `throw_tekton` | `func throw_tekton() -> void` | void | Throw tekton in facing direction |
| `drop_tekton` | `func drop_tekton() -> void` | void | Drop tekton at current position |
| `enter_charged_strike` | `func enter_charged_strike() -> void` | void | Activate charged attack mode |
| `knock_tekton` | `func knock_tekton() -> void` | void | Special attack on nearby tekton |
| `update_active_player_indicator` | `func update_active_player_indicator() -> void` | void | Refresh visual state |
| `is_finish_position` | `func is_finish_position(pos: Vector2i) -> bool` | bool | Check if pos is a finish line |
| `_after_action_completed` | internal | void | Post-action: cycle goals, check win |
| `consume_action_points` | `func consume_action_points(points: int) -> void` | void | Deduct AP |
| `display_message` | on player | void | Show notification to this player |
| `apply_stagger` | `func apply_stagger(duration: float) -> void` | void | Stun for duration |
**RPCs (network-synced functions on player.gd):**
| Function | RPC Mode | Description |
|---|---|---|
| `sync_position` | any_peer, call_local | Sync current grid position |
| `sync_rotation` | any_peer, call_local | Sync Y rotation |
| `sync_grid_item` | any_peer, call_local | Sync grid cell item change |
| `sync_goals` | any_peer, call_local | Sync active goal set |
| `sync_second_lap_goals` | any_peer, call_local | Sync lap 2 goals |
| `sync_grab_tekton` | any_peer, call_local, reliable | Grab tekton network sync |
| `sync_snatch_tekton` | any_peer, call_local, reliable | Tekton theft sync |
| `sync_throw_tekton` | any_peer, call_local, reliable | Throw tekton sync |
| `sync_drop_tekton` | any_peer, call_local, reliable | Drop tekton sync |
| `sync_bump` | any_peer, call_local, unreliable | Visual bump animation |
| `sync_knock_tekton` | any_peer, call_local, reliable | Knock attack sync |
| `set_spawn_position` | any_peer, call_local, reliable | Random spawn position |
| `complete_race` | any_peer, call_local, reliable | Race completion |
| `force_action_state_none` | any_peer, call_local, reliable | Reset UI action state |
| `request_server_grab` | any_peer, reliable | Server-auth grab request |
| `request_server_put` | any_peer, reliable | Server-auth put request |
| `notify_spawn_selected` | any_peer, reliable | Spawn occupancy sync |
| `trigger_screen_shake` | (authority) | Screen shake RPC |
| `bot_grab_item` | any_peer, call_local | Bot grab sync |
| `bot_put_item` | any_peer, call_local | Bot put sync |
| `bot_arrange_item` | any_peer, call_local | Bot arrange sync |
**Dependencies:** PlayerMovementManager, PlayerInputManager, PlayerActionManager, PlayerboardManager, PowerupManager, PlayerRaceManager, GoalsCycleManager, SfxManager, NotificationManager, EnhancedGridMap (scene node), LobbyManager.
**Depended by:** main.gd (spawned per player).
[Back to top](#top)
### 11.3 lobby.gd
[Back to top](#top)
**File:** `/home/dev/tekton/scenes/lobby.gd` (583 lines)
**Extends:** Control
**Scene:** lobby.tscn
The lobby/home screen controller. Manages main menu, room creation/joining, player slots, server selection, character selection, settings, mail, chat, social panel, and 3D character preview.
**Key Properties:**
| Name | Type | Description |
|---|---|---|
| chat | LobbyChat | Chat helper instance |
| main_menu | LobbyMainMenu | Main menu helper |
| room_list_helper | LobbyRoomList | Room list helper |
| room_helper | LobbyRoom | Room/lobby helper |
| character_textures | Dictionary | {char_name: Texture2D} |
| profile_panel_instance | Control | Dynamic profile panel |
| shop_panel_instance | Control | Dynamic shop panel |
| daily_reward_panel_instance | Control | Daily reward panel |
| leaderboard_panel_instance | Control | Leaderboard panel |
| _mailbox_panel_instance | Control | Mail panel |
| social_panel_instance | Control | Social panel |
| _local_player_rank | int | Cached rank |
| _bot_names | Dictionary | Slot index -> bot name |
| _room_mode_filter | String | Room list filter |
| _is_hosting | bool | Re-entry guard |
**UI Node References (onready):**
| Variable | Node Path | Type |
|---|---|---|
| main_menu_panel | $MainMenuPanel | Control |
| main_title | %Title | Label |
| username_label | %Username | Label |
| create_room_btn | %CreateRoomBtn | Button |
| browse_rooms_btn | %BrowseRoomsBtn | Button |
| tutorial_btn | %TutorialBtn | Button |
| main_menu_profile_btn | %MainProfileBtn | Button |
| avatar_display | %AvatarDisplay | TextureRect |
| lobby_settings_btn | %SettingsBtn | Button |
| quit_btn | %QuitBtn | Button |
| character_root | %CharacterRoot | Node3D |
| anim_player | %AnimationPlayer | AnimationPlayer |
| gold_label | %GoldLabel | Label |
| star_label | %StarLabel | Label |
| server_option | %ServerOption | OptionButton |
| server_ip_input | %ServerIPInput | LineEdit |
| leaderboard_btn | %LeaderboardBtn | Button |
| shop_btn | %CartBtn | Button |
| top_right_profile_btn | %ProfileBtn | Button |
| mailbox_btn | %MailboxBtn | Button |
| mail_badge | %MailBadge | Label |
| banner1_btn | %Banner1 | Button |
| ticket_btn | %TicketBtn | Button |
| room_list_panel | %RoomListPanel | Control |
| room_list | %RoomList | ItemList |
| match_id_input | %MatchIdInput | LineEdit |
| refresh_btn | %RefreshBtn | Button |
| join_btn | %JoinBtn | Button |
| back_btn | %RoomListCloseBtn | Button |
| lobby_panel | $LobbyPanel | Control |
| host_banner | $LobbyPanel/HostBanner | Panel |
| match_id_display | $LobbyPanel/TopBar/... | Label |
| copy_id_btn | $LobbyPanel/TopBar/... | Button |
| duration_option | $LobbyPanel/TopBar/... | OptionButton |
| random_spawn_check | `" " ` | CheckButton |
| enable_timer_check | `" " ` | CheckButton |
| scarcity_option | `" " ` | OptionButton |
| game_mode_option | `" " ` | OptionButton |
| players_container | $LobbyPanel/PlayersContainer | Control |
| area_selector | $LobbyPanel/AreaSelector | Control |
| leave_btn | $LobbyPanel/BottomBar/LeaveBtn | Button |
| ready_btn | $LobbyPanel/BottomBar/ReadyBtn | Button |
| start_game_btn | $LobbyPanel/BottomBar/StartGameBtn | Button |
| connection_status | $StatusBar/ConnectionStatus | Label |
| chat_display | %RichTextLabel | RichTextLabel |
| chat_input | %ChatInput | LineEdit |
| chat_send_btn | %SendBtn | Button |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `_ready` | auto-called | void | Initialize all helpers, load textures, setup UI, connect signals |
| `_setup_3d_preview` | `func _setup_3d_preview() -> void` | void | Swap character model in SubViewport |
| `_load_character_textures` | `func _load_character_textures() -> void` | void | Load preview textures |
| `_on_server_option_selected` | `func _on_server_option_selected(index: int) -> void` | void | Handle server type dropdown |
| `_on_server_ip_submitted` | `func _on_server_ip_submitted(new_text: String) -> void` | void | Handle IP input |
| `_setup_game_modes` | `func _setup_game_modes() -> void` | void | Populate game mode dropdown |
| `_setup_player_slots` | `func _setup_player_slots() -> void` | void | Collect player slot nodes |
| `_connect_slot_signals` | `func _connect_slot_signals(slot: Control, i: int)` | void | Wire character nav buttons |
| `_show_panel` | `func _show_panel(panel_name: String) -> void` | void | Toggle main_menu/room_list/lobby panels |
| `_update_settings_visibility` | `func _update_settings_visibility() -> void` | void | Show/hide settings by mode and host status |
| `_create_custom_settings_ui` | `func _create_custom_settings_ui() -> void` | void | Build SNG/Tekton Doors settings dynamically |
| `_sync_room_profile_card` | `func _sync_room_profile_card() -> void` | void | Refresh username, score, rank, avatar, currency |
| `_apply_loadout_character` | `func _apply_loadout_character() -> void` | void | Apply saved character to LobbyManager |
| `admin_wipe_chat` | `func admin_wipe_chat() -> void` | void (async) | Admin: clear global chat |
| `admin_purge_chat` | `func admin_purge_chat(max_age_days: int) -> int` | int (async) | Admin: purge old messages |
**Dependencies:** AuthManager, NakamaManager, LobbyManager, UserProfileManager, SkinManager, MusicManager, FriendManager, MailManager, BackendService.
**Depended by:** (root lobby scene; no dependents).
[Back to top](#top)
### 11.4 animation.gd
[Back to top](#top)
**File:** `/home/dev/tekton/scenes/animation.gd` (41 lines)
**Extends:** Control
**Scene:** (embedded in main.tscn for Stop n Go UI)
Stop n Go phase animation player. Controls ready-go countdown, stop phase overlay, safe zone, and go animation sequences.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `play_ready_go` | `func play_ready_go() -> void` | void | Play ready-set-go sequence |
| `play_stop_phase` | `func play_stop_phase() -> void` | void | Play STOP overlay |
| `play_safe_zone_appear` | `func play_safe_zone_appear() -> void` | void | Show safe zone indicator |
| `stop_phase_anim_play` | `func stop_phase_anim_play() -> void` | void | Play stop phase spritesheet |
| `stop_phase_anim_stop` | `func stop_phase_anim_stop() -> void` | void | Stop phase animation |
| `play_countdown_30s` | `func play_countdown_30s() -> void` | void | 30-second countdown |
| `play_countdown_15s` | `func play_countdown_15s() -> void` | void | 15-second countdown |
| `play_go_animation` | `func play_go_animation() -> void` | void | GO animation |
| `play_go_finish_animation` | `func play_go_finish_animation() -> void` | void | Finish line animation |
**Dependencies:** AnimatedSprite2D, AnimationPlayer (scene nodes).
**Depended by:** StopNGoManager, main.gd.
[Back to top](#top)
## 12. UI Helper Classes (RefCounted)
[Back to top](#top)
All UI helper classes are RefCounted objects instantiated by lobby.gd in _ready(). They do NOT extend Node -- they are lightweight event wiring and state management objects.
### 12.1 LobbyMainMenu
[Back to top](#top)
```gdscript
class_name LobbyMainMenu extends RefCounted
```
**File:** `/home/dev/tekton/scenes/ui/lobby_main_menu.gd` (338 lines)
Event wiring for main menu buttons. Connects all lobby button signals to handler methods.
**Constructor:** `func _init(p_lobby: Control)` -- Stores lobby ref, connects 15+ button signals.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `on_tutorial_pressed` | `func on_tutorial_pressed() -> void` | void | Set name, apply loadout, call LobbyManager.start_tutorial |
| `on_create_room_pressed` | `func on_create_room_pressed() -> void` | void | Show room list panel, create tab |
| `host_room` | `func host_room(game_mode: String) -> void` | void | Guarded double-click, set name/mode, create Nakama or LAN room |
| `on_browse_rooms_pressed` | `func on_browse_rooms_pressed() -> void` | void | Show room list, browse tab, refresh |
| `on_profile_btn_pressed` | `func on_profile_btn_pressed() -> void` | void | Instantiate and show profile_panel.tscn |
| `on_mailbox_pressed` | `func on_mailbox_pressed() -> void` | void | Instantiate and show mailbox_panel.tscn |
| `on_settings_pressed` | `func on_settings_pressed() -> void` | void | Instantiate and show settings_menu.tscn |
| `restore_after_settings` | `func restore_after_settings() -> void` | void | Restore lobby/main_menu panel visibility |
| `on_shop_pressed` | `func on_shop_pressed() -> void` | void | Instantiate and show shop_panel.tscn |
| `on_banner1_pressed` | `func on_banner1_pressed() -> void` | void | Instantiate and show gacha_panel.tscn |
| `on_leaderboard_pressed` | `func on_leaderboard_pressed() -> void` | void | Show leaderboard_panel.tscn |
| `on_ticket_pressed` | `func on_ticket_pressed() -> void` | void | Show daily_reward_panel.tscn |
| `on_social_pressed` | `func on_social_pressed() -> void` | void | Show social_panel.tscn, hide main menu UI |
| `on_logout_pressed` | `func on_logout_pressed() -> void` | void | AuthManager.logout() -> login screen |
| `on_quit_pressed` | `func on_quit_pressed() -> void` | void | get_tree().quit() |
| `go_to_login` | `func go_to_login() -> void` | void | Change scene to login_screen.tscn |
**Dependencies:** AuthManager, LobbyManager, UserProfileManager, NakamaManager, BackendService.
**Depended by:** lobby.gd.
[Back to top](#top)
### 12.2 LobbyRoom
[Back to top](#top)
```gdscript
class_name LobbyRoom extends RefCounted
```
**File:** `/home/dev/tekton/scenes/ui/lobby_room.gd` (432 lines)
Room/lobby panel event wiring. Handles ready/start/leave buttons, player slot rendering, character navigation, game mode/duration/scarcity settings, friend invites, and lobby invitation popup.
**Constructor:** `func _init(p_lobby: Control)` -- Stores lobby ref, connects 20+ signals.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `_on_ready_toggled` | `func _on_ready_toggled(is_ready: bool) -> void` | void | Toggle ready state |
| `_on_start_game_pressed` | `func _on_start_game_pressed() -> void` | void | Host starts game |
| `_on_leave_pressed` | `func _on_leave_pressed() -> void` | void | Leave room, release bot names |
| `_on_copy_id_pressed` | `func _on_copy_id_pressed() -> void` | void | Copy match ID to clipboard |
| `_on_duration_selected` | `func _on_duration_selected(index: int) -> void` | void | Host sets match duration |
| `_on_random_spawn_toggled` | `func _on_random_spawn_toggled(toggled_on: bool) -> void` | void | Toggle random spawn |
| `_on_enable_timer_toggled` | `func _on_enable_timer_toggled(toggled_on: bool) -> void` | void | Toggle cycle timer |
| `_on_scarcity_selected` | `func _on_scarcity_selected(index: int) -> void` | void | Host sets scarcity |
| `_on_scarcity_mode_changed` | `func _on_scarcity_mode_changed(mode: String) -> void` | void | UI update for scarcity |
| `_on_game_mode_selected` | `func _on_game_mode_selected(index: int) -> void` | void | Host sets game mode |
| `_on_game_mode_changed` | `func _on_game_mode_changed(mode: String) -> void` | void | UI update for game mode |
| `_on_sng_update` | `func _on_sng_update(_val: int = 0) -> void` | void | Sync SNG setting UI |
| `_on_doors_update` | `func _on_doors_update(_val: int = 0) -> void` | void | Sync Doors setting UI |
| `_on_room_joined` | `func _on_room_joined(room_data: Dictionary) -> void` | void | Switch to lobby panel, populate settings |
| `_on_room_left` | `func _on_room_left() -> void` | void | Return to main menu |
| `_on_host_disconnected` | `func _on_host_disconnected() -> void` | void | Show disconnect message |
| `_on_player_joined` | `func _on_player_joined(player_data: Dictionary) -> void` | void | Update slots + status |
| `_on_player_left` | `func _on_player_left(_player_id: int) -> void` | void | Update slots |
| `_on_ready_state_changed` | `func _on_ready_state_changed(_player_id: int, _is_ready: bool) -> void` | void | Update slot visuals |
| `_on_all_players_ready` | `func _on_all_players_ready() -> void` | void | Enable start button |
| `_on_game_starting` | `func _on_game_starting() -> void` | void | Transition to main.tscn |
| `_update_player_slots` | `func _update_player_slots() -> void` | void | Render all 8 player slots (players + bot slots) |
| `_update_status` | `func _update_status() -> void` | void | Show ready count |
| `_on_add_friend_pressed` | `func _on_add_friend_pressed(nakama_id: String) -> void` | void (async) | Add friend by Nakama ID |
| `on_invite_friends_pressed` | `func on_invite_friends_pressed() -> void` | void | Open invite dialog |
| `_on_lobby_invite_received` | `func _on_lobby_invite_received(from_user_id: String, from_name: String, match_id: String) -> void` | void | Show invite popup |
| `_on_invite_accepted` | `func _on_invite_accepted() -> void` | void | Join invited match |
**Dependencies:** LobbyManager, FriendManager, NakamaManager, NameGenerator, UserProfileManager.
**Depended by:** lobby.gd.
[Back to top](#top)
### 12.3 LobbyRoomList
[Back to top](#top)
```gdscript
class_name LobbyRoomList extends RefCounted
```
**File:** `/home/dev/tekton/scenes/ui/lobby_room_list.gd` (155 lines)
Room list panel event wiring. Handles room list refresh, selection, join, and back navigation.
**Constructor:** `func _init(p_lobby: Control)` -- Stores lobby ref, connects signals.
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `_on_refresh_pressed` | `func _on_refresh_pressed() -> void` | void | Clear + refresh room list |
| `_on_room_selected` | `func _on_room_selected(index: int) -> void` | void | Copy room match_id/IP to input |
| `_on_room_activated` | `func _on_room_activated(index: int) -> void` | void | Select + auto-join |
| `_on_join_pressed` | `func _on_join_pressed() -> void` | void | Validate input, set name, join room (LAN or Nakama) |
| `_on_back_pressed` | `func _on_back_pressed() -> void` | void | Return to main menu |
| `on_room_list_updated` | `func on_room_list_updated(rooms: Array) -> void` | void | Render room rows, apply mode filter |
**Dependencies:** LobbyManager, AuthManager, UserProfileManager.
**Depended by:** lobby.gd.
[Back to top](#top)
### 12.4 LobbyChat
[Back to top](#top)
```gdscript
class_name LobbyChat extends RefCounted
```
**File:** `/home/dev/tekton/scenes/ui/lobby_chat.gd` (373 lines)
Global and direct message chat system. Handles Nakama socket channel chat, DM tabs, friend suggestions, and admin chat commands.
**Constants:** `GLOBAL_CHAT_ROOM = "social_global"`
**Properties:**
| Name | Type | Description |
|---|---|---|
| _chat_channel | NakamaChannel | Current chat channel |
| _chat_messages | Array | [{sender, content, ts, date}] |
| _active_chat_context | String | "global" or user_id |
| _dm_tabs | Dictionary | user_id -> HBoxContainer (tab UI) |
| _dm_messages | Dictionary | user_id -> Array of messages |
| _chat_config | Dictionary | {prefix, max_messages, max_age_days} |
**Public Functions:**
| Function | Signature | Return | Description |
|---|---|---|---|
| `join_global_chat` | `func join_global_chat() -> void` | void (async) | Join social_global channel, fetch history, inject prefix |
| `switch_chat_tab` | `func switch_chat_tab(context_id: String) -> void` | void | Switch between global and DM tabs |
| `_on_chat_send_pressed` | `func _on_chat_send_pressed() -> void` | void (async) | Send message: @username for DM, /clear for admin |
| `on_lobby_dm_received` | `func on_lobby_dm_received(from_user_id: String, from_name: String, message: String) -> void` | void | Incoming DM handler |
| `leave_global_chat` | `func leave_global_chat() -> void` | void (async) | Disconnect and leave channel |
**Internal Functions:** `_add_chat_message`, `_send_dm_message`, `_open_dm_tab`, `_create_dm_tab`, `_close_dm_tab`, `_inject_local_message`, `_trim_old_messages`, `_refresh_chat_display`, `_format_nakama_time`, `_get_local_time`, `_on_chat_input_changed`, `_on_friend_suggest_activated`, `_setup_friend_suggest_ui`.
**Dependencies:** NakamaManager, BackendService, FriendManager, AdminManager, UserProfileManager.
**Depended by:** lobby.gd.
[Back to top](#top)
## 13. Dependency Graph
[Back to top](#top)
### 13.1 Manager Autoload Dependencies
[Back to top](#top)
ASCII diagram showing which autoloads reference others:
```
NakamaManager (no deps on other managers -- pure Nakama SDK)
|
+-- BackendService
| +-- SteamworksManager (child node, not autoload)
|
+-- AuthManager
| +-- NakamaManager
| +-- BackendService
|
+-- SessionManager
| +-- NakamaManager
|
+-- LobbyManager
| +-- NakamaManager
| +-- GameStateManager
|
+-- GameStateManager (no deps)
|
+-- PlayerManager (no deps -- data only)
|
+-- EventBus (no deps -- pure observer)
|
+-- UserProfileManager
| +-- NakamaManager
| +-- BackendService
| +-- EventBus
|
+-- FriendManager
| +-- BackendService
| +-- NakamaManager
|
+-- MailManager
| +-- BackendService
|
+-- GachaManager
| +-- BackendService
| +-- UserProfileManager
| +-- EventBus
|
+-- DailyRewardManager
| +-- BackendService
|
+-- AdminManager
| +-- BackendService
| +-- NakamaManager
|
+-- SkinManager
| +-- UserProfileManager
|
+-- ShopManager
| +-- BackendService (thin)
|
+-- PlayerInputManager
| +-- TouchControls
|
+-- PlayerMovementManager
| +-- ObstacleManager
| +-- SpecialTilesManager
| +-- EnhancedGridMap (scene)
|
+-- PlayerActionManager
| +-- PlayerboardManager
| +-- PlayerInputManager
| +-- GoalsCycleManager
|
+-- PlayerboardManager
| +-- GoalsCycleManager
| +-- GoalManager
|
+-- PlayerRaceManager
| +-- GoalsCycleManager
|
+-- GoalsCycleManager
| +-- GoalManager
| +-- TurnManager
| +-- Timer (scene)
|
+-- StopNGoManager
| +-- TurnManager
| +-- GoalManager
| +-- GoalsCycleManager
| +-- animation.gd (scene)
|
+-- GauntletManager
| +-- TurnManager
| +-- EnhancedGridMap
|
+-- PortalModeManager
| +-- SpecialTilesManager
| +-- EnhancedGridMap
|
+-- SpecialTilesManager
| +-- ObstacleManager
| +-- EnhancedGridMap
|
+-- ObstacleManager
| +-- EnhancedGridMap
|
+-- StaticTektonManager
| +-- EnhancedGridMap
| +-- ObstacleManager
|
+-- PowerupManager (no deps)
|
+-- UIManager (no deps -- dynamic UI)
|
+-- SettingsManager (no deps -- ConfigFile)
|
+-- GameUpdateManager (HTTPRequest -- no manager deps)
|
+-- TutorialManager
| +-- TutorialOverlay
|
+-- TutorialOverlay
| +-- TutorialManager
| +-- UIManager
|
+-- MusicManager (no deps)
+-- SfxManager (no deps)
+-- ScreenShake (no deps)
+-- NotificationManager (no deps)
+-- CameraContextManager (no deps)
+-- TouchControls (no deps)
+-- JoinManager (no deps -- stub)
```
[Back to top](#top)
### 13.2 Cross-Manager Signal Wiring
[Back to top](#top)
Key signal connections between managers and scene scripts:
```
NakamaManager.match_joined -> LobbyManager._on_match_joined
NakamaManager.match_join_error -> lobby.gd (clears _is_hosting)
NakamaManager.connection_failed -> lobby.gd (clears _is_hosting)
LobbyManager.room_joined -> LobbyRoom._on_room_joined
LobbyManager.room_left -> LobbyRoom._on_room_left
LobbyManager.host_disconnected -> LobbyRoom._on_host_disconnected
LobbyManager.player_joined -> LobbyRoom._on_player_joined
LobbyManager.player_left -> LobbyRoom._on_player_left
LobbyManager.ready_state_changed -> LobbyRoom._on_ready_state_changed
LobbyManager.all_players_ready -> LobbyRoom._on_all_players_ready
LobbyManager.game_starting -> LobbyRoom._on_game_starting
LobbyManager.game_mode_changed -> LobbyRoom._on_game_mode_changed
LobbyManager.room_list_updated -> LobbyRoomList.on_room_list_updated
LobbyManager.character_changed -> LobbyRoom._on_character_changed
LobbyManager.rematch_votes_updated -> main.gd (update rematch button)
FriendManager.dm_message_received -> LobbyChat.on_lobby_dm_received
FriendManager.lobby_invite_received -> LobbyRoom._on_lobby_invite_received
MailManager.unread_count_changed -> lobby.gd (update badge)
UserProfileManager.profile_loaded -> lobby.gd (_sync_room_profile_card)
UserProfileManager.profile_updated -> lobby.gd (_sync_room_profile_card)
AuthManager.logged_out -> LobbyMainMenu.go_to_login
```
[Back to top](#top)
## 14. Scene Node Trees
[Back to top](#top)
### 14.1 main.tscn
[Back to top](#top)
```
Main (Node) -- attached: main.gd
+-- EnhancedGridMap (GridMap / custom EnhancedGridMap node)
+-- PlayerSpawnPoints (Node3D)
+-- HUD (CanvasLayer)
| +-- LeaderboardPanel (Panel)
| | +-- MarginContainer/VBox
| | +-- Entry1-8 (HBoxContainer with RankLabel/NameLabel/ScoreLabel)
| +-- NotificationOverlay (Control)
| +-- ActionButtons (Control)
+-- PauseMenu (Panel)
| +-- Panel/VBox/ResumeBtn, HowToPlayBtn, SettingsBtn, UnstuckBtn, QuitMatchBtn
+-- HowToPlayPanel (Panel)
+-- StopNGoUI (Control) -- attached: animation.gd
| +-- StopPhase/AnimatedSprite2D
| +-- AnimationPlayer
| +-- CountDown/CountDownAnimation (AnimatedSprite2D)
| +-- GoFinish/GoAnimation2D (AnimatedSprite2D)
+-- Camera3D
+-- WorldEnvironment
+-- Player instances (added dynamically by main.gd)
+-- Player (CharacterBody3D) -- attached: player.gd
+-- MeshInstance3D (visual)
+-- CollisionShape3D
+-- PlayerboardUI (Control overlay)
+-- AnimationPlayer
```
[Back to top](#top)
### 14.2 player.tscn
[Back to top](#top)
```
Player (CharacterBody3D) -- attached: player.gd
+-- MeshInstance3D (character model)
+-- CollisionShape3D
+-- PlayerboardUI (Control)
| +-- Slot0-9 (Panel/TextureRect)
+-- AnimationPlayer
+-- (tektons picked up become children at runtime)
```
[Back to top](#top)
### 14.3 lobby.tscn
[Back to top](#top)
```
Lobby (Control) -- attached: lobby.gd
+-- StatusBar (HBoxContainer)
| +-- ConnectionStatus (Label)
+-- MainMenuPanel (Panel)
| +-- Title/Username/Subtitle/Buttons (CreateRoom, BrowseRooms, Tutorial, etc.)
| +-- CharacterRoot (SubViewportContainer > SubViewport > Node3D)
| | +-- Oldpop, Masbro, Gatot, Bob (character meshes, hidden by default)
| +-- %AnimationPlayer
| +-- CurrencyLabels (GoldLabel, StarLabel)
| +-- ServerOption / ServerIPInput
| +-- LeaderboardBtn, CartBtn, ProfileBtn, MailboxBtn, Banner1Btn, TicketBtn
+-- RoomListPanel (Control)
| +-- RoomListTabs (TabContainer)
| +-- RoomTab, PlayTab
| +-- MatchIdInput, RefreshBtn, JoinBtn, BackBtn
| +-- RoomList (ItemList)
| +-- ItemTemplate (hidden)
| +-- ProfileCard (PlayerUsername, PlayerScore, Rank, Avatar)
+-- LobbyPanel (Panel)
| +-- RoomNameHeader
| +-- HostBanner
| +-- TopBar/SettingsSection (Duration, Spawn, Timer, Scarcity, GameMode options)
| +-- AreaSelector
| +-- PlayersContainer (slots 1-4)
| +-- PlayersContainer2 (slots 5-8)
| +-- BottomBar (LeaveBtn, ReadyBtn, StartGameBtn, InviteBtn)
| +-- StatusLabel
+-- ChatPanel (Panel)
| +-- RichTextLabel
| +-- ChatInput (LineEdit)
| +-- SendBtn
| +-- ChatTabsContainer (GlobalChatTabBtn + DM tabs)
| +-- FriendSuggestPanel (hidden)
+-- (dynamic instances: MailboxPanel, ShopPanel, GachaPanel, ProfilePanel, etc.)
```
[Back to top](#top)
+392
View File
@@ -0,0 +1,392 @@
# Tekton Armageddon - Server Architecture
<a id="top"></a>
High-level architecture of the Nakama Lua backend. For detailed RPC reference (params, returns, errors), see [Nakama-Server-API](../Nakama-Server-API).
[Back to top](#top)
---
## System Overview
```mermaid
flowchart LR
Client[Godot Client<br/>player.tscn] -->|RPC calls| Nakama[Nakama Server<br/>port 7350/7351]
Nakama -->|wallet_update| Wallet[Wallet Engine]
Nakama -->|storage_read/write| Storage[Nakama Storage DB<br/>PostgreSQL]
Nakama -->|leaderboard_*| LB[Native Leaderboard]
Nakama -->|match_*| Match[Match Handler]
Nakama -->|channel_*| Chat[Lobby Chat]
Client -->|nk.match_join| Match
Client -->|Direct RPC| Lua[Lua RPC Handlers<br/>server/nakama/lua/]
```
The Lua backend runs **inside** the Nakama process. Lua modules hook into Nakama's lifecycle (after-authentication, RPC dispatch, match signals). All game transactions (wallet, inventory, gacha, shop) are **server-authoritative** — the Godot client sends RPC requests and the Lua code validates and executes.
[Back to top](#top)
---
## Module Architecture
```mermaid
flowchart TD
main["main.lua<br/>Entry point: loads all modules"] --> utils["utils.lua<br/>Auth guards, channel resolver"]
main --> core["core.lua<br/>Authentication hooks<br/>Wallet init, ban check"]
main --> economy["economy.lua<br/>Shop catalog, currency IAP<br/>Item purchases"]
main --> gacha["gacha.lua<br/>Gacha pulls, pity system<br/>RNG on server"]
main --> leaderboard["leaderboard.lua<br/>Score submission, sync<br/>Global rankings"]
main --> inbox["inbox.lua<br/>Global/personal mail<br/>Reward claiming"]
main --> daily["daily_rewards.lua<br/>Daily login rewards<br/>Monthly schedule"]
main --> user["user.lua<br/>Profile updates, identity<br/>Friend sync"]
main --> admin["admin.lua<br/>Kick/ban, stats, chat mgmt<br/>Role management"]
core --> utils
economy --> utils
gacha --> utils
leaderboard --> utils
inbox --> utils
daily --> utils
user --> utils
admin --> utils
economy -->|Wallet deduction| Wallet[(Wallet Engine)]
gacha -->|Wallet deduction| Wallet
inbox -->|Claim rewards| Wallet
daily -->|Daily claim| Wallet
user -->|Profile load| Wallet
```
### Dependency order (loading sequence)
1. `utils.lua` — no deps (loaded first)
2. `economy.lua` — depends on utils
3. `core.lua` — depends on utils
4. `admin.lua` — depends on utils
5. `daily_rewards.lua` — depends on utils
6. `user.lua` — depends on utils
7. `leaderboard.lua` — depends on utils
8. `inbox.lua` — depends on utils
9. `gacha.lua` — no deps beyond utils
[Back to top](#top)
---
## Authentication Flow
```mermaid
sequenceDiagram
participant C as Godot Client
participant N as Nakama Core
participant A as after_hooks (core.lua)
participant DB as Nakama Storage
C->>N: AuthenticateDevice/Custom/Email
N->>A: after_authenticate(context)
A->>DB: storage_read(profiles/profile)
alt First login (no profile)
A->>DB: storage_write(initial profile)
A->>N: wallet_update(gold=100, star=500)
end
A->>DB: storage_read(profiles/profile)
alt metadata.banned == true
A-->>C: error("Account banned")
end
A->>C: session token returned
```
### Boot sequence per player
1. Godot client calls `AuthenticateDevice` (or Email/Custom/Steam).
2. Nakama core calls `after_authenticate()` hook in `core.lua`.
3. Hook reads `profiles/profile` from storage.
4. **If first login:** initializes default profile and grants starting wallet (`gold: 100, star: 500`).
5. **Ban check:** If `metadata.banned == true`, raises error (player rejected).
6. Client receives session token and proceeds to lobby.
[Back to top](#top)
---
## Wallet & Economy Flow
```mermaid
flowchart LR
subgraph Client Side
ShopUI[Shop Panel] -->|buy_currency RPC| Server
ShopUI -->|purchase_item RPC| Server
GachaUI[Gacha Panel] -->|perform_gacha_pull RPC| Server
MailUI[Inbox] -->|claim_mail_reward RPC| Server
DailyUI[Daily Rewards] -->|claim_daily_reward RPC| Server
end
subgraph Server Side
Server[Lua RPC Handler]
Server -->|nk.wallet_update| Wallet[(Wallet)]
Server -->|nk.storage_write| Inv[(Inventory Storage)]
Server -->|nk.storage_write| Rec[(Receipts Storage)]
Server -->|nk.storage_write| Frag[(Fragments Storage)]
end
subgraph Client Refresh
Wallet -->|Wallet updated| Client
Client -->|get_account RPC| Wallet
Client -->|emit profile_updated| UI[All UI Panels<br/>update labels]
end
```
### Currency types
| Currency | Purpose | Earned by |
|---|---|---|
| `gold` | Shop purchases, star conversion | IAP (real money), admin topup |
| `star` | Gacha pulls | Gold conversion, daily rewards, mail rewards |
### All wallet changesets
| Operation | Changeset |
|---|---|
| First login grant | `{gold: 100, star: 500}` |
| Buy gold IAP | `{gold: +N}` (N=100/550/1150/2400/6250/13000) |
| Buy stars (gold convert) | `{gold: -N, star: +M}` |
| Buy shop item | `{gold: -price}` or `{star: -price}` |
| Gacha pull | `{star: -cost}` or `{gold: -cost}` |
| Claim mail reward | `{gold: +N}` and/or `{star: +N}` |
| Claim daily reward | `{star: +N}` or `{gold: +N}` |
| Admin topup | `{gold: 999999}` |
[Back to top](#top)
---
## Gacha Flow
```mermaid
flowchart TD
A[Client: perform_gacha_pull] --> B{Check banner}
B -->|star/gold| C[Read wallet balance]
C --> D{Sufficient funds?}
D -->|No| E[error: Insufficient currency]
D -->|Yes| F{Check pity}
F -->|pity >= 90| G[Force real_prize rarity]
F -->|pity < 90| H[Roll rarity by drop rates]
G --> I[Pick from real_prize pool]
H --> J[Pick from rarity pool]
I --> K[Deduct wallet cost]
J --> K
K --> L{real_prize?}
L -->|Yes| M[Add to inventory storage]
L -->|No| N[Increment fragment count]
M --> O[Return results]
N --> O
```
### Drop rates
| Rarity | Rate | Result |
|---|---|---|
| Common | 60% | Fragment (`frag_common`) |
| Uncommon | 25% | Fragment (`frag_uncommon`) |
| Rare | 14% | Fragment (`frag_rare`) |
| Real Prize | 1% | Skin from catalog |
**Pity:** Guaranteed Real Prize at 90 pulls. Pity counter resets on any Real Prize win.
[Back to top](#top)
---
## Mail/Inbox System
```mermaid
flowchart TD
Admin[Admin RPC] -->|admin_send_mail| Global[Global Mail<br/>config/global_mail<br/>system user]
Admin -->|admin_send_mail<br/>with target_user_id| Personal[Personal Mail<br/>inbox/personal<br/>per user]
Client -->|get_mail| Merge[Merge global + personal]
Merge --> Filter[Filter by: not deleted,<br/>within date range,<br/>not expired]
Filter --> Response[Return to client]
Client -->|claim_mail_reward| CW{Check claimed_ids}
CW -->|Already claimed| Err[error: Reward already claimed]
CW -->|Not claimed| Grant[Grant rewards:<br/>gold/star -> wallet<br/>fragments -> fragment storage<br/>skins -> inventory storage]
Grant --> UpdateState[Update state:<br/>add mailId to claimed_ids]
```[Global Mail<br/>config/global_mail<br/>system user]
Admin -->|admin_send_mail<br/>with target_user_id| Personal[Personal Mail<br/>inbox/personal<br/>per user]
Client -->|get_mail| Merge[Merge global + personal]
Merge --> Filter[Filter by: not deleted,<br/>within date range,<br/>not expired]
Filter --> Response[Return to client]
Client -->|claim_mail_reward| CW[Check claimed_ids]
CW -->|Already claimed| Err[error: Reward already claimed]
CW -->|Not claimed| Grant[Grant rewards:<br/>gold/star -> wallet<br/>fragments -> fragment storage<br/>skins -> inventory storage]
Grant --> UpdateState[Update state:<br/>add mailId to claimed_ids]
```
[Back to top](#top)
---
## Client-Server Data Flow
```mermaid
sequenceDiagram
participant C as Godot Client
participant L as Lua RPC
participant S as Nakama Storage
participant W as Wallet
Note over C,W: Purchase Flow
C->>L: purchase_item(item_id, idempotency_key)
L->>L: Look up item in SHOP_CATALOG_DEFS
L->>W: wallet_update(-price)
alt Insufficient funds
W-->>L: error
L-->>C: error("NotEnoughFunds")
else Success
L->>S: storage_write(inventory/item_id)
L->>S: storage_write(receipts/idempotency_key)
L-->>C: {success: true, item: item_id}
end
Note over C,W: Wallet State Sync
C->>L: get_account (Nakama SDK call)
S->>C: wallet JSON string
C->>C: Parse wallet JSON
C->>C: emit profile_updated signal
C->>C: All UI panels update labels
```
All transactions are **idempotent** via `idempotency_key` — if the same key is used twice, the server returns the previous result instead of re-executing.
[Back to top](#top)
---
## Admin Hierarchy
| Role | Can | Guarded by |
|---|---|---|
| `player` (default) | Nothing special | — |
| `moderator` | Match-related admin: kick players, get server stats | `utils.require_admin_or_host` (also checks match host) |
| `admin` | All moderation: ban/unban, manage mail, manage chat, view users | `utils.require_admin(context)` |
| `owner` | Everything admin can + set user roles | Inline check: `callerMetadata.role == "owner"` |
### Guard functions (utils.lua)
```lua
utils.require_admin(context) -- errors if role not admin or owner
utils.require_admin_or_host(context, match_id) -- errors if not admin/owner AND not match host
utils.is_banned(metadata) -- returns boolean
utils.resolve_channel_id(channelId) -- channel name → hashed ID
```
[Back to top](#top)
---
## Storage Collections
| Collection | Owner | Key | Public R | Public W | Purpose |
|---|---|---|---|---|---|
| `profiles` | User | `"profile"` | 1 | 0 | User metadata, role, ban status, loadout |
| `profiles` | User | `"pity_counters"` | 1 | 0 | Per-banner gacha pity counts |
| `profiles` | User | `"fragments"` | 1 | 0 | Accumulated gacha fragments |
| `inventory` | User | Item ID | 1 | 0 | Owned cosmetic items |
| `inventory` | User | `"fragments"` | 1 | 0 | Fragment counts (legacy) |
| `stats` | User | `"game_stats"` | 1 | 0 | Player stats (wins, kills, score) |
| `receipts` | User | Idempotency key | 1 | 0 | Purchase receipts (IAP + shop) |
| `inbox` | User | `"personal"` | 1 | 0 | Personalized mail inbox |
| `inbox` | User | `"state"` | 1 | 0 | claimed_ids, deleted_ids, read_ids |
| `daily_rewards` | User | `"state"` | 1 | 0 | Daily reward claim tracking |
| `config` | SYSTEM | `"global_mail"` | 2 | 0 | Global mail sent to all players |
| `config` | SYSTEM | `"daily_rewards"` | 2 | 0 | Monthly reward schedule |
| `config` | SYSTEM | `"lobby_chat"` | 2 | 0 | Chat prefix/max_messages config |
| `shop_config` | SYSTEM | `"featured_banners"` | 2 | 0 | Featured shop banners (max 3) |
| `bans` | SYSTEM | User ID | 2 | 0 | Ban records (redundant with metadata) |
[Back to top](#top)
---
## vs Nakama-Server-API
| Aspect | Architecture-Server (this page) | Nakama-Server-API |
|---|---|---|
| Audience | Architects, new devs | Implementers, AI agents |
| Detail level | High-level flow, diagrams | Per-function: params, returns, errors |
| Diagrams | Mermaid flowcharts | None |
| RPC listing | Summary table with key flows | Full 48 RPC documentation |
| Storage | Conceptual collection overview | Exact schema per collection |
| Best for | Understanding the system | Calling RPCs without reading code |
[Back to top](#top)
---
## Quick Reference: All 48 Registered RPCs
| RPC Name | Module | Auth | Purpose |
|---|---|---|---|
| `update_display_name` | user | required | Change display name |
| `update_avatar` | user | required | Change avatar URL |
| `sync_profile` | user | required | Push profile to server |
| `change_identity` | user | required | Link new device/email |
| `set_password` | user | required | Set email password |
| `sync_friends` | user | required | Push friend list |
| `get_shop_catalog` | economy | required | Get catalog + featured |
| `buy_currency` | economy | required | IAP gold/star purchase |
| `purchase_item` | economy | required | Buy cosmetic item |
| `perform_gacha_pull` | gacha | required | Roll gacha |
| `get_leaderboard_stats` | leaderboard | no | Get top 50 scores |
| `submit_score` | leaderboard | required | Record match score |
| `sync_leaderboard` | leaderboard | required | Sync stats → leaderboard |
| `reset_stats` | leaderboard | required | Clear own stats |
| `get_mail` | inbox | required | Get available mail |
| `claim_mail_reward` | inbox | required | Claim mail rewards |
| `delete_mail` | inbox | required | Soft-delete mail |
| `save_mail_state` | inbox | required | Mark as read |
| `claim_daily_reward` | daily_rewards | required | Claim today's reward |
| `get_daily_reward_state` | daily_rewards | required | View monthly schedule |
| `set_daily_reward_config` | daily_rewards | admin | Set reward schedule |
| `get_daily_reward_config_admin` | daily_rewards | admin | Get reward config |
| `admin_*` (18 RPCs) | admin | admin/owner | Moderation tools |
For full params/returns/errors on any RPC above, see [Nakama-Server-API](../Nakama-Server-API).
[Back to top](#top)
---
## Deployment Topology
```mermaid
flowchart TD
subgraph VPS [VPS 52.74.133.55]
Gitea[Gitea Server<br/>port 3000]
Nakama[Nakama Server<br/>port 7350/7351]
PG[(PostgreSQL<br/>Nakama DB)]
Act[act_runner<br/>CI/CD Worker]
end
Client[Godot Player] -->|HTTPS| Gitea
Client -->|gRPC/WebSocket| Nakama
Nakama --> PG
GitHub_mirror[GitHub Mirror] -->|Push| Gitea
Gitea -->|Webhook/Manual| Act
Act -->|Build| Binary[Binary Releases]
Act -->|Build| Patch[patch.pck on patches branch]
Client -->|Check version| Gitea
Client -->|Download patch| Gitea
```
[Back to top](#top)
+604
View File
@@ -0,0 +1,604 @@
# Game Modes
<a id="top"></a>
[Back to Home](./Home)
## Table of Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [GameMode Enum &amp; ModeConfig](#gamemode-enum--modeconfig)
- [Session Flow (all modes)](#session-flow-all-modes)
- [Core Managers (shared)](#core-managers-shared)
- [Freemode](#freemode)
- [Stop n Go](#stop-n-go)
- [Tekton Doors (Portal)](#tekton-doors-portal)
- [Candy Pump Survival (Gauntlet)](#candy-pump-survival-gauntlet)
- [Scoring &amp; Leaderboard](#scoring--leaderboard)
- [Glossary](#glossary)
- [File Index](#file-index)
[Back to top](#top)
## Overview
Four game modes, each implementing the same core loop — players navigate a grid, collect tiles (Heart / Diamond / Star / Coin), match them to goals on a virtual 5x5 playerboard, and compete for score before the match timer expires.
| Mode | Enum Value | Display Name | Play Area | Gimmick |
|------|-----------|-------------|-----------|---------|
| Freemode | `FREEMODE = 0` | "Freemode" | Variable | No restrictions, just goals + timer |
| Stop n Go | `STOP_N_GO = 1` | "Stop n Go" | 23x12 | GO/STOP phases, safe zones, scatter penalty |
| Tekton Doors | `TEKTON_DOORS = 2` | "Tekton Doors" | 14x14 | 4 rooms, portal doors swap connections every 15s |
| Candy Pump Survival | `GAUNTLET = 3` | "Candy Pump Survival" | 20x20 | Ground growth — candy slowly fills the arena |
[Back to top](#top)
## Architecture
```
GameMode (enum/RefCounted)
├── .from_string() / .mode_to_string() / .is_restricted() / .get_all_modes()
├── ModeConfig (RefCounted)
│ └── SCHEMA with defaults, type-checking, min/max for each mode
├── GoalsCycleManager (Node, autoload?)
│ ├── 30s cycle timer
│ ├── Match timer (configurable 60-600s)
│ ├── Score tracking (player_scores, player_goal_counts)
│ ├── Goal completion: _process_goal_completion()
│ └── RPC sync: sync_player_score, sync_goal_count, sync_timer
├── GoalManager (Node)
│ ├── initialize_random_goals() — generates 9-slot goal patterns
│ ├── Speed tracking (completion_times, boost_multiplier)
│ └── generate_preset_goals() / get_goals_for_player()
├── PlayerRaceManager (per-player)
│ ├── goals: Array[int] (9 slots)
│ ├── playerboard: Array[int] (25 slots, 5x5)
│ ├── check_pattern_match() — core matching logic
│ └── DEPRECATED lap/finish-line stubs
├── PlayerboardManager (per-player)
│ ├── grab_item() / auto_put_item() / arrange operations
│ ├── _execute_grab() — server-authoritative grab validation
│ ├── HIDDEN_SLOTS (12 of 25 cells blocked)
│ ├── bot_try_grab_item() — AI grab logic
│ └── _check_and_refill_grid_if_needed() — scarcity refill
├── TurnManager (shared)
│ ├── next_turn() / end_current_turn()
│ └── turn_based_mode toggle
└── Mode-specific managers
├── StopNGoManager — phase transitions, safe zones, mission HUD
├── PortalModeManager — room partitions, portal doors, swap timer
└── GauntletManager — ground growth, phases, bubbles, smack
```
All mode managers live under `/root/Main` and connect to `GoalsCycleManager` signals for score / goal tracking.
[Back to top](#top)
## GameMode Enum &amp; ModeConfig
**File:** `scripts/game_mode.gd` (41 lines)
```
enum Mode { FREEMODE = 0, STOP_N_GO = 1, TEKTON_DOORS = 2, GAUNTLET = 3 }
```
| Static Method | Returns | Description |
|--------------|---------|-------------|
| `from_string(mode: String)` | `Mode` | Converts "Freemode"/"Stop n Go"/"Tekton Doors"/"Candy Pump Survival" to enum |
| `mode_to_string(mode: Mode)` | `String` | Reverse of from_string |
| `is_restricted(mode: Mode)` | `bool` | true for STOP_N_GO, TEKTON_DOORS, GAUNTLET |
| `get_all_modes()` | `Array[String]` | Returns display names |
**File:** `scripts/mode_config.gd` (109 lines)
SCHEMA defines per-mode settings with type, default, min, max, allowed values:
| Mode | Settings |
|------|---------|
| Freemode | match_duration (180s default), randomize_spawn (bool), enable_cycle_timer (bool), scarcity_mode (Normal/Aggressive/Chaos) |
| Stop n Go | match_duration, sng_go_duration (20s), sng_stop_duration (4s), sng_required_goals (8) |
| Tekton Doors | match_duration, doors_swap_time (15s), doors_refresh_time (25s), doors_required_goals (8) |
| Gauntlet | match_duration, gauntlet_growth_interval (3.0s), gauntlet_cells_per_tick (phase dict) |
| Method | Args | Returns |
|--------|------|---------|
| `get_defaults(mode)` | String | Dictionary of defaults for that mode |
| `validate_setting(mode, key, value)` | String, String, Variant | `{"valid": bool, "error": String}` |
| `validate_config(mode, config)` | String, Dictionary | `{"valid": bool, "errors": Array}` |
| `get_mode_settings(mode)` | String | Array of setting keys |
| `get_setting_schema(mode, key)` | String, String | Dictionary with type/default/min/max/allowed |
[Back to top](#top)
## Session Flow (all modes)
1. **Lobby** — players join, select mode + settings
2. **Game start**`main.gd` calls `_setup_host_game()` which:
- Creates arena (gridmap resize + clear)
- Spawns mission tiles on Layer 1
- Calls mode manager's `start_game_mode()`
3. **Countdown** — brief timer then match begins
4. **Match active**`GoalsCycleManager.start_match()` runs match timer + cycle timer
5. **Per-cycle** (30s):
- Players grab tiles from grid → place on 5x5 playerboard
- Match 3x3 pattern against 9-slot goals
- Complete goals → B1000 score + new goals + tiles randomize around player
- Cycle ends → board cleared, unmatched tiles scored at 10/tile match
6. **Match ends** — final leaderboard sync
[Back to top](#top)
## Core Managers (shared)
### GoalManager (`scripts/managers/goal_manager.gd`, 108 lines)
Generates 9-slot goal arrays using tile IDs 7-10 (Heart=7, Diamond=8, Star=9, Coin=10) with -1 for no-goal slots (~3 nulls per set).
| Function | Returns | Description |
|----------|---------|-------------|
| `initialize_random_goals(size, min_value, max_value, null_count)` | `Array` | Random goals with controlled null distribution |
| `generate_preset_goals(count)` | `Array` | Pre-generates N goal sets for all players |
| `get_goals_for_player(player_index)` | `Array` | Returns goals for a specific player slot |
| `mark_goal_start(player_id)` | void | Records timestamp for speed tracking |
| `mark_goal_complete(player_id)` | void | Records completion duration |
| `get_player_average_time(player_id)` | `float` | Average completion speed |
| `get_global_average_time()` | `float` | Average across all players |
| `get_boost_multiplier(player_id)` | `float` | 0.8-1.5x fill rate based on speed vs average |
| `reset()` | void | Clears all state |
### GoalsCycleManager (`scripts/managers/goals_cycle_manager.gd`, 520 lines)
Central scoring and timer system. Emits signals consumed by mode managers, UI, and leaderboard.
| Signal | Payload |
|--------|---------|
| `cycle_started()` | — |
| `cycle_ended()` | — |
| `timer_updated(time_remaining)` | float |
| `score_updated(peer_id, new_score)` | int, int |
| `goal_count_updated(peer_id, count)` | int, int |
| `leaderboard_updated(sorted_scores)` | Array |
| `match_started()` | — |
| `match_ended()` | — |
| `global_timer_updated(time_remaining)` | float |
| Function | Description |
|----------|-------------|
| `start_match(duration_seconds, start_cycles)` | Begins match timer, optionally starts first 30s cycle |
| `_on_match_end()` | Processes final scores, syncs to clients |
| `start_cycle()` | Begin 30s cycle, emit `cycle_started` |
| `on_goal_completed(player, time_remaining)` | Entry point — routes to server or optimistic local path |
| `_process_goal_completion(player, time_remaining)` | Server: award B1000 + time bonus, regen goals, randomize tiles |
| `regenerate_goals_for_player(player)` | Generate new 9-slot goal set, sync via RPC |
| `_randomize_tiles_around_player(player)` | Randomize 3x3 area around player on the grid |
| `_process_cycle_end_for_all_players()` | Clear all boards, convert matches to B10/tile |
| `add_score(peer_id, amount)` | Server: add arbitrary score points |
| `_update_leaderboard()` | Sort by score descending (or with SNG winner override) |
### PlayerRaceManager (`scripts/managers/player_race_manager.gd`, 133 lines)
State holder per player. Core logic is `check_pattern_match()`.
| Function | Description |
|----------|-------------|
| `check_pattern_match()` | Returns true if any 3x3 sub-grid of 5x5 board matches 3x3 goals |
| `check_3x3_section(board, goals, start_row, start_col)` | Checks single 3x3 section |
| `_normalize_tile(tile)` | Converts holo tiles 11-14 → 7-10 for match comparison |
| Remaining functions | DEPRECATED lap/finish line stubs |
**Playerboard Layout:** 5x5 (indices 0-24). 13 cells are HIDDEN_SLOTS (cannot hold tiles). Only 12 usable slots arranged in an L-shape matching the HUD.
### PlayerboardManager (`scripts/managers/playerboard_manager.gd`, 793 lines)
Handles grab, put, arrange operations with optimistic local updates + server-authoritative validation.
| Function | Description |
|----------|-------------|
| `grab_item(grid_position)` | Grab tile from grid → place on board (auto-arrange) or consume as power-up |
| `_execute_grab(grid_pos, cell, item_id, expected_slot)` | Server-side validation + state update + sync |
| `_force_sync_to_client(cell, server_item)` | Revert client when server rejects grab |
| `auto_put_item()` | AI/bot: find best tile to remove from board |
| `find_best_goal_slot_for_item(item)` | Auto-arrange into best matching slot |
| `bot_try_grab_item()` | AI grab logic |
| `_check_and_refill_grid_if_needed(gridmap)` | Refill floor 1 via ScarcityController when empty |
### TurnManager (`scripts/managers/turn_manager.gd`, 27 lines)
| Function | Description |
|----------|-------------|
| `next_turn(players)` | Advance turn index, emit `turn_changed` |
| `end_current_turn()` | Emit `turn_ended` |
| `reset_turn()` / `reset()` | Clear state |
[Back to top](#top)
## Freemode
**No dedicated manager.** Freemode relies entirely on the shared core managers (GoalsCycleManager, PlayerboardManager, etc.) with no mode-specific restrictions or gimmicks. Arena size is configurable via LobbyManager settings.
**Settings effects:**
- `enable_cycle_timer` false → cycle never expires (board never auto-clears)
- `scarcity_mode` → controls tile refill aggression
- `randomize_spawn` → players start at random positions
[Back to top](#top)
## Stop n Go
**Manager:** `StopNGoManager` (`scripts/managers/stop_n_go_manager.gd`, 1107 lines)
A 23x12 arena with two alternating phases:
### Phase System
| Phase | Duration (default) | Behaviour |
|-------|-------------------|-----------|
| GO | 20s | Players move freely, collect tiles, complete goals |
| STOP | 4s | Players frozen if outside safe zone → tiles scattered |
When STOP begins:
1. 3 dynamic safe zones spawn randomly (green tiles)
2. All players outside safe zone get `_scatter_player_tiles()` — board tiles scattered on grid
3. Power-up tiles (Speed=11, Ghost=14) spawn at 5 permanent locations
4. Mission requirement: complete 8 goals before reaching finish (x=22)
When GO begins:
1. Dynamic safe zones cleared
2. All STOP freeze effects removed via `sync_stop_freeze(false)`
### Tile IDs
| ID | Meaning |
|----|---------|
| 0 | Walkable floor |
| 2 | Safe zone (green) |
| 3 | Start/Finish line |
| 4 | Wall/obstacle |
| 15 | Lightning stone (decorative ancient rock) |
| 16 | Safe zone wall |
### Arena
22x10 walkable area with two interior rooms with entrances:
- Room 1: (7,6) to (11,9) — 5x4 area with 4 door entrances
- Room 2: (15,1) to (19,5) — 5x5 area with 4 door entrances
### HUD
- Center-bottom mission label: "GOALS (X/8)" or "ALL GOALS COMPLETE! REACH THE FINISH!"
- Traffic light stop timer (3 segments): all empty during GO, fills red during STOP phase
- Last 3 seconds of GO phase: segments light up one-by-one (countdown)
- VFX: `vfx_manager.play_go_animation()` / `play_stop_phase()`
### RPCs
| RPC | Direction | Description |
|-----|-----------|-------------|
| `sync_phase(phase_name, duration)` | Authority → all | Broadcast GO/STOP phase change |
| `sync_arena_setup()` | Authority → remote | Sync 23x12 grid dimensions + obstacles |
| `sync_all_safe_zones_vfx()` | Authority → all | Trigger safe zone visual effects |
### Key Functions (server-only unless noted)
| Function | Description |
|----------|-------------|
| `start_game_mode()` | Server: setup arena, assign missions, start GO phase |
| `_start_phase(phase)` | Transition GO↔STOP, penalize players outside safe zone |
| `_setup_arena()` | Build 23x12 with obstacles + rooms |
| `_spawn_mission_tiles()` | Heart(7)/Diamond(8)/Star(9)/Coin(10) at 60% density |
| `_spawn_powerup_tiles()` | Speed(11)+Ghost(14) at 5 permanent locations |
| `_assign_missions()` | NO-OP (mission = achievement: collect 8 goals) |
| `activate_client_side()` | Client: show HUD, connect to GoalsCycleManager signals |
| `rotate_players_to_start()` | Force all players to face East (PI/2) |
| `can_rpc()` | Check multiplayer peer is connected |
[Back to top](#top)
## Tekton Doors (Portal)
**Manager:** `PortalModeManager` (`scripts/managers/portal_mode_manager.gd`, 585 lines)
**Actor:** `PortalDoor` (`scripts/portal_door.gd`, 136 lines)
A 14x14 grid divided into 4 rooms (7x7 each) by cross-shaped wall partitions. Players move between rooms via portal doors that swap connections every 15 seconds.
### Room Layout
```
Room 0 (NW) | Room 1 (NE)
x: 0-6 | x: 7-13
z: 0-6 | z: 0-6
--------------+--------------
Room 2 (SW) | Room 3 (SE)
x: 0-6 | x: 7-13
z: 7-13 | z: 7-13
```
Central divider: columns 6,7 and rows 6,7 are walls (tile ID 4).
### Portal System
- 10 doors total (2 base per room + 2 randomly placed extras)
- Every 15s (`doors_swap_time`): `_randomize_connections()` shuffles pairings
- Each pair gets a color from `PORTAL_COLORS` (Cyan/Magenta/Red/Green/Orange)
- Validation ensures no pair connects doors in the same room
- 200ms anti-jitter cooldown per player in `handle_portal_interaction()`
### PortalDoor (actor)
| Property/Method | Description |
|----------------|-------------|
| `room_id` | Which room this door belongs to |
| `door_id` | Unique door index |
| `target_door_id` | Connected door (set by PortalModeManager) |
| `portal_color` | Color (set triggers `set_portal_color`) |
| `detection_area` | Area3D — body_entered triggers portal |
| `_on_body_entered(body)` | 200ms cooldown, emit `player_entered_portal` |
| `spawn_offset` | Vector2i meta — nudge spawn position into room |
| `_adjust_indicator_position()` | Move GroundIndicator toward room interior |
### Finish Room
- At 30s remaining on match timer, reveal `_spawn_finish_room()`
- Random 3x3 area converted to finish tiles (ID 3) in one room
- Player must be standing on finish tile AND have `doors_required_goals` complete
### Tile Refill
- Every 25s (`tile_refresh_time`): `_refresh_tiles()` refills Floor 1 at 60% density
- Uses `ScarcityModel.get_tile_weights()` for weighted random selection
- Avoids spawning under portal doors
### HUD
- Center-bottom: "GOALS (X/8)" or "ALL GOALS COMPLETE! FIND THE FINISH ROOM!"
- Message broadcasts: "PORTALS SWITCHED!", "TILES REPLENISHED!"
- Warning: "A 3x3 Finish Zone has appeared in Room N!"
### RPCs
| RPC | Direction | Description |
|-----|-----------|-------------|
| `sync_portal_data(data)` | Authority → local | Sync connections + colors to all clients |
| `sync_portal_configs(door_configs)` | Via main | Broadcast door positions/rotations |
### Key Functions
| Function | Description |
|----------|-------------|
| `initialize(p_main, p_gridmap)` | Create swap timer + tile refresh timer, connect signals |
| `start_game_mode()` | Setup arena, randomize connections, start timers |
| `setup_arena_locally()` | Resize to 14x14, build room walls, spawn portal doors |
| `_randomize_connections()` | Shuffle door pairings, assign colors, validate same-room rule |
| `handle_portal_interaction(player, door)` | Teleport player to connected door + offset |
| `_spawn_finish_room()` | Convert random 3x3 area to finish tiles |
| `check_win_condition(player_id, pos)` | Check finish tile + mission complete |
| `_refresh_tiles()` | Refill floor 1 items with scarcity weights |
| `sync_to_client(peer_id)` | Sync portal connections to late-joining client |
| `get_spawn_points()` | Returns 4 spawn positions (one per room quadrant) |
[Back to top](#top)
## Candy Pump Survival (Gauntlet)
**Manager:** `GauntletManager` (`scripts/managers/gauntlet_manager.gd`, 1825 lines)
A 20x20 arena where sticky candy (pink) slowly grows from the edges inward over 3 phases. Players must navigate shrinking safe zones, avoid sticky tiles, and use the "Smack" ability to temporarily clear candy.
### Phase System
| Phase | Start | Duration | Cell Growth (per tick) | Description |
|-------|-------|----------|----------------------|-------------|
| OPEN_ARENA (0) | 0s | 60s | 4-6 | "Outer Pressure" — candy pushes from perimeter |
| ROUTE_PRESSURE (1) | 60s | 60s | 6-8 | "Middle Pressure" — corridors tighten |
| SURVIVAL_ENDGAME (2) | 120s | 60s | 8-10 | "Inner Survival" — center fills in |
Each phase transition shrinks the arena bounds by removing outer layers (via `_shrink_arena()`).
### Growth Algorithm
Each tick (every `growth_interval` = 3s):
1. **Detect movement buffers** — identify critical corridor cells (#083)
2. **Generate candidates** — all SAFE cells scored by formula:
```
CandidateScore = LayerPriority + StickyNeighbor + InwardPressure
+ PlayerPressure + ClusterGrowth + CampingPressure
+ RandomNoise(-20..+20) + MovementBuffer + PathSafety + Repetition
```
3. **Weighted selection** — pick `_cells_this_tick()` cells via roulette wheel
4. **Path safety check**`_apply_path_safety()` ensures no player gets fully trapped
5. **Telegraph** — amber warning overlay appears for 1s (cells still passable)
6. **Apply** — cells convert to permanent STICKY (pink overlay on Layer 2)
### Scoring Components
| Score Component | Range | Description |
|----------------|-------|-------------|
| `_score_layer_priority` | -40..+60 | Phase weight by ring (outer/middle/inner) |
| `_score_sticky_neighbor` | 0..+64 | +8 per adjacent sticky cell (cap +64) |
| `_score_inward_pressure` | 0..+30 | Push inward, scales with phase |
| `_score_player_pressure` | -50..+20 | 2-4 cells away +20; under player -50 (+10 in final 30s) |
| `_score_cluster_growth` | 0..+25 | +15 expansion, +25 bridge between clusters |
| `_score_camping_pressure` | 0..+60 | Per-region: >5s +20, >8s +40, >10s +60 |
| `_score_movement_buffer` | -40..0 | Hidden corridor buffers + player proximity floor |
| `_score_path_safety` | -100..0 | Soft penalty if selection would strand a player |
| `_score_repetition` | -30..0 | Penalty for cells near last tick's selection |
### Cell States
| State | Meaning | Passable? |
|-------|---------|-----------|
| SAFE | Normal floor | Yes |
| TELEGRAPHED | Amber warning (1s) | Yes |
| STICKY | Permanent candy overlay | No (slows) |
| BUBBLE_GROWING | Candy bubble expanding | No |
| BLOCKED | NPC zone / permanent obstacle | No |
### Candy Bubble System (#082)
Anti-camping hazard: grows 1x1 → 3x3 sticky area.
| Phase | Max Bubbles |
|-------|-------------|
| OPEN_ARENA | 0 (disabled) |
| ROUTE_PRESSURE | 2 |
| SURVIVAL_ENDGAME | 3 |
| Property | Value |
|----------|-------|
| Grow duration | 2.75s |
| Explosion radius | 1 (3x3) |
| Recent memory | 4 positions |
| Anti-stack radius | 3 (no bubbles within 3 of recent) |
### Camping Detection (#073)
Players tracked in 4x4 regions. Time accumulates while player stays in same region, resets on region change. Drives camping pressure in candidate scoring.
### Movement Buffers (#083)
Hidden per-cell penalties on critical corridor cells (chokepoints where removing the cell would isolate part of the arena). Decay over time and phase transitions so arena can still close in.
### Smack Mechanic
| Property | Value |
|----------|-------|
| Cooldown | 8s |
| Charge window | 3s |
| Effect | Clears nearby sticky? (consumes charge) |
Per-player cooldown/charge tracked in `smack_cooldowns` / `smack_charged` Dictionaries. Pink modulate during charge window, white on cooldown.
### Arena NPC
Candy Pump NPC at center (9,9) in a 3x3 blocked zone. Visual-only in v2 (projectile logic removed). Scattered projectiles still spawned for visual effect during telegraph phase.
### Slow-Mo
- Triggered conditionally, duration 4s
- `Engine.time_scale = 0.25` (1/4 speed)
- Restored to 1.0 in `_exit_tree()`
### Spawn Points
| Player Count | Positions |
|-------------|-----------|
| 4 | 4 corners: (1,1), (18,1), (1,18), (18,18) |
| 5-6 | 4 corners + top-mid (10,1) + bottom-mid (10,18) |
| 7-8 | 4 corners + all 4 mid-edges |
### RPCs
| RPC | Direction | Description |
|-----|-----------|-------------|
| `sync_phase(phase_index, phase_name)` | Authority → local | Phase change broadcast |
| `sync_arena_setup()` | Authority → remote | Arena dimensions + layout |
| `sync_growth_telegraph(cells)` | Authority → local | Show amber warning on selected cells |
| `sync_growth_apply(cells)` | Authority → local | Convert telegraphed to sticky |
| `consume_smack(pid)` | Any peer → local | Smack consumption + animation |
| `sync_stop_freeze` | (inherited from player.gd) | Freeze/unfreeze player |
### Key Functions
| Function | Description |
|----------|-------------|
| `initialize(main, grid)` | Connect to GoalsCycleManager |
| `start_game_mode()` | Activate client side, start OPEN_ARENA phase |
| `_setup_arena()` | Build 20x20, spawn Candy Pump NPC |
| `_process_growth_tick()` | One growth cycle: score → select → telegraph → apply |
| `_generate_candidates()` | Score all SAFE cells |
| `_calculate_candidate_score(pos, player_cells)` | Full formula with 10 components |
| `_select_cells_weighted(candidates, count)` | Roulette-wheel selection |
| `_apply_path_safety(selected)` | Filter: ensure no player stranded |
| `_try_spawn_bubble()` | Anti-camping bubble spawn attempt |
| `_update_camp_tracking(delta)` | Per-player region residency timer |
| `_detect_movement_buffers()` | Identify critical corridor chokepoints |
| `_shrink_arena()` | Remove outer arena layers on phase change |
| `_spawn_mission_tiles()` | Heart/Diamond/Star/Coin at 60% density |
| `get_spawn_points(player_count)` | Return spawn positions by player count |
| `has_smack_charged(pid)` / `consume_smack(pid)` | Smack mechanic |
| `_spawn_telegraph_highlight(pos)` | Amber glow visual (2-stage: build-up + flash) |
| `_spawn_impact_particles(targets)` | Candy splash particles on sticky impact |
| `_check_all_players_trapped()` | Re-evaluate sticky traps after growth apply |
[Back to top](#top)
## Scoring &amp; Leaderboard
| Action | Points |
|--------|--------|
| Complete goal pattern (match 3x3) | B1000 + time bonus |
| Tile match at cycle end (per tile) | B10 |
| Time bonus formula | `int(time_remaining * TIME_BONUS_MULTIPLIER)` — currently 0 (flat 1000) |
Leaderboard sorted descending. Stop n Go special case: winner (first to reach finish) placed at top regardless of score.
Leaderboard signal payload:
```
[{"peer_id": int, "score": int}, ...]
```
[Back to top](#top)
## Glossary
| Term | Definition |
|------|------------|
| Goal | 3x3 pattern (9 slots, some -1 for null) player must match on their playerboard |
| Playerboard | 5x5 virtual grid (12 usable slots, 13 hidden) per player |
| Tile | Grid item on Floor 1: Heart(7), Diamond(8), Star(9), Coin(10) |
| Holo Tile | Power-up tiles: Speed(11), Ghost(14) — consumed on pickup, not placed on board |
| Cycle | 30-second scoring round; ends with board clear + point conversion |
| Sticky | Permanent pink overlay on Gauntlet floor cells — blocks/slows movement |
| Telegraph | Amber 1-second warning before a cell becomes sticky |
| Safe Zone | Green tiles in Stop n Go STOP phase — only safe tile type |
| Portal | Colored door connecting rooms in Tekton Doors |
| Scarcity | Tile refill model controlling spawn weights based on mode config |
| Smack | Gauntlet ability: clear nearby sticky (8s cooldown, 3s charge window) |
| Camping | Player staying in same 4x4 region >5s, attracts growth pressure |
| Movement Buffer | Hidden chokepoint corridor that growth algorithm avoids sealing early |
| Chebyshev Distance | `max(|x1-x2|, |y1-y2|)` — used for all proximity calculations |
[Back to top](#top)
## File Index
| File | Lines | Role |
|------|-------|------|
| `scripts/game_mode.gd` | 41 | Mode enum + helper functions |
| `scripts/mode_config.gd` | 109 | Schema-driven per-mode settings |
| `scripts/managers/goal_manager.gd` | 108 | Goal generation + speed tracking |
| `scripts/managers/goals_cycle_manager.gd` | 520 | Timer, scoring, cycle control |
| `scripts/managers/player_race_manager.gd` | 133 | Per-player state: goals, board, pattern matching |
| `scripts/managers/playerboard_manager.gd` | 793 | Grab/put/arrange operations |
| `scripts/managers/turn_manager.gd` | 27 | Turn-based flow |
| `scripts/managers/stop_n_go_manager.gd` | 1107 | Stop n Go phase system, safe zones, HUD |
| `scripts/managers/portal_mode_manager.gd` | 585 | Tekton Doors room layout, portals, tiles |
| `scripts/managers/gauntlet_manager.gd` | 1825 | Candy Pump Survival growth, phases, smack |
| `scripts/portal_door.gd` | 136 | PortalDoor actor — detection, teleport, visuals |
| `scripts/managers/goals_cycle_manager.gd` | (shared) | Also referenced by gauntlet signal connections |
| `scripts/managers/camera_context_manager.gd` | ... | Camera mode changes per game mode? |
| `scripts/managers/player_movement_manager.gd` | ... | Movement restrictions per mode |
[Back to top](#top)
+16
View File
@@ -0,0 +1,16 @@
# Tekton Dash Armageddon
<a id="top"></a>
- [Game Modes](./Game-Modes.-) — Full per-mode reference: Stop n Go, Tekton Doors, Candy Pump Survival, Freemode
- [Architecture - Client](./Architecture-Client) — Godot client code structure, managers, scenes, player controller
- [Architecture - Server](./Architecture-Server) — Nakama Lua backend topology, auth flow, wallet economy, admin roles
- [Nakama Server API](./Nakama-Server-API) — Full per-function RPC reference with params, returns, errors
- [Patch Release Workflow](./Patch-Release-Workflow.-) — Hot patch and binary release CI/CD pipelines
- [Skin Creation Workflow](./Skin-Creation-Workflow.-) — Skin material authoring, catalog registration, gacha prizes
- [Nakama Deployment](./Nakama-Deployment.-) — Push Lua updates to Nakama server
- [SSH Setup — Linux](./SSH-Setup-Linux)
- [SSH Setup — macOS](./SSH-Setup-macOS)
- [SSH Setup — Windows](./SSH-Setup-Windows)
[Back to top](#top)
+322
View File
@@ -0,0 +1,322 @@
# Patch & Release Workflow
Complete guide for shipping updates to Tekton players — hot patches (`.pck`) for content changes and full binary releases for engine/platform changes.
---
## Overview
Two automated CI pipelines handle all distribution:
| Pipeline | Trigger | Output | Delivery |
|---|---|---|---|
| **Deploy Patch** (`deploy_patch.yml`) | Manual workflow dispatch | `patch.pck` + `version.json``patches` branch | Gitea raw endpoint |
| **Release** (`ci.yml`) | Git tag `v*` push | Windows/Linux/macOS `.zip` → Gitea Release | git.klud.top releases |
---
## Infrastructure
### Gitea instance
- **URL:** https://git.klud.top
- **API:** http://52.74.133.55:3000/api/v1
- **Runner:** Local Docker container (`gitea-runner`) via `docker-compose`
- **Cache volume:** `/home/dev/godot-cache``/cache` (rw) inside runner containers
- **Secret:** `TEKTON_RELEASE_TOKEN` — Token from user `adtpdn` with repo write access
### Patch serving
Patches served directly from Gitea's built-in raw file endpoint — no external CDN:
- Manifest: `https://git.klud.top/danchie/tekton/raw/branch/patches/version.json`
- PCK: `https://git.klud.top/danchie/tekton/raw/branch/patches/patch.pck`
Old `raw.klud.top` (gitea-pages container) retired — Gitea raw endpoint is faster, simpler, and always available.
### Release page
- **URL:** https://git.klud.top/danchie/tekton/releases
- Assets auto-uploaded by CI on tag push
---
## Part 1: Hot Patch (content-only updates)
Use when: script changes, UI tweaks, balance patches, asset replacements, config changes.
### Step-by-step
**1. Write changelog**
Edit `CHANGELOG_DRAFT.md` — add player-facing notes under `## [NEXT]`:
```markdown
## [NEXT]
- Fixed playerboard desync in multiplayer.
- Adjusted Gauntlet difficulty scaling.
```
If `[NEXT]` is missing, add the header. Format is markdown list items without leading dash (the tool strips it). Each line becomes a bullet on the patch notes page.
**2. Commit to `experimental`**
```bash
git add CHANGELOG_DRAFT.md
git commit -m "docs: patch notes for next release"
git push origin experimental
```
**3. Trigger patch deploy workflow**
Navigate to the Actions tab:
```
https://git.klud.top/danchie/tekton/actions
```
Click **Deploy Patch****Run workflow**:
| Field | Example |
|---|---|
| **Patch version** | `2.4.3` |
| **Release notes** | `fix: multiplayer desync, gauntlet balance` |
OR via API:
```bash
curl -X POST "http://52.74.133.55:3000/api/v1/repos/danchie/tekton/actions/workflows/deploy_patch.yml/dispatches" \
-H "Authorization: token $TEKTON_RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ref":"experimental","inputs":{"version":"2.4.3","notes":"fix: multiplayer desync, gauntlet balance"}}'
```
### What the CI does (deploy_patch.yml)
1. **Checkout**`git clone --depth 1` from `experimental` branch (shallow = fast).
2. **Setup Godot** — Uses cached `/cache/godot_4.7` binary. Downloads only if missing (140MB, cached forever).
3. **Generate version.json** — Runs `tools/generate_version_json.py --skip-changelog`. Reads version from `project.godot`, bumps patch number, writes `assets/data/version.json` with the new release entry including `pck_url` pointing to Gitea raw endpoint.
4. **Export patch PCK**`godot --headless --export-pack "Windows Desktop" build/patch.pck`. No export templates needed — `--export-pack` only packs resources, not binaries. Output ~10-15MB.
5. **Push to patches branch** — Force-pushes `patch.pck` + `version.json` to the `patches` branch of the repo.
### Verification
```bash
# Check manifest
curl -s "https://git.klud.top/danchie/tekton/raw/branch/patches/version.json"
# Expected: latest_version matches your patch number
# Check pck exists
curl -s -o /dev/null -w "%{http_code} %{size_download}B" \
"https://git.klud.top/danchie/tekton/raw/branch/patches/patch.pck"
# Expected: HTTP 200, size ~10-15MB
```
### How players receive patches
1. Game boots → `GameUpdateManager` fetches `version.json` from Gitea raw endpoint.
2. Compares `latest_version` against local version.
3. If remote is newer → downloads `patch.pck` to `user://patch.pck`.
4. Mounts with `ProjectSettings.load_resource_pack("user://patch.pck")`.
5. All files in `patch.pck` override base `res://` files in memory.
6. No files are overwritten on disk (safe rollback by deleting `patch.pck`).
---
## Part 2: Full Binary Release (platform updates)
Use when: engine upgrade, native plugin change, export template update, platform-specific build fix, or any change that needs a new `.exe`/`.app`.
### Step-by-step
**1. Ensure changelog is written**
Same as patch step 1 — `CHANGELOG_DRAFT.md` must have `## [NEXT]` entries. The CI auto-extracts them for the release body.
**2. Commit and tag**
```bash
# Commit all changes
git add -A
git commit -m "chore: bump to v2.4.3"
# Push to experimental
git push origin experimental
# Create and push tag
git tag v2.4.3 experimental
git push origin v2.4.3
```
**IMPORTANT:** Tag must match `v` + version format (e.g. `v2.4.3`). The CI is triggered by `v*` tags.
### What the CI does (ci.yml)
1. **Install tools**`apt-get install curl unzip zip` (zip was missing in early runs — make sure it's present).
2. **Checkout** — Full clone from tag (shallow not used — needs full history for changelog extraction, though `--depth 1` works too).
3. **Setup Godot with templates** — Caches both Godot binary (140MB) and export templates (1.3GB) in `/cache/`. Templates downloaded once per runner lifetime.
4. **Export 3 platforms:**
- **Windows**`godot --headless --export-release "Windows Desktop"` → zipped with `zip`.
- **Linux/X11** — Same pattern.
- **macOS** — Export to `.zip` directly (Godot's macOS export produces a zip).
- **Note:** Steam DLLs copied into Windows build from `addons/godotsteam/`.
- **Note:** `|| true` on export commands masks Godot errors (e.g. GodotSteam plugin warnings). Real failures (missing `zip`) will surface.
5. **Extract changelog** — Parses `CHANGELOG_DRAFT.md` for the `## [version]` section matching the tag. Writes to `$CHANGELOG_BODY` env var.
6. **Create/Update Gitea Release** — Checks if release exists for tag. Creates new one with changelog as body if missing. Updates draft release if re-run.
7. **Upload assets** — Each `.zip` uploaded as release asset via multipart POST.
8. **Publish** — Sets `draft:false` to make release public.
### Verification
Check the release page:
```
https://git.klud.top/danchie/tekton/releases/tag/v2.4.3
```
Expected: 3 assets (Windows, Linux, macOS) with correct sizes, changelog body populated, release marked as published (not draft).
### Cleaning duplicate assets
If a tag was force-pushed, multiple CI runs may upload duplicate assets to the same release:
```bash
# List assets
curl "https://git.klud.top/api/v1/repos/danchie/tekton/releases/tags/v2.4.3" \
-H "Authorization: token $TEKTON_RELEASE_TOKEN" | jq '.assets[] | "\(.id): \(.name) \(.size/1024/1024)MiB"'
# Delete old duplicates (keep latest 3: Windows, Linux, macOS)
RELEASE_ID=<id>
curl -X DELETE "http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $TEKTON_RELEASE_TOKEN"
```
### Cancelling stuck or duplicate runs
Gitea API cannot cancel in-progress runs. Wait for completion, then delete:
```bash
# List runs for a tag
curl "http://52.74.133.55:3000/api/v1/repos/danchie/tekton/actions/runs?page=1&limit=10" \
-H "Authorization: token $TEKTON_RELEASE_TOKEN" | jq '.workflow_runs[] | "\(.id): \(.status) \(.conclusion) \(.display_title)"'
# Delete completed run
curl -X DELETE "http://52.74.133.55:3000/api/v1/repos/danchie/tekton/actions/runs/$RUN_ID" \
-H "Authorization: token $TEKTON_RELEASE_TOKEN"
```
---
## Part 3: Agent-Automated Release
Agent (Hermes) can execute the full release flow from a single user request:
### Scenario: "Ship v2.4.3"
Agent actions:
1. Read `CHANGELOG_DRAFT.md` — verify `[NEXT]` has entries.
2. Check `project.godot` current version.
3. Commit changelog to `experimental`.
4. Create tag `v2.4.3` → push to trigger `ci.yml`.
5. Wait for CI completion (poll every 30s, up to 30 min).
6. If CI fails:
- Read job logs for failure reason.
- Fix the workflow file, commit, force-push tag.
- Clean up duplicate assets after re-run.
7. Verify release page has 3 assets with correct sizes.
8. If patch deploy also needed:
- Trigger `deploy_patch.yml` dispatch.
- Verify `patch.pck` is served and version.json updated.
### Scenario: "Quick hot patch"
Agent actions:
1. Check if `[NEXT]` has entries in `CHANGELOG_DRAFT.md`.
2. If empty, ask user for changelog notes.
3. Commit `CHANGELOG_DRAFT.md` to `experimental`.
4. Dispatch `deploy_patch.yml` workflow.
5. Verify patch files on Gitea raw endpoint.
---
## Troubleshooting
### `zip: command not found` in CI
Root cause: `ubuntu-latest` container doesn't have `zip` pre-installed. The install step must include `zip`:
```yaml
- name: Install tools
run: apt-get update -qq && apt-get install -y -qq curl unzip zip
```
### Godot export fails silently (`|| true`)
The `|| true` on export commands means a failed Godot export still shows step as success. Check:
- Is `godot_4.7` cached at `/cache/`?
- Does the export preset name match exactly? E.g. `"Windows Desktop"` must match `export_presets.cfg`.
- Is `addons/godotsteam/libgodotsteam*` present? Missing DLLs cause Godot to exit 1.
### Runner container can't clone repo
Runner uses HTTP auth with `god` username and `TEKTON_RELEASE_TOKEN` as password. If token is revoked:
1. Generate new token from Gitea → Settings → Applications.
2. Update secret `TEKTON_RELEASE_TOKEN` in repo Settings → Actions → Secrets.
3. Restart runner: `docker compose -f /home/dev/gitea/docker-compose.yml restart runner`.
### Runner shows "permission denied" for Docker socket
User `dev` doesn't have Docker socket access. Commands that touch Docker must be run via `sudo` or by the root user on the VPS. The local agent can only:
- Restart runner via systemd: `systemctl --user restart docker-runner` (if running as user service).
- No Docker CLI commands from agent terminal.
### Release has duplicate assets
Each CI run uploads assets as new entries. To clean:
- Get release ID from API.
- Delete old asset IDs keeping only latest (highest IDs) for each platform.
- Use jq or manual curl loop (see "Cleaning duplicate assets" above).
### Tag force-push creates redundant CI runs
Each push to a tag triggers `ci.yml`. Force-pushing a tag to a new commit creates another run:
- Previous runs keep running (can't cancel via API).
- Wait for all to finish, then delete stale ones.
- The last run to publish sets the release state.
Best practice: Delete old release before force-pushing tag, or at minimum delete stale completed runs after.
### Patch manifest not updating
`generate_version_json.py --skip-changelog` only bumps version and writes `version.json`. If the version didn't change (e.g. `--skip-changelog` with no `[NEXT]` entries), the script exits with code 0 but doesn't write anything. Verify `assets/data/version.json` has the new version after CI run.
### `gitea-pages` (raw.klud.top) returns 404
gitea-pages container uses a Gitea token to read files. If the token is dead:
- Switch to Gitea native raw endpoint: `https://git.klud.top/danchie/tekton/raw/branch/patches/...`
- Update `MANIFEST_URL` in `generate_version_json.py` and `VERSION_MANIFEST_URL` in `game_update_manager.gd`.
- Retire gitea-pages container entirely (not needed, Gitea has built-in raw serving).
---
## File Reference
| File | Purpose |
|---|---|
| `.gitea/workflows/deploy_patch.yml` | Patch deploy CI — generates pck + pushes to patches branch |
| `.gitea/workflows/ci.yml` | Full binary release CI — exports 3 platforms + creates release |
| `tools/generate_version_json.py` | Version bumping + changelog → version.json conversion |
| `CHANGELOG_DRAFT.md` | Human-readable changelog draft (source of truth for release notes) |
| `assets/data/version.json` | Machine-readable manifest served to players (auto-generated) |
| `scripts/managers/game_update_manager.gd` | Client-side update checker (reads version.json → downloads patch.pck) |
| `project.godot` | Godot project file (config/version = source of truth for version number) |
| `export_presets.cfg` | Export configuration for all platforms |
| `/home/dev/gitea/docker-compose.yml` | Runner container composition (cache volume mount: `/home/dev/godot-cache:/cache`) |
---
## Key Gotchas
- **`zip` must be in install step** — missing zip kills Windows/Linux export. Added in run 141 — do not remove.
- **Tag format is `vX.Y.Z`**`ci.yml` trigger is `v*`. A tag without `v` prefix won't build.
- **Force-push tag = new CI run** — Always expect a new run on force-push. Old run keeps running.
- **Changelog extracted from tag version**`## [X.Y.Z]` section in `CHANGELOG_DRAFT.md`. If section doesn't exist, release body is empty.
- **Patch deploy skips changelog clearing**`--skip-changelog` means `version.json` is written but `CHANGELOG_DRAFT.md` is NOT modified. Only the full `ci.yml` pipeline clears it.
- **Cache is per-runner-host, not per-run** — Godot binary (140MB) and templates (1.3GB) download once on fresh runner container, then persist via `/cache` volume. Running `docker compose down` + `up` reuses cache if volume isn't deleted.
- **`|| true` masks Godot export errors** — If export fails silently, check the `2>&1 | tail -5` output in CI logs. Error messages like "Cannot call method 'queue_free' on a null value" from GodotSteam are non-fatal (cosmetic plugin warnings).
+257
View File
@@ -0,0 +1,257 @@
# Skin Creation Workflow
<a id="top"></a>
How to author a new character skin, register it in the game's shop/gacha catalog, and ship it to players.
---
## Overview
Each skin is defined in two places that must stay in sync:
| Layer | File | What it stores |
|---|---|---|
| **Client (visual)** | `scripts/managers/skin_manager.gd` | Mesh slots, material paths, override/overlay mode |
| **Server (shop)** | `server/nakama/lua/economy.lua` | Item ID, name, category, price (gold/star) |
The item `id` in `economy.lua` must match the dictionary key in `skin_manager.gd` `SKIN_CATALOG` exactly -- the Godot client looks up the item ID from the wallet/inventory and applies the matching skin data.
[Back to top](#top)
---
## Step 1: Create the Skin Material
Open the **Skin Shader Generator** at `res://scenes/tools/skin_shader_generator.tscn`.
1. Run the scene (F6).
2. Import your base albedo and mask textures (PNG with color/alpha channels).
3. Use the UI to visualize UV overlays and adjust color channels (Red, Green, Blue, Alpha).
4. Export the configured material as a `.tres` file into `assets/materials/skins/` or a subfolder:
- `assets/characters/skins/hat/`
- `assets/characters/skins/clothing/`
- `assets/characters/skins/gloves/`
**Material path conventions (Oldpop character):**
| Category | Example path |
|---|---|
| hat | `res://assets/characters/skins/hat/oldpop_mat_hat_blue.tres` |
| costume/clothing | `res://assets/characters/skins/clothing/oldpop_mat_cloth_red_pant.tres` |
| glove | `res://assets/characters/skins/gloves/oldpop_mat_gloves_blue.tres` |
| accessory | `res://assets/characters/skins/accessory/` |
[Back to top](#top)
---
## Step 2: Register the Skin in SkinManager (Client)
Open `res://scripts/managers/skin_manager.gd` and add a new entry inside `SKIN_CATALOG` (between `[BEGIN_SKIN_CATALOG]` and `[END_SKIN_CATALOG]` markers).
### Entry format
```gdscript
"item_id": {
"category": "head", # head | costume | glove | accessory
"character": "Oldpop", # node name under CharacterRoot
"slots": [
{
"mesh": "oldpop-hat1", # MeshInstance3D child name
"mode": "override", # "override" | "overlay"
"material": "res://path/to/material.tres"
},
]
}
```
### Slot modes
- **`override`** -- `set_surface_override_material(0, mat)`. Replaces the base material entirely. Preserves the outline shader (`next_pass`) automatically.
- **`overlay`** -- `material_overlay = mat`. Transparent layer on top of the base material. Good for costume/pant patterns.
### Multi-slot skins (costume example)
Costumes typically touch 3 meshes:
```gdscript
"oldpop-grey-pant": {
"category": "costume",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" },
{ "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" },
{ "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" },
]
},
```
### Tips
- Leave `"material"` as `""` if the `.tres` file is not ready yet. The slot is skipped gracefully.
- Use the **Skin Catalog Editor** (`res://scenes/tools/skin_catalog_editor.tscn`) to avoid manual edits. Click **Save & Generate** to rewrite both `skin_manager.gd` and `economy.lua`.
[Back to top](#top)
---
## Step 3: Register the Skin in Economy (Server)
Open `server/nakama/lua/economy.lua` and add a new entry to `SHOP_CATALOG_DEFS`.
### Catalog entry format
```lua
{ id = "oldpop-blue-hat", name = "Oldpop Blue Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
```
| Field | Type | Description |
|---|---|---|
| `id` | string | Must match the `SKIN_CATALOG` key in `skin_manager.gd` exactly |
| `name` | string | Display name shown in shop |
| `category` | string | `head` / `costume` / `glove` / `accessory` |
| `gold` | number | Gold coin price (0 = not sold for gold) |
| `star` | number | Star gem price (0 = not sold for stars) |
| `rarity` | string | `"Common"` / `"Uncommon"` / `"Rare"` -- cosmetic label only |
| `character` | string | Character this skin belongs to (e.g. `"Oldpop"`) |
### Existing catalog (12 items)
```
oldpop-blue-hat head 100 gold Common Oldpop
oldpop-green-hat head 100 gold Common Oldpop
oldpop-red-hat head 100 gold Common Oldpop
oldpop-yellow-hat head 100 gold Common Oldpop
oldpop-og-pant costume 0 gold Common Oldpop (free)
oldpop-grey-pant costume 150 gold Common Oldpop
oldpop-red-pant costume 150 gold Common Oldpop
oldpop-yellow-pant costume 150 gold Common Oldpop
oldpop-blue-gloves glove 75 gold Common Oldpop
oldpop-green-gloves glove 75 gold Common Oldpop
oldpop-red-gloves glove 75 gold Common Oldpop
oldpop-yellow-gloves glove 75 gold Common Oldpop
```
[Back to top](#top)
---
## Step 4 (Optional): Add Skin as Gacha Prize
Gacha-only skins are registered in `server/nakama/lua/gacha.lua` inside `GACHA_DATA.real_prize_catalog`.
### Existing gacha skins (4 items)
```lua
skin_gacha_rainbow_suit = { name = "Rainbow Suit", category = "costume", rarity = "real_prize", character = "" }
skin_gacha_dragon_hat = { name = "Dragon Hat", category = "head", rarity = "real_prize", character = "" }
skin_gacha_phantom_gloves = { name = "Phantom Gloves", category = "glove", rarity = "real_prize", character = "" }
skin_gacha_neon_acc = { name = "Neon Accessory", category = "accessory", rarity = "real_prize", character = "" }
```
Gacha skins also need a `skin_manager.gd` entry (same step 2 format) and a `skin_catalog_editor` entry so `SkinManager.apply_loadout()` can render them. The server catalog is optional -- gacha skins are not sold in the shop directly.
The gacha pulls these IDs from `GACHA_DATA.pools.real_prize`, so add your item_id there too.
```lua
pools = {
common = {"frag_common"},
uncommon = {"frag_uncommon"},
rare = {"frag_rare"},
real_prize = {
"skin_gacha_rainbow_suit",
"skin_gacha_dragon_hat",
"skin_gacha_phantom_gloves",
"skin_gacha_neon_acc",
-- add new skin here
}
},
```
[Back to top](#top)
---
## Step 5: Deploy
### Hot patch (content only) -- recommended for skins
1. Commit changes to `experimental` branch:
- `scripts/managers/skin_manager.gd`
- `server/nakama/lua/economy.lua` (if shop item)
- `server/nakama/lua/gacha.lua` (if gacha prize)
- Material `.tres` files
2. Push to `experimental`.
3. Trigger `deploy_patch.yml` via Gitea UI workflow dispatch.
- CI runs `--export-pack` to build `patch.pck`.
- CI force-pushes `patch.pck` + `version.json` to `patches` branch.
- Existing players auto-download on next boot via `GameUpdateManager`.
### Full binary release (if engine/templates changed)
Tag a version (e.g. `v2.5.0`) and push. CI builds all platform binaries and uploads to the release.
[Back to top](#top)
---
## Full Flow Diagram
```
┌──────────────────────┐
│ 1. Create Material │
│ skin_shader_generator │
│ ────────────────── │
│ Export .tres file │
└────────┬─────────────┘
v
┌──────────────────────────────┐
│ 2. Register in SkinManager │
│ skin_manager.gd │
│ ────────────────── │
│ Add SKIN_CATALOG entry │
│ (mesh slots + material) │
└────────┬─────────────────────┘
v
┌──────────────────────────────┐
│ 3. Register in Economy │
│ economy.lua │
│ ────────────────── │
│ Add SHOP_CATALOG_DEFS │
│ (price, name, category) │
└────────┬─────────────────────┘
v (optional)
┌──────────────────────────────┐
│ 4. Gacha Prize │
│ gacha.lua │
│ ────────────────── │
│ real_prize_catalog + pools │
└────────┬─────────────────────┘
v
┌──────────────────────┐
│ 5. Git Push & CI │
│ deploy_patch.yml │
│ ────────────────── │
│ patch.pck → players │
└──────────────────────┘
```
[Back to top](#top)
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Skin visible in editor but not in-game | Material path wrong or `.tres` not exported | Verify `res://` path in SKIN_CATALOG, run `--export-pack` |
| Skin purchase fails: "NotEnoughFunds" | Wallet balance insufficient | Check gold/star prices in economy.lua |
| Skin visible on all characters | `character` field wrong | Set correct character node name |
| Skin purchase fails: "ItemNotFound" | Item ID not in SHOP_CATALOG_DEFS | Add entry matching SKIN_CATALOG key |
| Player downloads patch but skin missing | econmy.lua change didn't reach server | Nakama hot-reload: restart Nakama container or wait for next restart |
| Outline shader lost on skin | next_pass not preserved | SkinManager preserves it automatically -- verify with latest `skin_manager.gd` |
[Back to top](#top)