
Godot Genre Metroidvania
- 141 installs
- 454 repo stars
- Updated July 28, 2026
- thedivergentai/gd-agentic-skills
Use godot-genre-metroidvania for development tasks
About
godot-genre-metroidvania: A skill for development. This provides functionality for development workflows.
- godot-genre-metroidvania
Godot Genre Metroidvania by the numbers
- 141 all-time installs (skills.sh)
- +8 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #2,593 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-genre-metroidvaniaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 141 |
|---|---|
| repo stars | ★ 454 |
| Last updated | July 28, 2026 |
| Repository | thedivergentai/gd-agentic-skills ↗ |
What it does
Use godot-genre-metroidvania for development tasks
Files
Genre: Metroidvania
Expert blueprint for Metroidvanias balancing exploration, progression, and backtracking rewards.
NEVER Do (Expert Anti-Patterns)
World Design & Exploration
- NEVER allow "Soft-Locks" where a player is trapped; if they enter via a one-way path ("valve"), they MUST be able to leave using current abilities. Always design fail-safe escape routes.
- NEVER create empty dead ends; if a player backtracks to a remote area, they MUST be rewarded with a collectible, lore, or currency. Empty rooms are design failures.
- NEVER make backtracking purely repetitive; as the player gains movement (Dash/Teleport), traversal through old areas MUST become faster. Open shortcuts to bypass long, early routes.
- NEVER hide the critical path without "crumbs"; use distinct Landmarks, unique lighting, or environmental storytelling to build the player's mental map.
- NEVER design abilities that serve only one purpose; strictly implement dual-use traversal and combat functionality (e.g., a "Dash" that crosses gaps and dodges attacks).
Persistence & Mapping
- NEVER forget to save persistent room state; if a player opens a chest or defeats a boss, that state MUST remain saved when they leave and return.
- NEVER load interconnected rooms synchronously via
load(); strictly useResourceLoader.load_threaded_request()for seamless transitions. - NEVER track global progression within localized room scripts; strictly use Autoload Singletons for global ability flags and world state.
- NEVER use floating-point types for grid coordinates (minimaps/fog); strictly use
Vector2ito prevent precision jitter. - NEVER manipulate the SceneTree directly from a background loading thread; strictly use
call_deferred().
Physics & Controls
- NEVER calculate jump arcs or dashes inside
_process(); strictly use_physics_process()to prevent stutter. - NEVER multiply
CharacterBody2Dvelocity bydeltabeforemove_and_slide(); the engine handles this internally. - NEVER poll
is_action_just_pressed()inside_physics_process()for buffering; strictly capture events in_unhandled_input(). - NEVER use standard strings for high-frequency ability checks; strictly use
StringName(&"dashing") for pointer-speed comparisons. - NEVER iterate through every node to broadcast updates; strictly use
SceneTree.call_group()for efficient mass communication. - NEVER delete active room/player nodes via
free(); strictly usequeue_free()to avoid segmentation faults.
---
🛠 Expert Components (scripts/)
Original Expert Patterns
- minimap_fog.gd - Grid-based fog of war that tracks visited rooms and persists via global save data.
- progression_gate_manager.gd - Central manager for ability-gated progression (Locks/Keys) and world persistence.
Modular Components
- platformer_jump_buffer.gd - Modular coyote time and jump buffering for high-fidelity movement.
- background_room_streamer.gd - Thread-safe background room preloading using
ResourceLoader. - safe_scene_switcher.gd - Deferred scene transition pattern for stable cross-room world-state switching.
- minimap_fog_revealer.gd - Vector2i-based fog-of-war clearing logic synced to player position.
- persistent_progression_system.gd - Autoload pattern for tracking global ability/collectible flags.
- ability_state_machine.gd - Optimized
StringNamepattern matching for traversal/combat states. - fast_wall_detector.gd - Direct
PhysicsServerqueries for performance-optimized wall detection. - save_station_broadcast.gd - Group-based entity resetting and healing logic on save interaction.
- decoupled_hazard_logic.gd - Interface-style pattern for generic damage interaction.
- smooth_room_camera_transition.gd - Tween-based camera limit interpolation for seamless room movement.
---
Core Loop
1. Exploration: Player explores available rooms until blocked by a "lock" (obstacle). 2. Discovery: Player finds a "key" (ability/item) or boss. 3. Acquisition: Player gains new traversal or combat ability. 4. Backtracking: Player returns to previous locks with new ability. 5. Progression: New areas open up, cycle repeats.
Skill Chain
| Phase | Skills | Purpose |
|---|---|---|
| 1. Character | godot-characterbody-2d, state-machines | Tight, responsive movement (Coyote time, buffers) |
| 2. World | godot-tilemap-mastery, level-design | Interconnected map, biomes, landmarks |
| 3. Systems | godot-save-load-systems, godot-scene-management | Persistent world state, room transitions |
| 4. UI | ui-system, godot-inventory-system | Map system, inventory, HUD |
| 5. Polish | juiciness | Effects, atmosphere, environmental storytelling |
Architecture Overview
1. Game State & Persistence
Metroidvanias require tracking the state of every collectible and boss across the entire world.
# game_state.gd (AutoLoad)
extends Node
var collected_items: Dictionary = {} # "room_id_item_id": true
var unlocked_abilities: Array[String] = []
var map_visited_rooms: Array[String] = []
func register_collectible(id: String) -> void:
collected_items[id] = true
save_game()
func has_ability(ability_name: String) -> bool:
return ability_name in unlocked_abilities2. Room Transitions
Seamless transitions are key. Use a SceneManager to handle instancing new rooms and positioning the player.
# door.gd
extends Area2D
@export_file("*.tscn") var target_scene_path: String
@export var target_door_id: String
func _on_body_entered(body: Node2D) -> void:
if body.is_in_group("player"):
SceneManager.change_room(target_scene_path, target_door_id)3. Ability System (State Machine Integration)
Abilities should be integrated into the player's State Machine.
# player_state_machine.gd
func _physics_process(delta):
if Input.is_action_just_pressed("jump") and is_on_floor():
transition_to("Jump")
elif Input.is_action_just_pressed("jump") and not is_on_floor() and GameState.has_ability("double_jump"):
transition_to("DoubleJump")
elif Input.is_action_just_pressed("dash") and GameState.has_ability("dash"):
transition_to("Dash")Key Mechanics Implementation
Map System
A grid-based or node-based map is essential for navigation.
- Grid Map: Auto-fill cells based on player position.
- Room State: Track "visited" status to reveal map chunks.
# map_system.gd
func update_map(player_pos: Vector2) -> void:
var grid_pos = local_to_map(player_pos)
if not grid_map_data.has(grid_pos):
grid_map_data[grid_pos] = VISITED
ui_map.reveal_cell(grid_pos)Ability Gating (The "Lock")
Obstacles that check for specific abilities.
# breakable_wall.gd
extends StaticBody2D
@export var required_ability: String = "super_missile"
func take_damage(amount: int, ability_type: String) -> void:
if ability_type == required_ability:
destroy()
else:
play_deflect_sound()Common Pitfalls
1. Softlocks: Ensure the player cannot get stuck in an area without the ability to leave. Design "valves" (one-way drops) carefully. 2. Backtracking Tedium: Make backtracking interesting by changing enemies, opening shortcuts, or making traversal faster with new abilities. 3. Empty Rewards: Every dead end should have a reward (health upgrade, lore, currency). 4. Lost Players: Use visual landmarks and environmental storytelling to guide players without explicit markers (e.g., "The Statue Room").
Godot-Specific Tips
- Camera2D: Use
limit_left,limit_top, etc., to confine the camera to the current room bounds. Update these limits on room transition. - Resource Preloading: Preload adjacent rooms for seamless open-world feel if not using hard transitions.
- RemoteTransform2D: Use this to have the camera follow the player but stay detached from the player's rotation/scale.
- TileMap Layers: Use separate layers for background (parallax), gameplay (collisions), and foreground (visual depth).
Design Principles (from Dreamnoid)
- Ability Versatility: Abilities should serve both traversal and combat (e.g., a dash that dodges attacks and crosses gaps).
- Practice Rooms: Introduce a mechanic in a safe environment before testing the player in a dangerous one.
- Landmarks: Distinct visual features help players build a mental map.
- Item Descriptions: Use them for "micro-stories" to build lore without interrupting gameplay.
Advanced Exploration Systems
Professional implementation of world persistence, sequence-breaking prevention, and seamless navigation.
1. Room-Metadata Resource (Persistence)
To persist room states efficiently, create a custom Resource that holds exported variables like is_cleared or items_found. Setting the resource_local_to_scene property to true ensures that the resource is uniquely duplicated upon scene instantiation, allowing each room to maintain its own state while being serializable via ResourceSaver.
class_name RoomMetadata extends Resource
@export var is_cleared: bool = false
@export var collected_item_ids: Array[StringName] = []
@export var enemy_positions: Array[Vector3] = []
func save_room_state(node: Node) -> void:
# Logic to populate resource from current room state
ResourceSaver.save(self, node.scene_file_path + ".tres")2. Sequence-Breaking Protection (Ability Checks)
Prevent unintended progression by using a Singleton (Autoload) to track global player progression. Interactable objects and gates should perform safe checks against this central authority to verify prerequisites before allowing passage.
# progression_manager.gd (Autoload)
class_name ProgressionManager extends Node
var unlocked_abilities: Dictionary = {
"double_jump": false,
"dash": false,
"wall_slide": false
}
func check_gate(ability_name: String) -> bool:
return unlocked_abilities.get(ability_name, false)
func unlock_ability(id: String) -> void:
if unlocked_abilities.has(id):
unlocked_abilities[id] = true3. Fast-Travel Logic
Implement fast-travel by utilizing ResourceLoader to asynchronously load target room scenes. This avoids main-thread hitches and allows for smooth transitions between distant points in the interconnected world.
class_name FastTravelSystem extends Node
func travel_to_node(scene_path: String, spawn_id: StringName) -> void:
# Load the packed scene resource
var room_scene := ResourceLoader.load(scene_path) as PackedScene
if room_scene:
# Cache the spawn ID for the next scene's _ready() call
GlobalState.target_spawn_id = spawn_id
get_tree().change_scene_to_packed(room_scene)Architectural Tip: For "Rooms", use the resource_local_to_scene flag on your metadata resource to ensure that instanced rooms don't share data accidentally, which is critical for unique item pickups.
Reference
- Master Skill: godot-master
Reference
- Master Skill: godot-master
# ability_state_machine.gd
extends Node
class_name AbilityStateMachine
# Player Ability State Machine
# Uses Godot 4's high-speed Enum/StringName matching for traversal states.
@export var current_state: StringName = &"idle"
func _physics_process(_delta: float) -> void:
# Uses advanced pattern matching for fast state evaluation.
match current_state:
&"idle", &"running":
_process_basic_locomotion()
&"dashing":
_process_dash_physics()
&"wall_sliding":
_process_wall_slide()
_:
# Log unknown states instead of failing silently.
push_warning("Unexpected player state: ", current_state)
func _process_basic_locomotion() -> void: pass
func _process_dash_physics() -> void: pass
func _process_wall_slide() -> void: pass
class_name AbilityResource
extends Resource
## Expert Ability Logic (Godot 4.6).
## Resource-based ability definitions and fly-in UI logic.
@export var id: StringName = &"double_jump"
@export var name: String = "Double Jump"
@export var icon: Texture2D
func trigger_notification(ui_panel: Control) -> void:
# Fly-in effect for discovery
var t = ui_panel.create_tween().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
t.tween_property(ui_panel, "position:x", 20, 0.5)
t.tween_interval(2.0)
t.tween_property(ui_panel, "position:x", -300, 0.4)
## [SKILL NOTICE]: Use 'duplicate(true)' if abilities have mutable
## power levels to prevent modifying the source .tres file.
# background_room_streamer.gd
extends Node
class_name BackgroundRoomStreamer
# Background Room Streaming
# Prevents lag spikes during room transitions by threading the load.
func preload_room(path: String) -> void:
# Requests the room scene to be loaded on a background thread.
ResourceLoader.load_threaded_request(path)
func fetch_loaded_room(path: String) -> PackedScene:
# Retrieves the loaded scene safely without stalling the main thread.
# Returns null if not ready yet.
if ResourceLoader.load_threaded_get_status(path) == ResourceLoader.THREAD_LOAD_LOADED:
return ResourceLoader.load_threaded_get(path) as PackedScene
return null
# decoupled_hazard_logic.gd
extends Area2D
class_name DecoupledHazardLogic
# Decoupled Hazard Damage (Duck Typing Pattern)
# Interacts with colliding bodies safely without strict class requirements.
@export var damage_value: int = 15
func _on_body_entered(body: Node) -> void:
# Safely checks if the body has a combat/health component method.
if body.has_method(&"take_damage"):
# Pattern: Pass damage and optional source metadata.
body.take_damage(damage_value)
elif body.has_method(&"on_hazard_collision"):
body.on_hazard_collision(self)
# fast_wall_detector.gd
extends Node2D
class_name FastWallDetector
# Physics-Based Wall Detection (Raycasting)
# Uses nodeless raycasting via DirectSpaceState for optimal performance.
func is_touching_wall(offset: Vector2) -> bool:
var space_state := get_world_2d().direct_space_state
# Create ray query from current position toward the offset target length.
var target = global_position + offset
var query := PhysicsRayQueryParameters2D.create(global_position, target)
# EXTREMELY IMPORTANT: Exclude the parent node (Player) to prevent self-collision.
if get_parent() is CollisionObject2D:
query.exclude = [get_parent().get_rid()]
var result := space_state.intersect_ray(query)
return not result.is_empty()
extends Node
## Expert Metroidvania Persistence (Godot 4.6).
## Global state for doors, items, and save rooms.
var opened_doors: Dictionary = {} # StringName -> bool
var collected_items: Array[StringName] = []
var visited_cells: Dictionary = {} # Vector2i -> bool
const SAVE_PATH = "user://savestate.json"
func register_door(id: StringName, open: bool) -> void:
opened_doors[id] = open
func is_door_open(id: StringName) -> bool:
return opened_doors.get(id, false)
func save_world() -> void:
var f = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
var data = {
"doors": opened_doors,
"items": collected_items,
"fog": visited_cells
}
f.store_line(JSON.stringify(data))
## [SKILL NOTICE]: Use 'StringName' for IDs to minimize memory
## and 'JSON' for human-readable save-game debugging.
class_name MinimapFog
extends SubViewport
## Expert Minimap Fog (Godot 4.6).
## TileMapLayer-based discovery and fog-of-war.
@export var fog_layer: TileMapLayer
@export var player: Node2D
func _physics_process(_delta: float) -> void:
if not player or not fog_layer: return
var pos = fog_layer.local_to_map(player.global_position)
_reveal_radius(pos, 2)
func _reveal_radius(center: Vector2i, radius: int) -> void:
for x in range(-radius, radius + 1):
for y in range(-radius, radius + 1):
var cell = center + Vector2i(x, y)
# Erase black fog tile
fog_layer.set_cell(cell, -1)
## [SKILL NOTICE]: Use 'TileMapLayer' (4.3+) for best performance.
## Erasing cells with '-1' ID is faster than changing tile visibility.
# minimap_fog_revealer.gd
extends Node2D
class_name MinimapFogRevealer
# Minimap Fog of War Reveal (TileMap Based)
# Efficiently clears fog cells based on player world coordinates.
@export var fog_layer: TileMapLayer
func reveal_area(player_global_pos: Vector2) -> void:
if not fog_layer: return
# Translates global world coordinates to TileMap grid coordinates (Vector2i).
# NEVER use floating-point types for grid-logic mapping.
var map_coords: Vector2i = fog_layer.local_to_map(fog_layer.to_local(player_global_pos))
# Erases the fog tile at the player's core location.
fog_layer.erase_cell(map_coords)
# Reveal neighbors for a larger visibility radius.
for neighbor in fog_layer.get_surrounding_cells(map_coords):
fog_layer.erase_cell(neighbor)
# godot-master/scripts/metroidvania_minimap_fog.gd
extends Node2D
## Minimap Fog Expert Pattern
## Grid-based fog of war that saves visited chunks/rooms.
class_name MinimapFog
@export var tile_map: TileMapLayer # The minimap visual layer
@export var target: Node2D # Player to track
@export var reveal_radius: int = 1 # Tiles around player
@export var hidden_tile_id: int = 0 # Tile index for 'fog'
@export var revealed_tile_id: int = -1 # Tile index for 'empty/revealed'
# State
var _visited_cells: Dictionary = {}
func _ready() -> void:
if not tile_map or not target:
set_process(false)
return
func _process(_delta: float) -> void:
var player_cell = tile_map.local_to_map(target.global_position)
# Reveal circular area
for x in range(-reveal_radius, reveal_radius + 1):
for y in range(-reveal_radius, reveal_radius + 1):
if Vector2(x, y).length() <= reveal_radius:
var cell = player_cell + Vector2i(x, y)
reveal_cell(cell)
func reveal_cell(cell: Vector2i) -> void:
if cell in _visited_cells:
return
# Mark visited
_visited_cells[cell] = true
# Update Visuals
# Option A: Clear 'Fog' tile
if revealed_tile_id == -1:
tile_map.erase_cell(cell)
# Option B: Set 'Revealed' tile
else:
tile_map.set_cell(cell, revealed_tile_id, Vector2i.ZERO)
func get_save_data() -> Dictionary:
# Convert Vector2i keys to String for JSON serialization
var save_dict = {}
for cell in _visited_cells:
save_dict["%d,%d" % [cell.x, cell.y]] = true
return save_dict
func load_save_data(data: Dictionary) -> void:
_visited_cells.clear()
for key in data:
var parts = key.split(",")
var cell = Vector2i(int(parts[0]), int(parts[1]))
reveal_cell(cell)
## EXPERT USAGE:
## Assign a TileMapLayer used as an overlay. Fill it with 'Fog' tiles.
## As player moves, fog is erased/replaced.
# persistent_progression_system.gd
extends Node
# Persistent Progression Autoload (Singleton Pattern)
# Tracks unlocked abilities across room transitions using Signal architecture.
signal ability_unlocked(ability_name: StringName)
# Use StringName for optimized dictionary keys.
var _unlocked_abilities: Dictionary[StringName, bool] = {
&"double_jump": false,
&"wall_climb": false,
&"dash": false
}
func unlock_ability(ability: StringName) -> void:
_unlocked_abilities[ability] = true
ability_unlocked.emit(ability)
func has_ability(ability: StringName) -> bool:
# Safe retrieval with fallback.
return _unlocked_abilities.get(ability, false)
# platformer_jump_buffer.gd
extends CharacterBody2D
class_name PlatformerJumpBuffer
# Modular Jump Buffering & Coyote Time
# Ensures responsive feel by allowing jumps slightly before landing or after falling.
const JUMP_VELOCITY := -400.0
const COYOTE_TIME_MAX := 0.15
const JUMP_BUFFER_MAX := 0.1
var _coyote_timer := 0.0
var _jump_buffer_timer := 0.0
func _unhandled_input(event: InputEvent) -> void:
# Capture jump input outside physics tick for frame-perfect buffering.
if event.is_action_pressed(&"jump"):
_jump_buffer_timer = JUMP_BUFFER_MAX
get_viewport().set_input_as_handled()
func _physics_process(delta: float) -> void:
if is_on_floor():
_coyote_timer = COYOTE_TIME_MAX
else:
_coyote_timer -= delta
velocity += get_gravity() * delta
_jump_buffer_timer -= delta
# Check if both timers are valid to execute jump.
if _jump_buffer_timer > 0.0 and _coyote_timer > 0.0:
velocity.y = JUMP_VELOCITY
_jump_buffer_timer = 0.0
_coyote_timer = 0.0
move_and_slide()
# godot-master/scripts/metroidvania_progression_gate_manager.gd
extends Node
## Progression Gate Manager
## Tracks persistent world state and ability unlocks using a singleton pattern (AutoLoad).
class_name ProgressionGateManager
signal ability_unlocked(ability_name: String)
signal gate_opened(gate_id: String)
# Persistent Data
var unlocked_abilities: Dictionary = {}
var opened_gates: Dictionary = {}
func _ready() -> void:
# In a full game, load this from SaveFile
pass
func grant_ability(ability_name: String) -> void:
if not has_ability(ability_name):
unlocked_abilities[ability_name] = true
ability_unlocked.emit(ability_name)
print("Ability Unlocked: %s" % ability_name)
func has_ability(ability_name: String) -> bool:
return unlocked_abilities.get(ability_name, false)
func open_gate(gate_id: String) -> void:
if not is_gate_open(gate_id):
opened_gates[gate_id] = true
gate_opened.emit(gate_id)
func is_gate_open(gate_id: String) -> bool:
return opened_gates.get(gate_id, false)
func reset_progress() -> void:
unlocked_abilities.clear()
opened_gates.clear()
## EXPERT USAGE:
## access via 'ProgressionGateManager' autoload.
## check `if ProgressionGateManager.has_ability("DoubleJump")` in Player.
# safe_scene_switcher.gd
extends Node
class_name SafeSceneSwitcher
# Safe Deferred Scene Transitions
# Prevents engine crashes by ensuring scene changes happen between frames.
func goto_room(path: String) -> void:
# Pattern: ALWAYS defer scene switches to avoid flushing nodes mid-execution.
call_deferred(&"_deferred_goto_room", path)
func _deferred_goto_room(path: String) -> void:
# Safely dispose of previous world before switching.
if get_tree().current_scene:
get_tree().current_scene.free()
var next_scene := ResourceLoader.load(path) as PackedScene
if next_scene:
var instance := next_scene.instantiate()
get_tree().root.add_child(instance)
get_tree().current_scene = instance
# save_station_broadcast.gd
extends Area2D
class_name SaveStationBroadcast
# Broadcasting Save Station Events
# Uses Godot groups to reset enemies and world state without manual iteration.
func _on_body_entered(body: Node2D) -> void:
if body.is_in_group(&"player"):
_trigger_save_routine(body)
func _trigger_save_routine(player: Node2D) -> void:
# 1. Broadly reset all entities in the 'enemies' group.
get_tree().call_group(&"enemies", &"respawn")
# 2. Heal the player via duck-typing to avoid hard dependencies.
if player.has_method(&"heal_to_full"):
player.call(&"heal_to_full")
# 3. Trigger persistent save logic.
# SaveManager.save_game()
pass
# smooth_room_camera_transition.gd
extends Camera2D
class_name SmoothRoomCameraTransition
# Smooth Room Camera Transition (Tweening)
# Interpolates camera limits to snap to new room borders without visual snapping.
var _active_tween: Tween
func transition_to_bounds(new_limits: Rect2) -> void:
# Clean up any existing transition.
if _active_tween:
_active_tween.kill()
_active_tween = create_tween().set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
# Smoothly animate the limits. Note: limits must be integers.
_active_tween.tween_property(self, ^"limit_left", int(new_limits.position.x), 0.5)
_active_tween.tween_property(self, ^"limit_right", int(new_limits.end.x), 0.5)
_active_tween.tween_property(self, ^"limit_top", int(new_limits.position.y), 0.5)
_active_tween.tween_property(self, ^"limit_bottom", int(new_limits.end.y), 0.5)