
Godot Master
- 2.2k installs
- 454 repo stars
- Updated July 28, 2026
- thedivergentai/gd-agentic-skills
godot-master is an agent skill that expert patterns for 2d animation in godot using animatedsprite2d and skeletal cutout rigs. use when implementing sprite frame animations, procedural animation (squash/stretch), cutout
About
godot-master is an agent skill from thedivergentai/gd-agentic-skills that expert patterns for 2d animation in godot using animatedsprite2d and skeletal cutout rigs. use when implementing sprite frame animations, procedural animation (squash/stretch), cutout bone hierarchies. # 2D Animation Expert-level guidance for frame-based and skeletal 2D animation in Godot. ## NEVER Do - **NEVER use AnimatedTexture** — This class is deprecated, highly inefficient in modern renderers, and may be removed in future Godot versions. Use AnimatedSprite2D or AnimationPlayer instead. - **NEVER allow Tweens to fight over the same proper Developers invoke godot-master during operate/infra work for cloud & infrastructure tasks. The skill documents triggers, prerequisites, and step-by-step workflows grounded in SKILL.md. Compatible with Claude Code, Cursor, and Codex agent runtimes that load marketplace skills. Review the Security Audits panel on this listing before installing in production environments.
- Expert-level guidance for frame-based and skeletal 2D animation in Godot.
- NEVER use `animation_finished` for looping animations** — The signal only fires on non-looping animations. Use `animatio
- NEVER forget to cache `@onready var anim_sprite`** — The node lookup getter is surprisingly slow in hot paths like `_phy
- NEVER mix AnimationPlayer tracks with code-driven AnimatedSprite2D** — Choose one animation authority per sprite. Mixing
- MANDATORY**: Read the appropriate script before implementing the corresponding pattern.
Godot Master by the numbers
- 2,206 all-time installs (skills.sh)
- +229 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #171 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
godot-master capabilities & compatibility
- Capabilities
- expert level guidance for frame based and skelet · never use `animation_finished` for looping anima · never forget to cache `@onready var anim_sprite` · never mix animationplayer tracks with code drive · mandatory**: read the appropriate script before
- Use cases
- orchestration
What godot-master says it does
Expert-level guidance for frame-based and skeletal 2D animation in Godot.
- **NEVER use AnimatedTexture** — This class is deprecated, highly inefficient in modern renderers, and may be removed in future Godot versions. Use AnimatedSprite2D or AnimationPlayer instead.
- **NEVER use `animation_finished` for looping animations** — The signal only fires on non-looping animations. Use `animation_looped` instead for loop detection.
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-masterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.2k |
|---|---|
| repo stars | ★ 454 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | thedivergentai/gd-agentic-skills ↗ |
What it does
Expert patterns for 2D animation in Godot using AnimatedSprite2D and skeletal cutout rigs. Use when implementing sprite frame animations, procedural animation (squash/stretch), cutout bone hierarchies
Who is it for?
Developers working on cloud & infrastructure during operate tasks.
Skip if: Tasks outside Cloud & Infrastructure scope described in SKILL.md.
When should I use this skill?
Expert patterns for 2D animation in Godot using AnimatedSprite2D and skeletal cutout rigs. Use when implementing sprite frame animations, procedural animation (squash/stretch), cutout bone hierarchies
What you get
Completed cloud & infrastructure workflow aligned with SKILL.md steps.
- 2D animation implementation
- SpriteFrames resource setup
- Cutout rig hierarchy
Files
2D Animation
Expert-level guidance for frame-based and skeletal 2D animation in Godot.
NEVER Do
- NEVER use AnimatedTexture — This class is deprecated, highly inefficient in modern renderers, and may be removed in future Godot versions. Use AnimatedSprite2D or AnimationPlayer instead.
- NEVER allow Tweens to fight over the same property — If multiple Tweens animate the same property, the last one created forcibly takes priority. Always assign your Tween to a variable and call
kill()on the previous instance before creating a new one. - NEVER process kinematic movement outside the physics tick — If your AnimationPlayer moves a CharacterBody2D, ensure the AnimationPlayer's callback mode is set to Physics. Animating physics bodies during the Idle (render) frame breaks fixed timestep physics interpolation and causes stutter.
- NEVER use `animation_finished` for looping animations — The signal only fires on non-looping animations. Use
animation_loopedinstead for loop detection. - NEVER call `play()` and expect instant state changes — AnimatedSprite2D applies
play()on the next process frame. Calladvance(0)immediately afterplay()if you need synchronous property updates (e.g., when changing animation + flip_h simultaneously). - NEVER set `frame` directly when preserving animation progress — Setting
frameresetsframe_progressto 0.0. Useset_frame_and_progress(frame, progress)to maintain smooth transitions when swapping animations mid-frame. - NEVER forget to cache `@onready var anim_sprite` — The node lookup getter is surprisingly slow in hot paths like
_physics_process(). Always use@onready. - NEVER mix AnimationPlayer tracks with code-driven AnimatedSprite2D — Choose one animation authority per sprite. Mixing causes flickering and state conflicts.
- NEVER use paper-thin skeletons for deformation — 2D meshes require balanced vertex density. If your mesh deforms poorly, increase the vertex count near joints in the Mesh2D editor.
---
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
animation_sync.gd
Method track triggers for frame-perfect logic (SFX/VFX hitboxes), signal-driven async gameplay orchestration, and AnimationTree blend space management. Use when syncing gameplay events to animation frames.
animation_state_sync.gd
Frame-perfect state-driven animation with transition queueing - essential for responsive character animation.
shader_hook.gd
Animating ShaderMaterial uniforms via AnimationPlayer property tracks. Covers hit flash, dissolve effects, and instance uniforms for batched sprites. Use for visual feedback tied to animation states.
procedural_squash_stretch.gd
Dynamic physics-driven deformation. Provides lerp logic for smoothing out sudden impact squashes and directional stretches based on high-velocity movement.
skeleton_2d_rig_helper.gd
Programmatic rig management. Tuning FABRIK/CCDIK modification stacks and updating bone rest poses at runtime for procedural limb goal-reaching.
animation_tree_step.gd
Expert state machine control. Utilizes `Animati
2D Animation
Expert-level guidance for frame-based and skeletal 2D animation in Godot.
NEVER Do
- NEVER use AnimatedTexture — This class is deprecated, highly inefficient in modern renderers, and may be removed in future Godot versions. Use AnimatedSprite2D or AnimationPlayer instead.
- NEVER allow Tweens to fight over the same property — If multiple Tweens animate the same property, the last one created forcibly takes priority. Always assign your Tween to a variable and call
kill()on the previous instance before creating a new one. - NEVER process kinematic movement outside the physics tick — If your AnimationPlayer moves a CharacterBody2D, ensure the AnimationPlayer's callback mode is set to Physics. Animating physics bodies during the Idle (render) frame breaks fixed timestep physics interpolation and causes stutter.
- NEVER use `animation_finished` for looping animations — The signal only fires on non-looping animations. Use
animation_loopedinstead for loop detection. - NEVER call `play()` and expect instant state changes — AnimatedSprite2D applies
play()on the next process frame. Calladvance(0)immediately afterplay()if you need synchronous property updates (e.g., when changing animation + flip_h simultaneously). - NEVER set `frame` directly when preserving animation progress — Setting
frameresetsframe_progressto 0.0. Useset_frame_and_progress(frame, progress)to maintain smooth transitions when swapping animations mid-frame. - NEVER forget to cache `@onready var anim_sprite` — The node lookup getter is surprisingly slow in hot paths like
_physics_process(). Always use@onready. - NEVER mix AnimationPlayer tracks with code-driven AnimatedSprite2D — Choose one animation authority per sprite. Mixing causes flickering and state conflicts.
- NEVER use paper-thin skeletons for deformation — 2D meshes require balanced vertex density. If your mesh deforms poorly, increase the vertex count near joints in the Mesh2D editor.
---
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
animation_sync.gd
Method track triggers for frame-perfect logic (SFX/VFX hitboxes), signal-driven async gameplay orchestration, and AnimationTree blend space management. Use when syncing gameplay events to animation frames.
animation_state_sync.gd
Frame-perfect state-driven animation with transition queueing - essential for responsive character animation.
shader_hook.gd
Animating ShaderMaterial uniforms via AnimationPlayer property tracks. Covers hit flash, dissolve effects, and instance uniforms for batched sprites. Use for visual feedback tied to animation states.
procedural_squash_stretch.gd
Dynamic physics-driven deformation. Provides lerp logic for smoothing out sudden impact squashes and directional stretches based on high-velocity movement.
skeleton_2d_rig_helper.gd
Programmatic rig management. Tuning FABRIK/CCDIK modification stacks and updating bone rest poses at runtime for procedural limb goal-reaching.
animation_tree_step.gd
Expert state machine control. Utilizes AnimationNodeStateMachinePlayback.travel() to leverage the engine's internal A* pathfinding for multi-state transitions.
one_frame_sync_fix.gd
Eliminates the "One-Frame Glitch" by using advance(0) to force the engine to apply animation poses immediately alongside property changes like flip_h.
gpu_mesh_optimizer.gd
Architectural pattern for bypassing GPU fill-rate bottlenecks. Demonstrates when to convert large sprites into specialized 2D meshes to avoid transparent pixel overhead.
multimesh_swarm_anim.gd
Optimization for thousands of entities. Offloads animation logic (sine waves, flight patterns) to the GPU vertex shader to eliminate CPU node processing.
tween_lifecycle_manager.gd
Safe and memory-efficient Tween orchestration. Handles interruption cleanup and property-fight prevention in fast-paced gameplay loops.
---
AnimatedSprite2D Signals (Expert Usage)
animation_looped vs animation_finished
extends CharacterBody2D
@onready var anim: AnimatedSprite2D = $AnimatedSprite2D
func _ready() -> void:
# ✅ Correct: Use animation_looped for repeating animations
anim.animation_looped.connect(_on_loop)
# ✅ Correct: Use animation_finished ONLY for one-shots
anim.animation_finished.connect(_on_finished)
anim.play("run") # Looping animation
func _on_loop() -> void:
# Fires every loop iteration
emit_particle_effect("dust")
func _on_finished() -> void:
# Only fires for non-looping animations
anim.play("idle")frame_changed for Event Triggering
# Frame-perfect event system (attacks, footsteps, etc.)
extends AnimatedSprite2D
signal attack_hit
signal footstep
# Define event frames per animation
const EVENT_FRAMES := {
"attack": {3: "attack_hit", 7: "attack_hit"},
"run": {2: "footstep", 5: "footstep"}
}
func _ready() -> void:
frame_changed.connect(_on_frame_changed)
func _on_frame_changed() -> void:
var events := EVENT_FRAMES.get(animation, {})
if frame in events:
emit_signal(events[frame])---
Advanced Pattern: Animation State Sync
Problem: play() Timing Glitch
When updating both animation and sprite properties (e.g., flip_h + animation change), play() doesn't apply until next frame, causing a 1-frame visual glitch.
# ❌ BAD: Glitches for 1 frame
func change_direction(dir: int) -> void:
anim.flip_h = (dir < 0)
anim.play("run") # Applied NEXT frame
# Result: 1 frame of wrong animation with correct flip
# ✅ GOOD: Force immediate sync
func change_direction(dir: int) -> void:
anim.flip_h = (dir < 0)
anim.play("run")
anim.advance(0) # Force immediate update---
set_frame_and_progress() for Smooth Transitions
Use when changing animations mid-animation without visual stutter:
# Example: Skin swapping without animation reset
func swap_skin(new_skin: String) -> void:
var current_frame := anim.frame
var current_progress := anim.frame_progress
# Load new SpriteFrames resource
anim.sprite_frames = load("res://skins/%s.tres" % new_skin)
# ✅ Preserve exact animation state
anim.play(anim.animation) # Re-apply animation
anim.set_frame_and_progress(current_frame, current_progress)
# Result: Seamless skin swap mid-animation---
Expert Decision Tree: Choosing the Right Animation Tool
| Scenario | Recommended Node | Expert Insight |
|---|---|---|
| Isolated, pure frame-by-frame spritesheets | AnimatedSprite2D | Simple and effective, but cannot animate non-visual properties, manipulate transforms, or trigger external methods. |
| Cutout animations, non-visual sync, audio/particles | AnimationPlayer | Required when manipulating transforms of many child sprites, driving 2D mesh deformations, or syncing methods/particles to visual frames. |
| Complex state machines, blending, locomotion | AnimationTree | Essential for blending movement directions. Does not hold animations itself; it's a logic graph driving an underlying AnimationPlayer. |
| Procedural, dynamic, fire-and-forget UI/fx | Tween | Target values calculated at runtime. Far more lightweight than AnimationPlayer; designed to be created and discarded via script. |
| Swarms of thousands of entities (bats, fish) | MultiMeshInstance2D + Shader | Bypasses the node system entirely. Calculate sine waves/movement on the GPU vertex shader to avoid massive CPU bottlenecks. |
---
Expert Pattern: Procedural Squash & Stretch
# Physics-driven squash/stretch for game feel
extends CharacterBody2D
@onready var sprite: Sprite2D = $Sprite2D
var _base_scale := Vector2.ONE
func _physics_process(delta: float) -> void:
var prev_velocity := velocity
move_and_slide()
# Squash on landing
if not is_on_floor() and is_on_floor():
var impact_strength := clamp(abs(prev_velocity.y) / 800.0, 0.0, 1.0)
_squash_and_stretch(Vector2(1.0 + impact_strength * 0.3, 1.0 - impact_strength * 0.3))
# Stretch during jump
elif velocity.y < -200:
sprite.scale = _base_scale.lerp(Vector2(0.9, 1.1), delta * 5.0)
else:
sprite.scale = sprite.scale.lerp(_base_scale, delta * 10.0)
func _squash_and_stretch(target_scale: Vector2) -> void:
var tween := create_tween().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tween.tween_property(sprite, "scale", target_scale, 0.08)
tween.tween_property(sprite, "scale", _base_scale, 0.12)---
Cutout Animation (Bone2D Skeleton)
For complex skeletal animation, use Bone2D instead of manual Sprite2D parenting:
Skeleton Setup
Player (Node2D)
└─ Skeleton2D
├─ Bone2D (Root - Torso)
│ ├─ Sprite2D (Body)
│ └─ Bone2D (Head)
│ └─ Sprite2D (Head)
├─ Bone2D (ArmLeft)
│ └─ Sprite2D (Arm)
└─ Bone2D (ArmRight)
└─ Sprite2D (Arm)AnimationPlayer Tracks
# Key bone rotations in AnimationPlayer
# Tracks:
# - "Skeleton2D/Bone2D:rotation"
# - "Skeleton2D/Bone2D/Bone2D2:rotation" (head)
# - "Skeleton2D/Bone2D3:rotation" (arm left)Why Bone2D over manual parenting?
- Forward Kinematics (FK) and Inverse Kinematics (IK) support
- Easier to rig and weight paint
- Better integration with animation retargeting
---
Performance: SpriteFrames Optimization
# ✅ GOOD: Share SpriteFrames resource across instances
const SHARED_FRAMES := preload("res://characters/player_frames.tres")
func _ready() -> void:
anim_sprite.sprite_frames = SHARED_FRAMES
# All player instances share same resource in memory
# ❌ BAD: Each instance loads separately
func _ready() -> void:
anim_sprite.sprite_frames = load("res://characters/player_frames.tres")
# Duplicates resource in memory per instance---
Edge Case: Pixel Art Centering
# Pixel art textures can appear blurry when centered between pixels
# Solution 1: Disable centering
anim_sprite.centered = false
anim_sprite.offset = Vector2.ZERO
# Solution 2: Enable global pixel snapping (Project Settings)
# rendering/2d/snap/snap_2d_vertices_to_pixel = true
# rendering/2d/snap/snap_2d_transforms_to_pixel = trueSpriteFrames Texture Filtering
# Problem: SpriteFrames uses bilinear filtering (blurry for pixel art)
# Solution: In Import tab for each texture:
# - Filter: Nearest (for pixel art)
# - Mipmaps: Off (prevents blending at distance)
# Or set globally in Project Settings:
# rendering/textures/canvas_textures/default_texture_filter = Nearest---
Expert Techniques & Optimizations
1. Hybrid Cutout and Cel Animation
Do not limit yourself to just one style. Use AnimationPlayer to rig a 2D skeleton and animate the bones (Cutout animation), while simultaneously keyframing the texture or frame properties of specific child sprites. This allows highly efficient transform-based animation for the body, while selectively swapping hand shapes or facial expressions using traditional hand-drawn cel animation.
2. Optimizing GPU Fill Rate with 2D Meshes
Sprites with large transparent areas (like tree leaves) waste GPU fill rate. Convert a Sprite2D into a MeshInstance2D in Godot. This generates a 2D polygon that tightly hugs the opaque pixels, bypassing transparent areas. While this slightly increases vertex processing time, it massively improves GPU fill rate on complex 2D scenes.
3. Safe Tween Interruption and Looping
Game states change rapidly. Ensure Tweens are properly cleaned up before starting new ones to avoid memory leaks and conflicting animations.
extends Node2D
var _tween: Tween
func animate_damage_flash() -> void:
# Anti-pattern prevention: Always kill the existing tween before overwriting it.
if _tween:
_tween.kill()
_tween = create_tween()
# Chain tweeners and use loops for rapid, predictable animation
_tween.set_loops(3)
_tween.tween_property($Sprite2D, "modulate", Color.RED, 0.1).set_trans(Tween.TRANS_SINE)
_tween.tween_property($Sprite2D, "modulate", Color.WHITE, 0.1).set_trans(Tween.TRANS_SINE)4. Advanced State Machine Travel via AnimationTree
When using an AnimationNodeStateMachine within an AnimationTree, you do not directly "play" animations. You command the state machine to compute the shortest path (using A*) to a new state.
extends CharacterBody2D
@onready var animation_tree: AnimationTree = $AnimationTree
# Retrieve the playback object from the AnimationTree properties
@onready var state_machine: AnimationNodeStateMachinePlayback = animation_tree.get("parameters/playback")
func _ready() -> void:
# The state machine must be started before traveling
state_machine.start("idle")
func _physics_process(delta: float) -> void:
if velocity.length() > 0:
# Travel to the run state. Internal A* will seamlessly play intermediate transitions.
state_machine.travel("run")
else:
state_machine.travel("idle")---
Expert Pattern: Animation-Frame-Data-Extractor
To extract custom per-frame metadata (e.g., spawn offsets, hitbox sizes), use an AnimationPlayer in conjunction with AnimatedSprite2D. SpriteFrames is strictly a visual container; AnimationPlayer allows you to decouple visual data from logical metadata using Value Tracks or Call Method Tracks.
class_name AnimationDataExtractor extends CharacterBody2D
# 1. Define the metadata property (Value Track target)
@export var current_spawn_offset: Vector2 = Vector2.ZERO:
set(value):
current_spawn_offset = value
_update_spawn_point()
@onready var anim_player: AnimationPlayer = $AnimationPlayer
@onready var spawn_marker: Marker2D = $SpawnMarker
func _ready() -> void:
# Playing via AnimationPlayer updates 'current_spawn_offset' on keyed frames
anim_player.play("attack_shoot")
func _update_spawn_point() -> void:
spawn_marker.position = current_spawn_offset
# 2. Call Method Track Implementation
# Use this to pass complex arguments directly to a function on a specific frame
func spawn_projectile(damage: int, specific_offset: Vector2) -> void:
var projectile = PROJECTILE_SCENE.instantiate()
projectile.damage = damage
projectile.position = global_position + specific_offset
get_parent().add_child(projectile)---
Expert Pattern: Skeletal-IK-2D (Procedural Foot Placement)
For procedural limb positioning (e.g., planting feet on slopes), use Godot's built-in 2D skeletal modification system. The SkeletonModification2DTwoBoneIK is the elite choice for limbs as it is more lightweight than full FABRIK solvers.
Setup
1. Add a SkeletonModificationStack2D to your Skeleton2D. 2. Add a SkeletonModification2DTwoBoneIK to the stack. 3. Assign the target bones (e.g., UpperLeg and LowerLeg). 4. Point the target_nodepath to a Marker2D (IK Target).
class_name ProceduralWalker2D extends Node2D
@onready var skeleton: Skeleton2D = $Skeleton2D
@onready var ik_target_left_foot: Marker2D = $IKTargets/LeftFootTarget
@onready var floor_raycast: RayCast2D = $RayCasts/LeftFootRay
func _ready() -> void:
# Ensure modification stack is enabled
var mod_stack: SkeletonModificationStack2D = skeleton.get_modification_stack()
if mod_stack:
mod_stack.enabled = true
mod_stack.enable_all_modifications(true)
func _physics_process(_delta: float) -> void:
floor_raycast.force_raycast_update()
if floor_raycast.is_colliding():
# Move IK target to the exact collision point
ik_target_left_foot.global_position = floor_raycast.get_collision_point()
else:
# Fallback to resting position
ik_target_left_foot.position = Vector2(0, 50)---
Expert Pattern: Sprite-Sheet-Memory-Manager
Dynamically load and unload high-resolution textures to optimize VRAM usage. Leverage ResourceLoader for asynchronous loading and RefCounted for automatic memory purging.
class_name SpriteSheetMemoryManager extends Node
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
var _pending_path: String = ""
var _target_anim: StringName = &"heavy_attack"
func load_high_res_anim(path: String) -> void:
_pending_path = path
# 1. Start background loading to prevent frame stutter
ResourceLoader.load_threaded_request(_pending_path)
set_process(true)
func _process(_delta: float) -> void:
# 2. Check loading status
var status = ResourceLoader.load_threaded_get_status(_pending_path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
var tex: Texture2D = ResourceLoader.load_threaded_get(_pending_path)
_apply_to_frames(tex)
set_process(false)
func _apply_to_frames(tex: Texture2D) -> void:
var frames: SpriteFrames = animated_sprite.sprite_frames
if not frames.has_animation(_target_anim):
frames.add_animation(_target_anim)
# 3. Inject frame dynamically
frames.add_frame(_target_anim, tex)
animated_sprite.play(_target_anim)
func unload_high_res_anim() -> void:
var frames: SpriteFrames = animated_sprite.sprite_frames
if frames.has_animation(_target_anim):
# 4. Breaking the reference to the Texture2D frees it from RAM
frames.clear(_target_anim)Reference
- Master Skill: godot-master
2D Physics
Expert guidance for collision detection, triggers, and raycasting in Godot 2D.
NEVER Do
- NEVER scale `CollisionShape2D` nodes — Use the shape handles in the editor, NOT the Node2D scale property. Scaling causes unpredictable physics behavior and incorrect collision normals [12].
- NEVER confuse `collision_layer` with `collision_mask` — Layer = "What AM I?", Mask = "What do I DETECT?". Setting both to the same value is usually wrong [13].
- NEVER multiply velocity by delta when using `move_and_slide()` —
move_and_slide()automatically includes timestep. Only multiply gravity/acceleration by delta [14]. - NEVER forget `force_raycast_update()` for manual mid-frame raycasts — Raycasts update once per physics frame. If you change target_position, you MUST force an update [15].
- NEVER use `get_overlapping_bodies()` every frame — It is expensive. Cache results with
body_entered/body_exitedsignals instead [16]. - NEVER modify `RigidBody2D` state directly in `_process` — Use
_integrate_forces()for safe, synchronized access toPhysicsDirectBodyState2D[17, 411]. - NEVER move `PhysicsBody2D` nodes in `_process()` — Use
_physics_process(). Moving bodies outside the physics step causes stutter and unreliable collision detection. - NEVER use `RigidBody2D` for 1000+ simple entities — Use
PhysicsServer2Dto bypass node overhead for massive performance gains (Swarms/Bullets) [18, 397]. - NEVER use `Area2D` for high-frequency blocking (Bullets) — Area signals can be delayed. Use
move_and_collide()orShapeCast2Dfor frame-perfect results [19]. - NEVER ignore 'Physics Jitter' on high-refresh monitors — Enable Physics Interpolation to prevent micro-stutter in motion [21, 400].
- NEVER scale collision shapes directly at runtime — It causes major instability. Resize the shape resource (size/radius) instead.
- NEVER use `set_deferred` for immediate physics transform logic — It happens at the end of the frame. Use
force_raycast_update()orPhysicsServer2Dinstead. - NEVER leave Continuous CD (CCD) enabled for slow objects — It adds significant CPU overhead. Reserve it for high-speed projectiles to prevent tunneling.
- NEVER use a single collision layer for all tiles/entities — Separate layers (Ground, Walls, Enemies) to allow selective filtering via masks.
- NEVER forget to free `PhysicsServer2D` RIDs manually — They are not garbage collected and will leak memory permanently.
---
Available Scripts
MANDATORY: Read the script matching your use case before implementation.
collision_setup.gd
Programmatic layer/mask management with named layer constants and debug visualization.
physics_query_cache.gd
Frame-based caching for PhysicsDirectSpaceState2D queries - eliminates redundant expensive queries.
custom_physics.gd
Custom physics integration patterns for CharacterBody2D. Covers non-standard gravity, forces, and manual stepping. Use for non-standard physics behavior.
physics_queries.gd
PhysicsDirectSpaceState2D query patterns for raycasting, point queries, and shape queries. Use for line-of-sight, ground detection, or area scanning.
physics_server_swarm.gd
Low-level PhysicsServer2D usage for thousands of moving objects. Bypasses node overhead for massive performance gains in bullet hells or swarms.
substepping_logic.gd
Manual physics sub-stepping for high-velocity projectiles. Ensures frame-perfect collision for objects moving faster than the physics tick.
safe_rigidbody_state.gd
Thread-safe RigidBody2D modification using _integrate_forces. Ideal for teleporting bodies or applying custom impulses without jitter.
physics_direct_query.gd
Lighweight environment sensing using PhysicsDirectSpaceState2D. Performs ray queries without the overhead of RayCast2D nodes.
collision_bitmask_helper.gd
Clean architectural pattern for managing complex collision layers/masks using bitwise Enums and helpers.
raycast_vision_stack.gd
Optimized multicasting vision system for AI. Reuses a single RayCast2D to check multiple angles in one physics frame.
shapecast_aoe.gd
Robust AOE detection using ShapeCast2D. Provides instant collision information without the signal-lag of Area2D.
custom_gravity_override.gd
Logic for localized gravity zones (Water, Space, Wind) and manual character-weight simulation.
collision_debouncer.gd
Expert pattern for preventing signal spam when multi-shape bodies enter triggers.
jitter_interpolation_fix.gd
Standard configuration and runtime adjustments to ensure smooth character movement on high-refresh-rate monitors.
physics_server_direct_body.gd
Direct PhysicsServer2D RID management for peak performance in massive physics simulations.
move_and_collide_precision.gd
Expert bounce and friction logic implementation for precision-critical movement.
continuous_collision_detection.gd
Advanced CCD management for preventing bullet tunneling at extremely high velocities.
performance_batch_mover.gd
Optimized batch movement for multiple static/animatable bodies using riders-aware logic.
---
Collision Layers & Masks (Bitmask Deep Dive)
The Mental Model
# collision_layer (32 bits): What broadcast channels am I transmitting on?
# collision_mask (32 bits): What broadcast channels am I listening to?
# Example: Player vs Enemy
# Player:
# layer = 0b0001 (Channel 1: "I am a player")
# mask = 0b0110 (Channels 2+3: "I listen for enemies and walls")
# Enemy:
# layer = 0b0010 (Channel 2: "I am an enemy")
# mask = 0b0101 (Channels 1+3: "I listen for players and walls")Bitmask Helpers
# ✅ GOOD: Use helper functions for clarity
func setup_player_collision() -> void:
# I am layer 1
set_collision_layer_value(1, true)
# I detect layers 2 (enemies) and 3 (world)
set_collision_mask_value(2, true)
set_collision_mask_value(3, true)
# ✅ GOOD: Bit shift for programmatic layer math
func enable_layers(base_layer: int, count: int) -> void:
var mask := 0
for i in range(count):
mask |= (1 << (base_layer + i - 1))
collision_mask = mask
# ❌ BAD: Hardcoded bitmasks without documentation
collision_mask = 0b110110 # What does this mean?!Common Patterns
# Pattern: Projectile that hits enemies but ignores other projectiles
# projectile.gd
extends Area2D
func _ready() -> void:
set_collision_layer_value(4, true) # Layer 4: "Projectiles"
set_collision_mask_value(2, true) # Mask Layer 2: "Enemies"
# Result: Projectiles don't collide with each other
# Pattern: One-way platform (player can jump through from below)
# platform.gd
extends StaticBody2D
@export var one_way := true
func _ready() -> void:
set_collision_layer_value(3, true) # Layer 3: "World"
if one_way:
# Use Area2D + collision exemption instead
# (Standard one-way platforms use different technique)
pass---
Area2D Expert Patterns
Problem: Duplicate Triggers on Multi-CollisionShape
# ❌ BAD: body_entered fires MULTIPLE times if Area2D has multiple shapes
extends Area2D
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
print("Entered!") # Fires 3x if Area has 3 CollisionShapes!
# ✅ GOOD: Track unique bodies with Set
extends Area2D
var _active_bodies := {} # Use dict as Set
func _ready() -> void:
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _on_body_entered(body: Node2D) -> void:
if body not in _active_bodies:
_active_bodies[body] = true
print("First entrance!") # Fires once
func _on_body_exited(body: Node2D) -> void:
_active_bodies.erase(body)Damage-Over-Time with Immunity Frames
# lava_zone.gd
extends Area2D
@export var damage_per_tick := 5
@export var tick_rate := 0.5 # Damage every 0.5s
var _damage_timers := {} # body -> time_until_next_tick
func _ready() -> void:
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _on_body_entered(body: Node2D) -> void:
if body.has_method("take_damage"):
_damage_timers[body] = 0.0 # Immediate first tick
func _on_body_exited(body: Node2D) -> void:
_damage_timers.erase(body)
func _process(delta: float) -> void:
for body in _damage_timers.keys():
_damage_timers[body] -= delta
if _damage_timers[body] <= 0.0:
body.take_damage(damage_per_tick)
_damage_timers[body] = tick_rate---
RayCast2D Advanced Usage
Dynamic Raycast Rotation
# enemy_vision.gd - Enemy looks toward player
extends CharacterBody2D
@onready var vision_ray: RayCast2D = $VisionRay
func can_see_target(target: Node2D) -> bool:
var direction := global_position.direction_to(target.global_position)
vision_ray.target_position = direction * 300 # 300px range
vision_ray.force_raycast_update() # CRITICAL: Update mid-frame
if vision_ray.is_colliding():
return vision_ray.get_collider() == target
return falseMultipa Raycasts for Ledge Detection
# platformer_controller.gd
extends CharacterBody2D
@onready var floor_front: RayCast2D = $FloorCheckFront
@onready var floor_back: RayCast2D = $FloorCheckBack
func at_ledge() -> bool:
return floor_front.is_colliding() and not floor_back.is_colliding()
func _physics_process(delta: float) -> void:
if at_ledge() and is_on_floor():
# Enemy AI: Turn around at ledges
velocity.x *= -1Raycast Exclusions
# Ignore specific bodies (e.g., self)
func _ready() -> void:
$RayCast2D.add_exception(self)
$RayCast2D.add_exception($Weapon) # Ignore attached weapon collider
# Reset exclusions
$RayCast2D.clear_exceptions()---
PhysicsDirectSpaceState2D (Manual Queries)
Point Query: Click Detection
# Check if mouse click hits any physics body
func get_body_at_mouse() -> Node2D:
var mouse_pos := get_global_mouse_position()
var space := get_world_2d().direct_space_state
var query := PhysicsPointQueryParameters2D.new()
query.position = mouse_pos
query.collide_with_areas = false
query.collision_mask = 0b11111111 # All layers
var results := space.intersect_point(query, 1) # Max 1 result
if results.is_empty():
return null
return results[0].colliderShape Cast: AOE Attack
# AOE damage in circle around player
func damage_nearby_enemies(center: Vector2, radius: float, damage: int) -> void:
var space := get_world_2d().direct_space_state
var query := PhysicsShapeQueryParameters2D.new()
var circle := CircleShape2D.new()
circle.radius = radius
query.shape = circle
query.transform = Transform2D(0.0, center)
query.collision_mask = 0b0010 # Layer 2: Enemies
var hits := space.intersect_shape(query)
for hit in hits:
var enemy: Node2D = hit.collider
if enemy.has_method("take_damage"):
enemy.take_damage(damage)Ray Cast: Instant Hit Weapon
# Hitscan weapon (no projectile)
func fire_hitscan_weapon(from: Vector2, direction: Vector2, max_range: float) -> void:
var space := get_world_2d().direct_space_state
var query := PhysicsRayQueryParameters2D.create(from, from + direction * max_range)
query.exclude = [self]
query.collision_mask = 0b0010 # Enemies
var result := space.intersect_ray(query)
if result:
var hit_enemy: Node2D = result.collider
var hit_point: Vector2 = result.position
spawn_hit_effect(hit_point)
if hit_enemy.has_method("take_damage"):
hit_enemy.take_damage(25)---
Decision Tree: Collision Detection Methods
| Use Case | Method | Why |
|---|---|---|
| Continuous trigger zone | Area2D + signals | Memory of what's inside, signals are efficient |
| One-time pickup (coin) | Area2D + queue_free() on enter | Simple, automatic cleanup |
| Line-of-sight check | RayCast2D | Efficient, built-in |
| Click-to-select units | PhysicsPointQueryParameters2D | Single query, no permanent node |
| AOE spell | PhysicsShapeQueryParameters2D | One-shot query, flexible shape |
| Instant-hit weapon | PhysicsRayQueryParameters2D | Hitscan, no projectile physics |
| Platformer ground check | RayCast2D or raycast down | Precise ledge detection |
---
Edge Cases
Collision During _ready()
# ❌ BAD: Raycasts don't work in _ready() (physics not initialized)
func _ready() -> void:
if $RayCast2D.is_colliding(): # Always false!
print("Hit something")
# ✅ GOOD: Wait for physics frame
func _ready() -> void:
await get_tree().physics_frame
if $RayCast2D.is_colliding():
print("Hit something")Area2D Not Detecting CharacterBody2D
# Problem: CharacterBody2D has collision_layer = 0 by default
# Solution: Explicitly set layer
# character.gd
func _ready() -> void:
collision_layer = 0b0001 # Layer 1: PlayerRaycast Hitting Backfaces
# Raycasts hit both front and back of collision shapes
# To raycast one-way (front only), use Area2D monitoring---
Performance
# ✅ GOOD: Disable raycasts when not needed
func _ready() -> void:
$OptionalRaycast.enabled = false
func check_vision() -> void:
$OptionalRaycast.enabled = true
$OptionalRaycast.force_raycast_update()
var sees_player := $OptionalRaycast.is_colliding()
$OptionalRaycast.enabled = false
return sees_player
# ❌ BAD: Always-on raycasts for rarely-used checks
# Leave RayCast2D.enabled = true for vision checks once per second---
Expert Techniques & Optimizations
1. Physics-Server-Batching (Low-Level Swarms)
For massive simulations (e.g., thousands of projectiles), avoid the overhead of the SceneTree by using PhysicsServer2D directly. This allows you to batch movement and collision updates in a single loop, significantly reducing CPU usage by bypassing node-based lifecycle overhead.
class_name PhysicsBatchManager extends Node
## Manages thousands of physics bodies directly via PhysicsServer2D.
var _bodies: Array[RID] = []
func create_bullet_swarm(count: int) -> void:
for i in range(count):
var body := PhysicsServer2D.body_create()
PhysicsServer2D.body_set_mode(body, PhysicsServer2D.BODY_MODE_KINEMATIC)
PhysicsServer2D.body_set_space(body, get_world_2d().space)
_bodies.append(body)
func _physics_process(_delta: float) -> void:
# Batch update all body transforms.
for body in _bodies:
var current_transform := PhysicsServer2D.body_get_state(body, PhysicsServer2D.BODY_STATE_TRANSFORM)
var next_transform := current_transform.translated(Vector2.RIGHT * 5.0)
PhysicsServer2D.body_set_state(body, PhysicsServer2D.BODY_STATE_TRANSFORM, next_transform)2. Multi-Shape-Sync (Compound RID Bodies)
A single physics body can consist of multiple shapes (e.g., a shield and a character). To sync these shapes dynamically without creating multiple nodes, use PhysicsServer2D.body_add_shape(). This is ideal for characters with dynamic equipment or vehicles with complex, non-uniform collision volumes.
class_name CompoundBodySync extends Node2D
## Synchronizes multiple shapes within a single low-level physics body.
var _body: RID
var _shapes: Array[RID] = []
func _ready() -> void:
_body = PhysicsServer2D.body_create()
# Add multiple collision shapes to the same body RID.
var circle := PhysicsServer2D.circle_shape_create()
PhysicsServer2D.shape_set_data(circle, 20.0)
PhysicsServer2D.body_add_shape(_body, circle, Transform2D.IDENTITY)
_shapes.append(circle)
var box := PhysicsServer2D.rectangle_shape_create()
PhysicsServer2D.shape_set_data(box, Vector2(10, 50))
PhysicsServer2D.body_add_shape(_body, box, Transform2D.IDENTITY.translated(Vector2(30, 0)))
_shapes.append(box)3. Collision-Visual-Debugger (Runtime Gizmos)
Professional debugging requires real-time visualization of collision data that isn't visible via standard debug options. Use CanvasItem._draw() to render contact points and normals extracted from KinematicCollision2D or the physics space state.
class_name CollisionVisualDebugger extends Node2D
## Renders collision normals and hit points for real-time physics debugging.
var _last_collision: KinematicCollision2D
func update_debug_info(collision: KinematicCollision2D) -> void:
_last_collision = collision
queue_redraw()
func _draw() -> void:
if not _last_collision: return
var hit_pos := to_local(_last_collision.get_position())
var normal := _last_collision.get_normal()
# Draw hit point and normal vector.
draw_circle(hit_pos, 5.0, Color.RED)
draw_line(hit_pos, hit_pos + normal * 30.0, Color.GREEN, 2.0)Reference
Related
- Master Skill: godot-master
3D Lighting
Expert guidance for realistic 3D lighting with shadows and global illumination.
NEVER Do
- NEVER use VoxelGI without setting a proper extents — Unbound VoxelGI tanks performance. Always set
sizeto tightly fit your scene. - NEVER enable shadows on every light — Each shadow-casting light is expensive. Use shadows sparingly: 1-2 DirectionalLights, ~3-5 OmniLights max.
- NEVER forget directional_shadow_mode — Default is ORTHOGONAL. For large outdoor scenes, use PARALLEL_4_SPLITS for better shadow quality at distance.
- NEVER use LightmapGI for fully dynamic scenes — Lightmaps are baked. Moving geometry won't receive updated lighting. Use VoxelGI or SDFGI instead.
- NEVER set omni_range too large — Light attenuation is quadratic. A range of 500 affects 785,000 sq units. Keep range as small as visually acceptable.
- NEVER hide a Light node using the Visible property to exclude it from a Lightmap bake — Hiding a light has no effect on the baker. You must change the light's Bake Mode to Disabled.
- NEVER use VoxelGI with paper-thin walls — VoxelGI evaluates lighting using a 3D grid. Thin walls (less than one voxel thick) will cause severe light leaking. Seal your geometry or place hidden thick MeshInstance3D blocks around the exterior.
- NEVER leave shadow bias at default for cascades — Default bias often causes Peter Panning or light leaking at split transitions. Tune bias per-light based on your scene's scale.
- NEVER bake LightmapGI without a Denoiser — Godot's baked lightmaps are noisy by default. Use OIDN or JNLM (in Project Settings) for professional results.
- NEVER use real-time SDFGI on Mobile/Compatibility renderers — It is a Forward+ exclusive feature. Use fake GI bounce lights for lower-end platforms.
- NEVER use 'Update Continuity' in ReflectionProbes for performance — Keep ReflectionProbes on 'Update Once' and trigger manual updates only when necessary.
---
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
day_night_cycle.gd
Dynamic sun position and color based on time-of-day. Handles DirectionalLight3D rotation, color temperature, and intensity curves. Use for outdoor day/night systems.
light_probe_manager.gd
VoxelGI and SDFGI management for global illumination setup.
lighting_manager.gd
Dynamic light pooling and LOD. Manages light culling and shadow toggling based on camera distance. Use for performance optimization with many lights.
volumetric_fx.gd
Volumetric fog and god ray configuration. Runtime fog density/color adjustments and light shaft setup. Use for atmospheric effects.
shadow_cascade_tuner.gd
Expert logic for adjusting DirectionalLight3D shadow split distances dynamically based on sun angle and camera tilt.
lightmap_bake_helper.gd
Advanced LightmapGI configuration pattern using Shadowmasking mode for hybrid static/dynamic shadowing.
sdfgi_probe_manager.gd
Dynamic quality scaler for real-time Global Illumination (SDFGI). Adjusts cell size and occlusion for performance/quality trade-offs.
volumetric_fog_zones.gd
Smoothly transitioning localized fog density for cave entrances or forest clearings using Tweens and Area3D triggers.
fake_gi_bounce.gd
Efficient 'Mobile-GI' pattern. Simulates light bouncing off the floor using non-shadowed directional fill lights.
environment_blender.gd
Architectural pattern for transitioning WorldEnvironment parameters (Sky, Ambient, Tonemap) during gameplay.
shadow_bias_tuner.gd
Optimization script for correcting 'Peter Panning' and 'Shadow Acne' on high-fidelity directional lights.
light_lod_optimizer.gd
Distance-based shadow and visibility culling for OmniLight3D nodes in dense environments.
reflection_probe_manager.gd
Performance-aware ReflectionProbe handling using manual 'Update Once' triggers for large environmental changes.
spotlight_projector_setup.gd
High-detail lighting using Projector textures to fake complex shadow patterns (grates, glass ripples).
---
DirectionalLight3D (Sun/Moon)
Shadow Cascades
# For outdoor scenes with camera moving from near to far
extends DirectionalLight3D
func _ready() -> void:
shadow_enabled = true
directional_shadow_mode = SHADOW_PARALLEL_4_SPLITS
# Split distances (in meters from camera)
directional_shadow_split_1 = 10.0 # First cascade: 0-10m
directional_shadow_split_2 = 50.0 # Second: 10-50m
directional_shadow_split_3 = 200.0 # Third: 50-200m
# Fourth cascade: 200m - max shadow distance
directional_shadow_max_distance = 500.0
# Quality vs performance
directional_shadow_blend_splits = true # Smooth transitionsDay/Night Cycle
# sun_controller.gd
extends DirectionalLight3D
@export var time_of_day := 12.0 # 0-24 hours
@export var rotation_speed := 0.1 # Hours per second
func _process(delta: float) -> void:
time_of_day += rotation_speed * delta
if time_of_day >= 24.0:
time_of_day -= 24.0
# Rotate sun (0° = noon, 180° = midnight)
var angle := (time_of_day - 12.0) * 15.0 # 15° per hour
rotation_degrees.x = -angle
# Adjust intensity
if time_of_day < 6.0 or time_of_day > 18.0:
light_energy = 0.0 # Night
elif time_of_day < 7.0:
light_energy = remap(time_of_day, 6.0, 7.0, 0.0, 1.0) # Sunrise
elif time_of_day > 17.0:
light_energy = remap(time_of_day, 17.0, 18.0, 1.0, 0.0) # Sunset
else:
light_energy = 1.0 # Day
# Color shift
if time_of_day < 8.0 or time_of_day > 16.0:
light_color = Color(1.0, 0.7, 0.4) # Orange (dawn/dusk)
else:
light_color = Color(1.0, 1.0, 0.9) # Neutral white---
OmniLight3D (Point Light)
Attenuation Tuning
# torch.gd
extends OmniLight3D
func _ready() -> void:
omni_range = 10.0 # Maximum reach
omni_attenuation = 2.0 # Falloff curve (1.0 = linear, 2.0 = quadratic/realistic)
# For "magical" lights, reduce attenuation
omni_attenuation = 0.5 # Flatter falloff, reaches fartherFlickering Effect
# campfire.gd
extends OmniLight3D
@export var base_energy := 1.0
@export var flicker_strength := 0.3
@export var flicker_speed := 5.0
func _process(delta: float) -> void:
var flicker := sin(Time.get_ticks_msec() * 0.001 * flicker_speed) * flicker_strength
light_energy = base_energy + flicker---
SpotLight3D (Flashlight/Headlights)
Setup
# flashlight.gd
extends SpotLight3D
func _ready() -> void:
spot_range = 20.0
spot_angle = 45.0 # Cone angle (degrees)
spot_angle_attenuation = 2.0 # Edge softness
shadow_enabled = true
# Projector texture (optional - cookie/gobo)
light_projector = load("res://textures/flashlight_mask.png")Follow Camera
# player_flashlight.gd
extends SpotLight3D
@onready var camera: Camera3D = get_viewport().get_camera_3d()
func _process(delta: float) -> void:
if camera:
global_transform = camera.global_transform---
Global Illumination: VoxelGI vs SDFGI
Decision Matrix
| Feature | VoxelGI | SDFGI |
|---|---|---|
| Setup | Manual bounds per room | Automatic, scene-wide |
| Dynamic objects | Fully supported | Partially supported |
| Performance | Moderate | Higher cost |
| Use case | Indoor, small-medium scenes | Large outdoor scenes |
| Godot version | 4.0+ | 4.0+ |
VoxelGI Setup
# room_gi.gd - Place one VoxelGI per room/area
extends VoxelGI
func _ready() -> void:
# Tightly fit the room
size = Vector3(20, 10, 20)
# Quality settings
subdiv = VoxelGI.SUBDIV_128 # Higher = better quality, slower
# Bake GI data
bake()SDFGI Setup
# world_environment.gd
extends WorldEnvironment
func _ready() -> void:
var env := environment
# Enable SDFGI
env.sdfgi_enabled = true
env.sdfgi_use_occlusion = true
env.sdfgi_read_sky_light = true
# Cascades (auto-scale based on camera)
env.sdfgi_min_cell_size = 0.2 # Detail level
env.sdfgi_max_distance = 200.0---
LightmapGI (Baked Static Lighting)
When to Use
- Static architecture (buildings, dungeons)
- Mobile/low-end targets
- No dynamic geometry
Setup
# Scene structure:
# - LightmapGI node
# - StaticBody3D meshes with GeometryInstance3D.gi_mode = STATIC
# lightmap_baker.gd
extends LightmapGI
func _ready() -> void:
# Quality settings
quality = LightmapGI.BAKE_QUALITY_HIGH
bounces = 3 # Indirect light bounces
# Bake (editor only, not runtime)
# Click "Bake Lightmaps" button in editor---
Environment & Sky
HDR Skybox
# world_env.gd
extends WorldEnvironment
func _ready() -> void:
var env := environment
env.background_mode = Environment.BG_SKY
var sky := Sky.new()
var sky_material := PanoramaSkyMaterial.new()
sky_material.panorama = load("res://hdri/sky.hdr")
sky.sky_material = sky_material
env.sky = sky
# Sky contribution to GI
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_sky_contribution = 1.0Volumetric Fog
extends WorldEnvironment
func _ready() -> void:
var env := environment
env.volumetric_fog_enabled = true
env.volumetric_fog_density = 0.01
env.volumetric_fog_albedo = Color(0.9, 0.9, 1.0) # Blueish
env.volumetric_fog_emission = Color.BLACK---
ReflectionProbe
For localized reflections (mirrors, shiny floors):
# reflection_probe.gd
extends ReflectionProbe
func _ready() -> void:
# Capture area
size = Vector3(10, 5, 10)
# Quality
resolution = ReflectionProbe.RESOLUTION_512
# Update mode
update_mode = ReflectionProbe.UPDATE_ONCE # Bake once
# or UPDATE_ALWAYS for dynamic reflections (expensive)---
Performance Optimization
Light Budgets
# Recommended limits:
# - DirectionalLight3D with shadows: 1-2
# - OmniLight3D with shadows: 3-5
# - SpotLight3D with shadows: 2-4
# - OmniLight3D without shadows: 20-30
# - SpotLight3D without shadows: 15-20
# Disable shadows on minor lights
@onready var candle_lights: Array = [$Candle1, $Candle2, $Candle3]
func _ready() -> void:
for light in candle_lights:
light.shadow_enabled = false # Save performancePer-Light Shadow Distance
# Disable shadows for distant lights
extends OmniLight3D
@export var shadow_max_distance := 50.0
func _process(delta: float) -> void:
var camera := get_viewport().get_camera_3d()
if camera:
var dist := global_position.distance_to(camera.global_position)
shadow_enabled = (dist < shadow_max_distance)---
Edge Cases
Shadows Through Floors
# Problem: Thin floors let shadows through
# Solution: Increase shadow bias
extends DirectionalLight3D
func _ready() -> void:
shadow_enabled = true
shadow_bias = 0.1 # Increase if shadows bleed through
shadow_normal_bias = 2.0Light Leaking in Indoor Scenes
# Problem: VoxelGI light bleeds through walls
# Solution: Place VoxelGI nodes per-room, don't overlap
# Also: Ensure walls have proper thickness (not paper-thin)---
Expert Techniques & Optimizations
1. Shadowmasking for Large Outdoor Scenes
Rendering real-time shadows for distant objects is too expensive. Use Shadowmasking by setting a DirectionalLight3D to the Dynamic bake mode while baking a LightmapGI. This bakes distant shadows into a texture while allowing dynamic objects to cast real-time shadows up close, preventing "double shadowing" artifacts.
2. Fake Global Illumination
If you cannot afford GI at all (e.g., strict mobile constraints), fake it! Duplicate your main DirectionalLight3D, rotate it 180 degrees (pointing up from the ground), turn Shadows OFF, set Specular to 0.0, and reduce Energy to 10-40%. This cheaply simulates bounced floor lighting.
3. Simulating PCSS (Contact-Hardening Shadows)
Godot's OmniLight3D scaling can simulate Percentage-Closer Soft Shadows (blurrier shadows further from the caster).
extends OmniLight3D
func _ready() -> void:
# Simulates area lights and Percentage-Closer Soft Shadows (PCSS).
# Note: High performance cost. Keep the number of lights with light_size > 0.0 low.
light_size = 0.5
shadow_enabled = true
# Distance fade culls the light and shadow completely when out of range,
# preventing the clustered renderer from choking on too many overlapping PCSS lights.
distance_fade_enabled = true
distance_fade_begin = 20.0
distance_fade_length = 5.0---
Expert Pattern: Light-Volume-Trigger
Smoothly transition between lighting environments (e.g., entering a dark cave from a bright desert) using Area3D triggers and Tween-driven Camera3D overrides.
class_name LightVolumeTrigger extends Area3D
@export var interior_environment: Environment
@export var transition_duration: float = 2.0
func _ready() -> void:
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _on_body_entered(body: Node3D) -> void:
if body.is_in_group("player"):
var camera := get_viewport().get_camera_3d()
# Duplicate to avoid modifying the original resource
if not camera.environment:
camera.environment = interior_environment.duplicate()
var tween := create_tween().set_parallel(true)
# Interpolate key properties for visual adaptation
tween.tween_property(camera.environment, "tonemap_exposure", interior_environment.tonemap_exposure, transition_duration)
tween.tween_property(camera.environment, "ambient_light_energy", interior_environment.ambient_light_energy, transition_duration)
func _on_body_exited(body: Node3D) -> void:
# Reverse tween or clear camera environment to return to WorldEnvironment
pass[!TIP]
Place aReflectionProbeinside the interior withinterior = true. Godot will automatically blend this with the exterior environment as the player transitions.
---
Expert Pattern: Interior-Mapping (Fake-Rooms)
Use shaders to create the illusion of 3D rooms inside flat window planes. This is significantly more performant than rendering actual geometry for every building interior.
shader_type spatial;
// Texture array containing wall/floor/ceiling layers
uniform sampler2DArray room_textures;
uniform vec3 room_dimensions = vec3(1.0, 1.0, 1.0);
void fragment() {
// 1. Transform view vector into object space
vec3 view_dir = normalize(VIEW * mat3(INV_VIEW_MATRIX * MODEL_MATRIX));
// 2. Ray-box intersection (Simplified logic)
// Calculate the 'depth' of the fake room based on view angle
vec3 pos = vec3(UV * 2.0 - 1.0, 0.0);
vec3 id = 1.0 / view_dir;
// ... complex ray-casting math ...
// 3. Sample the texture array
// Z component selects the specific room variation or wall type
ALBEDO = texture(room_textures, vec3(UV, 0.0)).rgb;
}---
Expert Pattern: Lighting-Quality-Settings
Manage complex lighting features (Shadows, SDFGI, Fog) at runtime using the RenderingServer API for direct engine control.
class_name LightingQualityManager extends Node
func apply_low_quality_profile(env_rid: RID) -> void:
# 1. SDFGI Optimization
# Huge performance gain: Render GI buffers at half resolution
RenderingServer.gi_set_use_half_resolution(true)
RenderingServer.environment_set_sdfgi_ray_count(RenderingServer.ENV_SDFGI_RAY_COUNT_4)
RenderingServer.environment_set_sdfgi_frames_to_converge(RenderingServer.ENV_SDFGI_CONVERGE_IN_30_FRAMES)
# 2. Shadow Optimization
# Reduce global directional shadow atlas
RenderingServer.directional_shadow_atlas_set_size(2048, true)
RenderingServer.directional_soft_shadow_filter_set_quality(RenderingServer.SHADOW_QUALITY_SOFT_VERY_LOW)
# Reduce positional (Omni/Spot) shadows for current viewport
get_viewport().positional_shadow_atlas_size = 1024
# 3. Volumetric Fog
# Disable or heavily reduce fog detail
RenderingServer.environment_set_volumetric_fog(env_rid, false, 0.01, Color.WHITE, Color.BLACK, 0.0, 0.2, 64.0, 2.0, 1.0, true, 0.9, 0.0, 1.0)Reference
- Master Skill: godot-master
3D Materials
Expert guidance for PBR materials and StandardMaterial3D in Godot.
NEVER Do
- NEVER use separate metallic/roughness/AO textures — Use ORM packing (1 RGB texture with Occlusion/Roughness/Metallic channels) to save texture slots and memory.
- NEVER forget to enable normal_enabled — Normal maps don't work unless you set
normal_enabled = true. Silent failure is common. - NEVER use TRANSPARENCY_ALPHA for cutout materials — Use TRANSPARENCY_ALPHA_SCISSOR or TRANSPARENCY_ALPHA_HASH instead. Full alpha blending is expensive and causes sorting issues.
- NEVER set metallic = 0.5 — Materials are either metallic (1.0) or dielectric (0.0). Values between are physically incorrect except for rust/dirt transitions.
- NEVER use emission without HDR — Emission values > 1.0 only work with HDR rendering enabled in Project Settings.
- NEVER use transparent materials for large environmental surfaces — Transparent objects cannot rely on the Z-buffer for early fragment rejection, resulting in massive overdraw. If only a tiny part of a mesh is transparent, split the mesh into two surfaces: one opaque, one transparent.
- NEVER create hundreds of slightly varied StandardMaterial3D resources if performance is dropping — Godot minimizes GPU state changes by automatically reusing the underlying shader for materials that share the exact same configuration flags (checkboxes). Try to group your material configurations.
- NEVER attempt to fix Z-fighting strictly by moving objects further apart — Floating-point precision degrades over distance. To fix flickering textures, increase your Camera3D's
Nearplane property and decrease theFarproperty to compress the precision range. - NEVER use unique Material resources per MeshInstance3D — This breaks draw call batching. Use 'Instance Uniforms' to vary parameters while keeping a single shared material.
- NEVER use Decals on dynamic moving actors without a Cull Mask — Bullet holes should not stick to the player's face as they walk over them. Mask out character layers.
---
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
material_fx.gd
Runtime material property animation for damage effects, dissolve, and texture swapping. Use for dynamic material state changes.
pbr_material_builder.gd
Runtime PBR material creation with ORM textures and triplanar mapping.
organic_material.gd
Subsurface scattering and rim lighting setup for organic surfaces (skin, leaves). Use for realistic character or vegetation materials.
triplanar_world.gdshader
Triplanar projection shader for terrain without UV mapping. Blends textures based on surface normals. Use for cliffs, caves, or procedural terrain.
pbr_orm_packer.gd
Expert PBR resource utility. Packs Ambient Occlusion, Roughness, and Metallic into a single ORM texture to optimize VRAM and draw calls.
vertex_wind_sway.gdshader
High-performance GPU-driven foliage animation. Uses vertex world coordinates and vertex color weight painting to simulate wind without skeletons.
triplanar_world_projection.gdshader
UV-less environment mapping. Projects textures along X/Y/Z axes for organic blending over complex rocks and terrain.
subsurface_scattering_setup.gd
Configuring realistic organic materials. Covers Skin Mode, Transmittance, and depth scattering settings for Forward+ rendering.
instance_uniform_batching.gdshader
Architecture pattern for high-speed batching. Allows 10,000 meshes to share one material while maintaining unique colors or health states via instance uniforms.
decal_placer_expert.gd
Dynamic 3D decal system with cull masking and life-cycle management for impact effects.
transparency_sorting_fix.gd
Solving visual artifacts using Alpha Hash and Depth Prepass strategies.
shader_state_manager.gd
Clean pattern for toggling shader-based visual states (Frozen, Burned) on multiple entities.
depth_precision_fix.gd
Camera-side fix for Z-fighting and texture flickering in large-scale worlds.
material_batcher.gd
Global override system to ensure environmental meshes draw in optimized, state-locked batches.
---
StandardMaterial3D Basics
PBR Texture Setup
# Create physically-based material
var mat := StandardMaterial3D.new()
# Albedo (base color)
mat.albedo_texture = load("res://textures/wood_albedo.png")
mat.albedo_color = Color.WHITE # Tint multiplier
# Normal map (surface detail)
mat.normal_enabled = true # CRITICAL: Must enable first
mat.normal_texture = load("res://textures/wood_normal.png")
mat.normal_scale = 1.0 # Bump strength
# ORM Texture (R=Occlusion, G=Roughness, B=Metallic)
mat.orm_texture = load("res://textures/wood_orm.png")
# Alternative: Separate textures (less efficient)
# mat.roughness_texture = load("res://textures/wood_roughness.png")
# mat.metallic_texture = load("res://textures/wood_metallic.png")
# mat.ao_texture = load("res://textures/wood_ao.png")
# Apply to mesh
$MeshInstance3D.material_override = mat---
Metallic vs Roughness
Metal Workflow
# Pure metal (steel, gold, copper)
mat.metallic = 1.0
mat.roughness = 0.2 # Polished metal
mat.albedo_color = Color(0.8, 0.8, 0.8) # Metal tint
# Rough metal (iron, aluminum)
mat.metallic = 1.0
mat.roughness = 0.7Dielectric Workflow
# Non-metal (wood, plastic, stone)
mat.metallic = 0.0
mat.roughness = 0.6 # Typical for wood
mat.albedo_color = Color(0.6, 0.4, 0.2) # Brown wood
# Glossy plastic
mat.metallic = 0.0
mat.roughness = 0.1 # Very smoothTransition Materials (Rust/Dirt)
# Use texture to blend metal/non-metal
mat.metallic_texture = load("res://rust_mask.png")
# White areas (1.0) = metal
# Black areas (0.0) = rust (dielectric)---
Transparency Modes
Decision Matrix
| Mode | Use Case | Performance | Sorting Issues |
|---|---|---|---|
| ALPHA_SCISSOR | Foliage, chain-link fence | Fast | No |
| ALPHA_HASH | Dithered fade, LOD transitions | Fast | Noisy |
| ALPHA | Glass, water, godot-particles | Slow | Yes (render order) |
Alpha Scissor (Cutout)
# For leaves, grass, fences
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
mat.alpha_scissor_threshold = 0.5 # Pixels < 0.5 alpha = discarded
mat.albedo_texture = load("res://leaf.png") # Must have alpha channel
# Enable backface culling for performance
mat.cull_mode = BaseMaterial3D.CULL_BACKAlpha Hash (Dithered)
# For smooth fade-outs without sorting issues
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_HASH
mat.alpha_hash_scale = 1.0 # Dither pattern scale
# Animate fade
var tween := create_tween()
tween.tween_property(mat, "albedo_color:a", 0.0, 1.0)Alpha Blend (Full Transparency)
# For glass, water (expensive)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.blend_mode = BaseMaterial3D.BLEND_MODE_MIX
# Disable depth writing for correct blending
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
mat.cull_mode = BaseMaterial3D.CULL_DISABLED # Show both sides---
Advanced Features
Emission (Glowing Materials)
mat.emission_enabled = true
mat.emission = Color(1.0, 0.5, 0.0) # Orange glow
mat.emission_energy_multiplier = 2.0 # Brightness (HDR)
mat.emission_texture = load("res://lava_emission.png")
# Animated emission
func _process(delta: float) -> void:
mat.emission_energy_multiplier = 1.0 + sin(Time.get_ticks_msec() * 0.005) * 0.5Rim Lighting (Fresnel)
mat.rim_enabled = true
mat.rim = 1.0 # Intensity
mat.rim_tint = 0.5 # How much albedo affects rim colorClearcoat (Car Paint)
mat.clearcoat_enabled = true
mat.clearcoat = 1.0 # Layer strength
mat.clearcoat_roughness = 0.1 # Glossy top layerAnisotropy (Brushed Metal)
mat.anisotropy_enabled = true
mat.anisotropy = 1.0 # Directional highlights
mat.anisotropy_flowmap = load("res://brushed_flow.png")---
Texture Channel Packing
ORM Texture (Recommended)
# External tool (GIMP, Substance, Python script):
# Combine 3 grayscale textures into 1 RGB:
# R channel = Ambient Occlusion (bright = no occlusion)
# G channel = Roughness (bright = rough)
# B channel = Metallic (bright = metal)# In Godot:
mat.orm_texture = load("res://textures/material_orm.png")
# This replaces ao_texture, roughness_texture, and metallic_texture!Custom Packing
# If using custom channel assignments:
mat.roughness_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_GREEN
mat.metallic_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_BLUE---
Shader Conversion
When to Convert to ShaderMaterial
- Need custom effects (dissolve, vertex displacement)
- StandardMaterial3D limitations hit
- Shader optimizations (remove unused features)
Conversion Workflow
# 1. Create StandardMaterial3D with all settings
var std_mat := StandardMaterial3D.new()
std_mat.albedo_color = Color.RED
std_mat.metallic = 1.0
std_mat.roughness = 0.2
# 2. Convert to ShaderMaterial
var shader_mat := ShaderMaterial.new()
shader_mat.shader = load("res://custom_shader.gdshader")
# 3. Transfer parameters manually
shader_mat.set_shader_parameter("albedo", std_mat.albedo_color)
shader_mat.set_shader_parameter("metallic", std_mat.metallic)
shader_mat.set_shader_parameter("roughness", std_mat.roughness)---
Material Variants (Godot 4.0+)
Efficient Material Reuse
# Base material (shared)
var base_red_metal := StandardMaterial3D.new()
base_red_metal.albedo_color = Color.RED
base_red_metal.metallic = 1.0
# Variant 1: Rough
var rough_variant := base_red_metal.duplicate()
rough_variant.roughness = 0.8
# Variant 2: Smooth
var smooth_variant := base_red_metal.duplicate()
smooth_variant.roughness = 0.1
# Note: Use resource_local_to_scene for per-instance tweaks---
Performance Optimization
Material Batching
# ✅ GOOD: Reuse materials across meshes
const SHARED_STONE := preload("res://materials/stone.tres")
func _ready() -> void:
for wall in get_tree().get_nodes_in_group("stone_walls"):
wall.material_override = SHARED_STONE
# All walls batched in single draw call
# ❌ BAD: Unique material per mesh
func _ready() -> void:
for wall in get_tree().get_nodes_in_group("stone_walls"):
var mat := StandardMaterial3D.new() # New material!
mat.albedo_color = Color(0.5, 0.5, 0.5)
wall.material_override = mat
# Each wall is separate draw callTexture Atlasing
# Combine multiple materials into one texture atlas
# Then use UV offsets to select regions
# material_atlas.gd
extends StandardMaterial3D
func set_atlas_region(tile_x: int, tile_y: int, tiles_per_row: int) -> void:
var tile_size := 1.0 / tiles_per_row
uv1_offset = Vector3(tile_x * tile_size, tile_y * tile_size, 0)
uv1_scale = Vector3(tile_size, tile_size, 1)---
Edge Cases
Normal Maps Not Working
# Problem: Forgot to enable
mat.normal_enabled = true # REQUIRED
# Problem: Wrong texture import settings
# In Import tab: Texture → Normal Map = trueTexture Seams on Models
# Problem: Mipmaps causing seams
# Solution: Disable mipmaps for tightly-packed UVs
# Import → Mipmaps → Generate = falseMaterial Looks Flat
# Problem: Missing normal map or roughness variation
# Solution: Add normal map + roughness texture
mat.normal_enabled = true
mat.normal_texture = load("res://normal.png")
mat.roughness_texture = load("res://roughness.png")---
Common Material Presets
# Glass
func create_glass() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(1, 1, 1, 0.2)
mat.metallic = 0.0
mat.roughness = 0.0
mat.refraction_enabled = true
mat.refraction_scale = 0.05
return mat
# Gold
func create_gold() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.85, 0.3)
mat.metallic = 1.0
mat.roughness = 0.3
return mat---
Expert Techniques & Optimizations
1. LOD Transitions using Pixel Dither
When utilizing Hierarchical Level of Detail (HLOD) or Visibility Ranges to fade objects out at a distance, standard alpha blending causes severe performance hits due to overlapping transparent bounds. Instead, configure the Distance Fade mode on your material to Pixel Dither. This provides a perceptually smooth fade while remaining entirely within the high-performance opaque pipeline.
2. Stencil Buffers (Godot 4.5+)
Use the Stencil Buffer directly in StandardMaterial3D. This allows you to easily render outlines or X-ray effects for objects hidden behind walls without needing to write custom shaders for basic effects.
3. AR Shadow Overlay Shader
If you are developing an AR game, you might want virtual shadows to appear on real-world camera feeds. Instead of standard blending, use Godot's built-in shadow_to_opacity render mode in a spatial shader.
shader_type spatial;
// shadow_to_opacity makes the material invisible when lit,
// but opaque (dark) when it receives a shadow from another 3D object.
render_mode blend_mix, depth_draw_opaque, cull_back, shadow_to_opacity;
void fragment() {
// The surface color is black; opacity will be driven by incoming shadows
ALBEDO = vec3(0.0, 0.0, 0.0);
}---
Expert Pattern: Material-Texture-Array (Instanced Variation)
To render hundreds of varied objects (e.g., forest trees, crowd variants) in a single draw call, use Instance Uniforms with a texture array in a custom Spatial shader. This bypasses the need for unique material resources per variation.
The Spatial Shader (texture_array.gdshader)
shader_type spatial;
uniform sampler2D texture_array[4];
// This uniform is unique per GeometryInstance3D node, NOT per material
instance uniform int texture_index;
void fragment() {
vec4 tex_color;
switch (texture_index) {
case 0: tex_color = texture(texture_array[0], UV); break;
case 1: tex_color = texture(texture_array[1], UV); break;
case 2: tex_color = texture(texture_array[2], UV); break;
case 3: tex_color = texture(texture_array[3], UV); break;
}
ALBEDO = tex_color.rgb;
}The GDScript Controller
func apply_variant(mesh_instance: GeometryInstance3D, index: int) -> void:
# Set the per-instance uniform. The underlying material remains shared.
mesh_instance.set_instance_shader_parameter(&"texture_index", index)---
Expert Pattern: Dissolve-Shader-Integration (Alpha Scissor)
For high-performance impact or dissolve effects, use Alpha Scissor transparency. Unlike standard Alpha blending, Scissor allows the mesh to cast shadows and avoids the expensive transparency sorting pipeline.
func trigger_dissolve(mesh: MeshInstance3D, duration: float = 1.0) -> void:
var mat := mesh.get_surface_override_material(0) as StandardMaterial3D
if not mat: return
# 1. Force Alpha Scissor for performance
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
# 2. Tween threshold to discard pixels based on noise/albedo alpha
var tween := create_tween()
tween.tween_property(mat, "alpha_scissor_threshold", 1.0, duration).from(0.0)---
Expert Pattern: Material-LOD-System (HLOD)
While Godot handles Mesh LOD (geometry) automatically, it does not simplify material shading at a distance. Use Visibility Ranges to swap meshes and apply simplified materials to reduce fragment shading costs.
func setup_lod_materials(detailed_node: GeometryInstance3D, distant_node: GeometryInstance3D) -> void:
# 1. Detailed version: Hide at 50m
detailed_node.visibility_range_end = 50.0
detailed_node.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF
# 2. Distant version: Appear at 50m
distant_node.visibility_range_begin = 50.0
distant_node.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF
# 3. Simplify Distant Material
var dist_mat := distant_node.get_surface_override_material(0) as StandardMaterial3D
if dist_mat:
# Disable expensive shading features for distant LOD
dist_mat.normal_enabled = false
dist_mat.rim_enabled = false
dist_mat.clearcoat_enabled = false
dist_mat.subsurf_scatter_enabled = false
# Use Pixel Dither for seamless, non-transparent fading
dist_mat.distance_fade_mode = BaseMaterial3D.DISTANCE_FADE_PIXEL_DITHERReference
- Master Skill: godot-master
3D World Building
Expert guidance for level design with GridMaps, CSG, and environmental setup.
NEVER Do
- NEVER forget to bake GridMap navigation — GridMaps don't auto-generate navigation meshes. Use EditorPlugin or manual NavigationRegion3D.
- NEVER use CSG for final game geometry — CSG is for prototyping. Convert to static meshes for performance (use "Bake CSG Mesh" in editor).
- NEVER scale GridMap cell size after placing tiles — Changing
cell_sizedoesn't update existing tiles, causing misalignment. Set it once at the start. - NEVER use MeshLibrary without collision shapes — Items without collision spawn visual-only geometry that players fall through.
- NEVER enable volumetric fog without DirectionalLight3D — Volumetric fog requires at least one light to scatter. No lights = no visible fog.
- NEVER animate CSG nodes during gameplay — Moving a CSG node within another forces the CPU to recalculate the boolean geometry, causing significant performance drops.
- NEVER place generic logic nodes in a GridMap — GridMap is highly optimized only for meshes, navigation, and collision. It is not a general-purpose system for placing arbitrary node structures on a grid.
- NEVER use non-manifold meshes in CSG — If you import a custom mesh for CSGMesh3D, it must be manifold (closed, no self-intersections, no interior faces, no negative volume). Non-manifold meshes will break the CSG algorithm and are completely unsupported.
---
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
collision_gen.gd
Automatic collision shape generation from meshes. Use when importing models without collision or for procedural geometry.
gridmap_runtime_builder.gd
Runtime GridMap tile placement with batch operations and auto-navigation baking.
csg_bake_tool.gd
EditorScript to bake CSG geometry to static meshes with proper materials and collision. Use when finalizing level prototypes.
safe_csg_baking.gd
Expert technique for safe CSG baking. Awaits the end of the frame before extracting baked meshes to avoid empty data.
lod_manager.gd
Level-of-detail switching based on camera distance. Manages mesh swapping and visibility for large outdoor scenes.
occlusion_setup.gd
OccluderInstance3D configuration for manual occlusion culling. Use for indoor levels with many rooms.
---
GridMap Fundamentals
Setup Workflow
# 1. Create MeshLibrary resource (editor)
# Scene → New Inherits Scene → Create Grid-aligned meshes
# Scene → Convert To → MeshLibrary...
# 2. Assign to GridMap
extends GridMap
func _ready() -> void:
mesh_library = load("res://tilesets/dungeon_library.tres")
cell_size = Vector3(2, 2, 2) # Must match library cell sizeCell Manipulation
# gridmap_builder.gd
extends GridMap
# Place cell
func place_tile(grid_pos: Vector3i, tile_index: int) -> void:
set_cell_item(grid_pos, tile_index)
# Get cell
func get_tile(grid_pos: Vector3i) -> int:
return get_cell_item(grid_pos) # Returns index or INVALID_CELL_ITEM (-1)
# Remove cell
func remove_tile(grid_pos: Vector3i) -> void:
set_cell_item(grid_pos, INVALID_CELL_ITEM)
# Rotate cell (0-23, see GridMap.ROTATION_* constants)
func place_rotated(grid_pos: Vector3i, tile_index: int, orientation: int) -> void:
set_cell_item(grid_pos, tile_index, orientation)Coordinate Conversion
# World position ↔ Grid coordinates
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
var camera := get_viewport().get_camera_3d()
var from := camera.project_ray_origin(event.position)
var to := from + camera.project_ray_normal(event.position) * 1000
var space := get_world_3d().direct_space_state
var query := PhysicsRayQueryParameters3D.create(from, to)
var result := space.intersect_ray(query)
if result:
var world_pos: Vector3 = result.position
var grid_pos := local_to_map(to_local(world_pos))
place_tile(grid_pos, 0) # Place tile at clicked position
# Grid → World
func get_cell_center(grid_pos: Vector3i) -> Vector3:
return to_global(map_to_local(grid_pos))---
MeshLibrary Creation
Collision Setup
# tile_scene.tscn (before converting to MeshLibrary)
# Root: Node3D
# ├─ MeshInstance3D (visual)
# └─ StaticBody3D (collision)
# └─ CollisionShape3D
# CRITICAL: StaticBody3D must be sibling/child for GridMap to detect collisionItem Metadata
# Access MeshLibrary item data
func get_tile_name(tile_index: int) -> String:
return mesh_library.get_item_name(tile_index)
# Custom metadata (stored in MeshLibrary resource)
# Use item_set_name() in editor script to organize---
CSG (Constructive Solid Geometry)
Boolean Operations
CSG Combiner3D
├─ CSGBox3D (Operation: Union) # Base room
├─ CSGBox3D (Operation: Subtraction) # Door cutout
└─ CSGSphere3D (Operation: Intersection) # Rounded cornerCSG Brush Types
# CSGBox3D - Room primitives
var room := CSGBox3D.new()
room.size = Vector3(10, 5, 10)
# CSGCylinder3D - Pillars
var pillar := CSGCylinder3D.new()
pillar.radius = 0.5
pillar.height = 5.0
# CSGSphere3D - Domes
var dome := CSGSphere3D.new()
dome.radius = 3.0
dome.radial_segments = 16
dome.rings = 8
# CSGPolygon3D - Extruded 2D shapes
var arch := CSGPolygon3D.new()
arch.polygon = PackedVector2Array([
Vector2(-1, 0), Vector2(-1, 2), Vector2(1, 2), Vector2(1, 0)
])
arch.depth = 0.5CSG Performance
# ❌ BAD: Use CSG at runtime (slow)
func _ready() -> void:
var csg := CSGBox3D.new()
add_child(csg) # Recalculates mesh every frame
# ✅ GOOD: Bake to MeshInstance3D (editor only)
# Select CSG node → Mesh → Bake Mesh Instance
# Then delete CSG node
# ✅ ALSO GOOD: Use CSG for level editor, bake on export---
WorldEnvironment Setup
Sky Configuration
# world_env.gd
extends WorldEnvironment
func _ready() -> void:
var env := Environment.new()
environment = env
# Procedural sky
env.background_mode = Environment.BG_SKY
var sky := Sky.new()
var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = Color(0.4, 0.6, 1.0) # Blue
sky_mat.sky_horizon_color = Color(0.8, 0.9, 1.0) # Lighter
sky_mat.ground_bottom_color = Color(0.2, 0.2, 0.1)
sky_mat.sun_angle_max = 30.0
sky.sky_material = sky_mat
env.sky = skyHDRI Skybox
# For realistic lighting
var env := environment
env.background_mode = Environment.BG_SKY
var sky := Sky.new()
var panorama := PanoramaSkyMaterial.new()
panorama.panorama = load("res://hdri/sunset.hdr") # Equirectangular HDR image
sky.sky_material = panorama
env.sky = sky
# Sky contribution to ambient light
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_sky_contribution = 1.0---
Fog & Atmosphere
Exponential Fog
extends WorldEnvironment
func _ready() -> void:
var env := environment
env.fog_enabled = true
env.fog_mode = Environment.FOG_MODE_EXPONENTIAL
env.fog_density = 0.01 # 0.0-1.0
env.fog_light_color = Color(0.9, 0.95, 1.0) # Blueish
env.fog_light_energy = 1.0Depth Fog
# Distance-based fog
env.fog_enabled = true
env.fog_mode = Environment.FOG_MODE_DEPTH
env.fog_depth_begin = 50.0 # Start distance
env.fog_depth_end = 200.0 # End distance (fully opaque)
env.fog_depth_curve = 1.0 # Falloff curveVolumetric Fog
# Requires DirectionalLight3D for scattering
env.volumetric_fog_enabled = true
env.volumetric_fog_density = 0.05
env.volumetric_fog_albedo = Color(0.9, 0.9, 1.0)
env.volumetric_fog_emission = Color.BLACK
env.volumetric_fog_gi_inject = 1.0 # How much GI affects fog
# Performance settings
env.volumetric_fog_temporal_reprojection_enabled = true
env.volumetric_fog_detail_spread = 2.0---
Level Streaming / LOD
GridMap Chunking
# level_streamer.gd - Load/unload GridMap chunks based on player position
extends Node3D
@export var chunk_size := 32 # Grid cells per chunk
@export var load_radius := 2 # Chunks to keep loaded
var loaded_chunks := {} # Vector2i → GridMap
func _process(delta: float) -> void:
var player_pos := get_player_position()
var player_chunk := Vector2i(
int(player_pos.x / (chunk_size * cell_size.x)),
int(player_pos.z / (chunk_size * cell_size.z))
)
# Load nearby chunks
for x in range(-load_radius, load_radius + 1):
for z in range(-load_radius, load_radius + 1):
var chunk_coord := player_chunk + Vector2i(x, z)
if chunk_coord not in loaded_chunks:
load_chunk(chunk_coord)
# Unload distant chunks
for chunk_coord in loaded_chunks.keys():
var dist := chunk_coord.distance_to(player_chunk)
if dist > load_radius:
unload_chunk(chunk_coord)
func load_chunk(coord: Vector2i) -> void:
var gridmap := GridMap.new()
gridmap.mesh_library = preload("res://library.tres")
add_child(gridmap)
loaded_chunks[coord] = gridmap
# TODO: Load chunk data from file/database
# gridmap.set_cell_item(...)
func unload_chunk(coord: Vector2i) -> void:
var gridmap: GridMap = loaded_chunks[coord]
gridmap.queue_free()
loaded_chunks.erase(coord)---
Procedural Generation
Random Dungeon with GridMap
# dungeon_generator.gd
extends GridMap
enum Tile { FLOOR, WALL, DOOR }
func generate_room(pos: Vector3i, size: Vector3i) -> void:
# Fill with floor
for x in range(size.x):
for z in range(size.z):
set_cell_item(pos + Vector3i(x, 0, z), Tile.FLOOR)
# Add walls
for x in range(size.x):
set_cell_item(pos + Vector3i(x, 0, 0), Tile.WALL) # North
set_cell_item(pos + Vector3i(x, 0, size.z - 1), Tile.WALL) # South
for z in range(size.z):
set_cell_item(pos + Vector3i(0, 0, z), Tile.WALL) # West
set_cell_item(pos + Vector3i(size.x - 1, 0, z), Tile.WALL) # East
func _ready() -> void:
generate_room(Vector3i(0, 0, 0), Vector3i(10, 1, 10))---
Edge Cases
GridMap Cells Not Colliding
# Problem: MeshLibrary items lack collision
# Solution: Ensure StaticBody3D + CollisionShape3D in source scene
# Verify in code:
var item_shapes := mesh_library.get_item_shapes(tile_index)
if item_shapes.is_empty():
push_error("Tile %d has no collision!" % tile_index)CSG Mesh Flickering
# Problem: Z-fighting between overlapping CSG operations
# Solution: Add small offset (0.001) to prevent exact overlap
var box := CSGBox3D.new()
box.size = Vector3(10, 5, 10)
var cutout := CSGBox3D.new()
cutout.operation = CSGShape3D.OPERATION_SUBTRACTION
cutout.size = Vector3(2, 3, 2.002) # Slightly larger depth---
Expert Techniques & Optimizations
1. Spatially Partitioning MultiMeshes
The major drawback of MultiMesh is that individual instances cannot be frustum or occlusion culled; the entire cluster is drawn based on the bounding box of the MultiMeshInstance3D. To solve this, partition your thousands of objects into several regional MultiMeshInstance3D nodes so the engine can cull entire regions at once.
---
Expert Pattern: GridMap-Custom-Data (Logic Proxies)
Since GridMap is optimized for visuals/collision rather than logic, use "Proxy Tiles" to mark locations for spawn points, NPCs, or triggers during level design.
class_name GridMapLogicManager extends Node3D
@export var level_grid: GridMap
@export var spawn_point_scene: PackedScene
# The ID of the invisible cube in your MeshLibrary
const SPAWN_PROXY_ID: int = 5
func _ready() -> void:
_replace_proxies_with_logic()
func _replace_proxies_with_logic() -> void:
# 1. Find all cells using the proxy tile
var proxy_cells: Array[Vector3i] = level_grid.get_used_cells_by_item(SPAWN_PROXY_ID)
for cell in proxy_cells:
# 2. Convert grid pos to world pos
var world_pos: Vector3 = level_grid.to_global(level_grid.map_to_local(cell))
# 3. Instantiate actual gameplay logic
var instance: Node3D = spawn_point_scene.instantiate()
add_child(instance)
instance.global_position = world_pos
# 4. Clear the proxy tile to save performance
level_grid.set_cell_item(cell, GridMap.INVALID_CELL_ITEM)---
Expert Pattern: Interior-Mapping (Fake Windows)
For massive cities, avoid rendering actual interiors. Use a Spatial shader to project the illusion of 3D depth onto a single 2D window plane.
shader_type spatial;
uniform sampler2DArray room_textures; // Cubemap-like layers
void fragment() {
// Project view vector into fake room depth
vec3 view_dir = normalize(VIEW);
// Intersection math to determine which wall/floor/ceiling pixel to sample
// Note: Use 'VIEW' and 'INV_VIEW_MATRIX' for perspective calculations
vec3 room_uv = view_dir; // Simplified placeholder
ALBEDO = texture(room_textures, room_uv).rgb;
}---
Expert Pattern: World-Streaming-Queue (Stutter-Free Loading)
To prevent frame-spikes when moving between level chunks, use ResourceLoader background threads.
class_name WorldStreamer extends Node
var load_queue: Array[String] = []
func request_chunk(path: String) -> void:
# Begin background thread request
var err = ResourceLoader.load_threaded_request(path)
if err == OK:
load_queue.append(path)
func _process(_delta: float) -> void:
for i in range(load_queue.size() - 1, -1, -1):
var path = load_queue[i]
var status = ResourceLoader.load_threaded_get_status(path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
# Resource ready! Instantiate and add to scene
var chunk: PackedScene = ResourceLoader.load_threaded_get(path)
add_child(chunk.instantiate())
load_queue.remove_at(i)Reference
- Master Skill: godot-master
Ability System
Expert guidance for building flexible, extensible ability systems.
NEVER Do
- NEVER use _process() for cooldown tracking — Use timers or manual delta tracking in _physics_process(). _process() has variable delta and causes cooldown desync in slow frames.
- NEVER forget global cooldown (GCD) — Without GCD, players spam instant abilities. Add a small universal cooldown (0.5-1.5s) between all ability casts.
- NEVER hardcode ability effects in manager code — Use the Strategy pattern. Each ability is a Resource with execute() method, not a giant switch statement.
- NEVER allow ability use during animation lock — Check
is_castingoranimation_playingbefore allowing new casts. Interrupting animations breaks state machines. - NEVER save cooldown state without time normalization — Save "cooldown_end_time" (OS.get_unix_time() + remaining), not "remaining_time". Prevents exploits (change system clock, reload game).
- NEVER use Singletons (Autoloads) for combat managers — Centralizing combat state in a global object makes tracking bugs difficult and breaks encapsulation. Keep abilities and stats scoped to the scenes that actually use them.
- NEVER use Object Pooling with GDScript — GDScript uses reference counting memory management, so you generally do not need to pool instantiated abilities or projectiles. Simply instantiate and queue_free().
- NEVER rely on deep inheritance trees — Avoid having a BaseAbility -> MagicAbility -> FireAbility inheritance hell. Use node composition instead.
---
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
ability_manager.gd
Ability orchestration with cooldown registry, can_use checks, and visual cooldown progress. Decoupled from character logic for use on players, enemies, or turrets.
ability_resource.gd
Scriptable ability resource base class with metadata, stats, and effects array. Virtual execute() method for inheritance (ProjectileAbility, BuffAbility).
buff_stat.gd
Resource-Driven Buff System setup. Extends Resource and creates highly modular, drag-and-drop ability data.
---
Architecture Patterns
Resource-Based Abilities
# ability_base.gd - Base class for all abilities
class_name Ability
extends Resource
@export var ability_id: String
@export var display_name: String
@export var icon: Texture2D
@export var description: String
@export_group("Costs")
@export var mana_cost: int = 0
@export var stamina_cost: int = 0
@export var health_cost: int = 0 # Life tap abilities
@export_group("Timing")
@export var cooldown: float = 5.0
@export var cast_time: float = 0.0 # 0 = instant
@export var channel_time: float = 0.0 # Channeled abilities
@export_group("Unlocking")
@export var unlock_level: int = 1
@export var prerequisites: Array[String] = [] # Other ability IDs
## Override these
func can_cast(caster: Node) -> bool:
return true # Additional checks (range, target, etc.)
func execute(caster: Node, target: Node = null) -> void:
pass # Ability effect
func on_cast_start(caster: Node) -> void:
pass # Animation, effects
func on_cast_complete(caster: Node) -> void:
execute(caster)
func on_cancel(caster: Node) -> void:
pass # Refund resourcesConcrete Ability Example
# fireball.gd
class_name FireballAbility
extends Ability
@export var damage: int = 50
@export var projectile_scene: PackedScene
@export var range: float = 500.0
func can_cast(caster: Node) -> bool:
var target = caster.get_target()
if not target:
return false
var distance := caster.global_position.distance_to(target.global_position)
return distance <= range
func execute(caster: Node, target: Node = null) -> void:
var projectile := projectile_scene.instantiate()
caster.get_parent().add_child(projectile)
projectile.global_position = caster.global_position
projectile.target = target
projectile.damage = damage---
Ability Manager (Centralized)
Core Manager
# ability_manager.gd
class_name AbilityManager
extends Node
signal ability_cast(ability_id: String)
signal ability_ready(ability_id: String)
signal cooldown_started(ability_id: String, duration: float)
var abilities: Dictionary = {} # ability_id → Ability
var cooldowns: Dictionary = {} # ability_id → float (time remaining)
var is_casting: bool = false
var global_cooldown: float = 0.0 # GCD timer
@export var gcd_duration: float = 1.0 # Global cooldown
func register_ability(ability: Ability) -> void:
abilities[ability.ability_id] = ability
cooldowns[ability.ability_id] = 0.0
func can_use_ability(ability_id: String, caster: Node) -> bool:
var ability := abilities.get(ability_id) as Ability
if not ability:
return false
# Check GCD
if global_cooldown > 0.0:
return false
# Check specific cooldown
if cooldowns.get(ability_id, 0.0) > 0.0:
return false
# Check if already casting
if is_casting and ability.cast_time > 0.0:
return false
# Check resources
if not has_resources(caster, ability):
return false
# Ability-specific checks
return ability.can_cast(caster)
func use_ability(ability_id: String, caster: Node, target: Node = null) -> bool:
if not can_use_ability(ability_id, caster):
return false
var ability := abilities[ability_id]
# Consume resources
consume_resources(caster, ability)
# Start cast
if ability.cast_time > 0.0:
start_cast(ability, caster, target)
else:
# Instant cast
ability.execute(caster, target)
trigger_cooldown(ability_id, ability.cooldown)
ability_cast.emit(ability_id)
return true
func start_cast(ability: Ability, caster: Node, target: Node) -> void:
is_casting = true
ability.on_cast_start(caster)
# Create timer for cast completion
var timer := get_tree().create_timer(ability.cast_time)
await timer.timeout
if is_casting: # Not interrupted
ability.on_cast_complete(caster)
trigger_cooldown(ability.ability_id, ability.cooldown)
is_casting = false
func interrupt_cast() -> void:
if is_casting:
is_casting = false
# Trigger ability.on_cancel() if needed
func trigger_cooldown(ability_id: String, duration: float) -> void:
cooldowns[ability_id] = duration
global_cooldown = gcd_duration
cooldown_started.emit(ability_id, duration)
func _physics_process(delta: float) -> void:
# Tick cooldowns
for ability_id in cooldowns.keys():
if cooldowns[ability_id] > 0.0:
cooldowns[ability_id] -= delta
if cooldowns[ability_id] <= 0.0:
ability_ready.emit(ability_id)
# Tick GCD
if global_cooldown > 0.0:
global_cooldown -= delta
func has_resources(caster: Node, ability: Ability) -> bool:
return (caster.mana >= ability.mana_cost and
caster.stamina >= ability.stamina_cost and
caster.health > ability.health_cost)
func consume_resources(caster: Node, ability: Ability) -> void:
caster.mana -= ability.mana_cost
caster.stamina -= ability.stamina_cost
caster.health -= ability.health_cost---
Advanced Patterns
Combo System
# combo_tracker.gd
extends Node
var combo_chain: Array[String] = []
var combo_window: float = 2.0 # Seconds to continue combo
var last_ability_time: float = 0.0
func register_ability_use(ability_id: String) -> void:
var current_time := Time.get_ticks_msec() * 0.001
# Reset if too much time passed
if current_time - last_ability_time > combo_window:
combo_chain.clear()
combo_chain.append(ability_id)
last_ability_time = current_time
# Check for combo completion
check_combos()
func check_combos() -> void:
# Example: "slash" → "slash" → "spin" = "whirlwind"
if combo_chain.size() >= 3:
var last_three := combo_chain.slice(-3)
if last_three == ["slash", "slash", "spin"]:
trigger_combo_ability("whirlwind")
combo_chain.clear()
func trigger_combo_ability(combo_id: String) -> void:
# Execute powerful combo ability
passCharge-Based Abilities
# charge_ability.gd - Abilities with multiple charges (like League of Legends Flash)
class_name ChargeAbility
extends Ability
@export var max_charges: int = 2
@export var charge_recharge_time: float = 20.0
var current_charges: int = max_charges
var recharge_timer: float = 0.0
func can_cast(caster: Node) -> bool:
return current_charges > 0
func execute(caster: Node, target: Node = null) -> void:
current_charges -= 1
# Start recharging if not at max
if current_charges < max_charges and recharge_timer == 0.0:
recharge_timer = charge_recharge_time
func tick(delta: float) -> void:
if recharge_timer > 0.0:
recharge_timer -= delta
if recharge_timer <= 0.0:
current_charges += 1
if current_charges < max_charges:
recharge_timer = charge_recharge_time # Continue recharging
else:
recharge_timer = 0.0---
Skill Tree System
Skill Node
# skill_node.gd
class_name SkillNode
extends Resource
@export var skill_id: String
@export var display_name: String
@export var description: String
@export var icon: Texture2D
@export_group("Requirements")
@export var prerequisites: Array[String] = [] # Other skill_ids
@export var character_level_required: int = 1
@export var points_required: int = 1
@export var mutually_exclusive_with: Array[String] = [] # Can't have both
@export_group("Progression")
@export var max_rank: int = 1
@export var current_rank: int = 0
@export_group("Effects")
@export var unlocks_ability: String = "" # Ability ID to grant
@export var stat_bonuses: Dictionary = {} # "strength": 5, "crit_chance": 0.05
func can_unlock(player_skills: Dictionary, player_level: int, available_points: int) -> bool:
# Already maxed
if current_rank >= max_rank:
return false
# Not enough points
if available_points < points_required:
return false
# Level requirement
if player_level < character_level_required:
return false
# Prerequisites
for prereq_id in prerequisites:
if not player_skills.has(prereq_id) or player_skills[prereq_id].current_rank == 0:
return false
# Mutual exclusivity
for exclusive_id in mutually_exclusive_with:
if player_skills.has(exclusive_id) and player_skills[exclusive_id].current_rank > 0:
return false
return true
func unlock() -> void:
current_rank += 1Skill Tree Manager
# skill_tree.gd
class_name SkillTree
extends Node
signal skill_unlocked(skill_id: String, rank: int)
signal points_changed(new_total: int)
var skills: Dictionary = {} # skill_id → SkillNode
var skill_points: int = 0
func add_skill(skill: SkillNode) -> void:
skills[skill.skill_id] = skill
func can_unlock_skill(skill_id: String, player_level: int) -> bool:
var skill := skills.get(skill_id) as SkillNode
if not skill:
return false
return skill.can_unlock(skills, player_level, skill_points)
func unlock_skill(skill_id: String, player_level: int) -> bool:
if not can_unlock_skill(skill_id, player_level):
return false
var skill := skills[skill_id]
skill.unlock()
skill_points -= skill.points_required
# Apply effects
apply_skill_effects(skill)
skill_unlocked.emit(skill_id, skill.current_rank)
points_changed.emit(skill_points)
return true
func apply_skill_effects(skill: SkillNode) -> void:
# Grant ability if specified
if skill.unlocks_ability != "":
var ability_manager := get_node("/root/AbilityManager")
# Register new ability
# Apply stat bonuses
var player := get_tree().get_first_node_in_group("player")
for stat_name in skill.stat_bonuses.keys():
var bonus = skill.stat_bonuses[stat_name]
player.set(stat_name, player.get(stat_name) + bonus)
func add_skill_points(amount: int) -> void:
skill_points += amount
points_changed.emit(skill_points)
func reset_tree(refund_points: bool = true) -> void:
var total_spent := 0
for skill in skills.values():
total_spent += skill.current_rank * skill.points_required
skill.current_rank = 0
if refund_points:
skill_points += total_spent
points_changed.emit(skill_points)---
Cooldown Strategies
Per-Ability Cooldown (Standard)
# Already shown in AbilityManager above
# Each ability has independent cooldownShared Cooldown (Hearthstone-style)
# All abilities of type "summon" share cooldown
var summon_cooldown: float = 0.0
func use_summon_ability(ability: Ability) -> void:
ability.execute()
summon_cooldown = 3.0 # All summons on 3s cooldownCharge System (Already shown above)
Multiple uses, recharges over time.
---
Edge Cases
Cooldown Persistence
# save_system.gd
func save_ability_cooldowns() -> Dictionary:
var data := {}
var current_time := Time.get_unix_time_from_system()
for ability_id in ability_manager.cooldowns.keys():
var remaining := ability_manager.cooldowns[ability_id]
if remaining > 0.0:
data[ability_id] = current_time + remaining # Absolute time
return data
func load_ability_cooldowns(data: Dictionary) -> void:
var current_time := Time.get_unix_time_from_system()
for ability_id in data.keys():
var end_time: float = data[ability_id]
var remaining := max(0.0, end_time - current_time)
ability_manager.cooldowns[ability_id] = remainingAnimation Lock
# Prevent ability spam during attack animations
func _on_animation_player_animation_started(anim_name: String) -> void:
if anim_name.begins_with("attack_"):
ability_manager.is_casting = true
func _on_animation_player_animation_finished(anim_name: String) -> void:
if anim_name.begins_with("attack_"):
ability_manager.is_casting = false---
Expert Techniques & Optimizations
1. Dependency Injection for Loose Coupling
Design your ability scenes so they have no hardcoded dependencies on the player context (e.g., getting parent nodes to reduce health). Instead, the parent context should inject itself or wire the signals.
2. Duck Typing for Hit Detection
Do not enforce strict class checks. Rely on duck-typing: if collision.get_collider().has_method("hit"): collision.get_collider().hit()
3. Group Broadcasting for AoE
For Area-of-Effect abilities, assign entities to groups. Process damage efficiently by calling get_tree().call_group("enemies", "apply_damage", 50) instead of looping manually.
---
Elite Godot 4.x Patterns
1. Advanced Status Effect System (Resource-Driven)
Status effects should be represented as custom Resource scripts. This allows them to be data containers with logic, encapsulated methods, and signals for data changes.
[!CAUTION]
When applying a status effect template to a character at runtime, you MUST use duplicate(true) to create a deep copy. Modifying a shared resource instance will apply changes to EVERY character using that template globally.# status_effect.gd
class_name StatusEffect extends Resource
@export var effect_name: String = "Unknown"
@export var duration: float = 5.0
@export var tick_rate: float = 1.0
var _time_since_last_tick: float = 0.0
var _elapsed_time: float = 0.0
func apply_effect(target: Node) -> void:
# Logic to apply effect (e.g., damage, stat change)
pass
func process_tick(target: Node, delta: float) -> bool:
_elapsed_time += delta
_time_since_last_tick += delta
if _time_since_last_tick >= tick_rate:
apply_effect(target)
_time_since_last_tick = 0.0
return _elapsed_time >= duration# status_effect_manager.gd
class_name StatusEffectManager extends Node
var active_effects: Array[StatusEffect] = []
func add_effect(effect_template: StatusEffect) -> void:
# Essential: duplicate to avoid global state pollution
active_effects.append(effect_template.duplicate(true))
func _process(delta: float) -> void:
# Backward iteration for safe removal
for i in range(active_effects.size() - 1, -1, -1):
var effect: StatusEffect = active_effects[i]
var is_finished: bool = effect.process_tick(get_parent(), delta)
if is_finished:
active_effects.remove_at(i)2. Networked Ability Prediction
To eliminate perceived lag in multiplayer, use a combination of local prediction and authoritative server validation via RPCs.
# ability_caster.gd
class_name AbilityCaster extends Node
@rpc("any_peer", "call_remote", "reliable")
func server_request_cast(target_pos: Vector3) -> void:
var sender_id := multiplayer.get_remote_sender_id()
# Authoritative check
if has_sufficient_resources():
consume_resources()
rpc("client_execute_cast", target_pos) # Confirm for everyone
else:
rpc_id(sender_id, "client_cancel_cast") # Reject prediction
@rpc("authority", "call_remote", "reliable")
func client_execute_cast(target_pos: Vector3) -> void:
if not is_multiplayer_authority():
_play_cast_animation() # Sync for observers
_spawn_projectile(target_pos)
@rpc("authority", "call_remote", "reliable")
func client_cancel_cast() -> void:
# Rollback local visuals/state
_cancel_animation()3. Skill Tree Visualizer (Editor Tooling)
Use @tool and GraphEdit to create visual auditing tools for skill dependencies and balance.
@tool
class_name SkillTreeVisualizer extends GraphEdit
@export var skill_database: Array[Resource] = []:
set(value):
skill_database = value
if Engine.is_editor_hint(): _rebuild_graph()
func _rebuild_graph() -> void:
clear_connections()
for child in get_children(): if child is GraphNode: child.queue_free()
# Instantiate GraphNodes and connect via connect_node(from, port, to, port)
# based on Resource dependency properties.Reference
- Master Skill: godot-master
Adapt: 2D to 3D
Expert guidance for migrating 2D games into the third dimension.
NEVER Do
- NEVER directly replace Vector2 with Vector3(x, y, 0) — This creates a "flat 3D" game with no depth gameplay. Add Z-axis movement or camera rotation to justify 3D.
- NEVER keep 2D collision layers — 2D and 3D physics use separate layer systems. You must reconfigure collision_layer/collision_mask for 3D nodes.
- NEVER forget to add lighting — 3D without lights is pitch black (unless using unlit materials). Add at least one DirectionalLight3D.
- NEVER use Camera2D follow logic in 3D — Camera3D needs spring arm or look-at logic. Direct position copying causes clipping and disorientation.
- NEVER assume same performance — 3D is 5-10x more demanding. Budget for lower draw calls, smaller viewport resolution on mobile.
- NEVER use the rotation property for complex 3D logic — 3D rotation uses Euler angles. Interpolating Euler angles causes unpredictable paths and Gimbal Lock. Always use
Quaternionfor 3D rotation interpolation or theBasismatrix for directional vectors. - NEVER ignore metric scaling — 3D physics and lighting assume 1 unit = 1 meter. Scaling models inside the engine introduces precision errors. Export assets from DCCs at the correct metric scale.
- NEVER disable physics interpolation when using custom camera follow scripts — Updating camera position in
_processto follow a body moving in_physics_processcauses jitter. UseNode3D.get_global_transform_interpolated()for smooth transforms.
---
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
sprite_plane.gd
Sprite3D billboard configuration and world-to-screen projection for placing 2D UI over 3D objects. Handles behind-camera detection.
vector_mapping.gd
Static utility for 2D→3D vector translation. The Y-to-Z rule: 2D Y (down) maps to 3D Z (forward). Essential for movement code.
crisp_projected_ui.gd
Projected 2D UI for 3D Objects mapping snippet. Replaces blurry text elements with true 2D Canvas space positioning projected from 3D space.
---
Node Conversion Matrix
| 2D Node | 3D Equivalent | Notes |
|---|---|---|
| CharacterBody2D | CharacterBody3D | Add Z-axis movement, rotate with mouse |
| RigidBody2D | RigidBody3D | Gravity now Vector3(0, -9.8, 0) |
| StaticBody2D | StaticBody3D | Collision shapes use Shape3D |
| Area2D | Area3D | Triggers work the same way |
| Sprite2D | MeshInstance3D + QuadMesh | Or use Sprite3D (billboarded) |
| AnimatedSprite2D | AnimatedSprite3D | Billboard mode available |
| TileMapLayer | GridMap | Requires MeshLibrary creation |
| Camera2D | Camera3D | Requires repositioning logic |
| CollisionShape2D | CollisionShape3D | BoxShape2D → BoxShape3D, etc. |
| RayCast2D | RayCast3D | target_position is now Vector3 |
---
Migration Steps
Step 1: Physics Layer Reconfiguration
# 2D collision layers are SEPARATE from 3D
# You must reconfigure in Project Settings → Layer Names → 3D Physics
# Before (2D):
# Layer 1: Player
# Layer 2: Enemies
# Layer 3: World
# After (3D) - same names, but different system
# In code, update all collision layer references:
# 2D version:
# collision_layer = 0b0001
# 3D version (same logic, different node):
var character_3d := CharacterBody3D.new()
character_3d.collision_layer = 0b0001 # Layer 1: Player
character_3d.collision_mask = 0b0110 # Detect Enemies + WorldStep 2: Camera Conversion
# ❌ BAD: Direct 2D follow logic
extends Camera3D
@onready var player: Node3D = $"../Player"
func _process(delta: float) -> void:
global_position = player.global_position # Clipping, disorienting!
# ✅ GOOD: Third-person camera with SpringArm3D
# Scene structure:
# Player (CharacterBody3D)
# └─ SpringArm3D
# └─ Camera3D
# player.gd
extends CharacterBody3D
@onready var spring_arm: SpringArm3D = $SpringArm3D
@onready var camera: Camera3D = $SpringArm3D/Camera3D
func _ready() -> void:
spring_arm.spring_length = 10.0 # Distance from player
spring_arm.position = Vector3(0, 2, 0) # Above player
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
spring_arm.rotate_y(-event.relative.x * 0.005) # Horizontal rotation
spring_arm.rotate_object_local(Vector3.RIGHT, -event.relative.y * 0.005) # Vertical
# Clamp vertical rotation
spring_arm.rotation.x = clamp(spring_arm.rotation.x, -PI/3, PI/6)Step 3: Movement Conversion
# 2D platformer movement
extends CharacterBody2D
const SPEED = 300.0
const JUMP_VELOCITY = -400.0
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity.y += gravity * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = JUMP_VELOCITY
var direction := Input.get_axis("left", "right")
velocity.x = direction * SPEED
move_and_slide()
# ✅ 3D equivalent (third-person platformer)
extends CharacterBody3D
const SPEED = 5.0
const JUMP_VELOCITY = 4.5
const GRAVITY = 9.8
@onready var spring_arm: SpringArm3D = $SpringArm3D
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity.y -= GRAVITY * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = JUMP_VELOCITY
# Movement relative to camera direction
var input_dir := Input.get_vector("left", "right", "forward", "back")
var camera_basis := spring_arm.global_transform.basis
var direction := (camera_basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
if direction:
velocity.x = direction.x * SPEED
velocity.z = direction.z * SPEED
# Rotate player to face movement direction
rotation.y = lerp_angle(rotation.y, atan2(-direction.x, -direction.z), 0.1)
else:
velocity.x = move_toward(velocity.x, 0, SPEED)
velocity.z = move_toward(velocity.z, 0, SPEED)
move_and_slide()---
Art Pipeline: Sprites → 3D Models
Option 1: Billboard Sprites (2.5D)
# Use Sprite3D for quick conversion
extends Sprite3D
func _ready() -> void:
texture = load("res://sprites/character.png")
billboard = BaseMaterial3D.BILLBOARD_ENABLED # Always face camera
pixel_size = 0.01 # Scale sprite in 3D spaceOption 2: Quad Meshes (Floating Sprites)
# Create textured quads
var mesh_instance := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(1, 1)
mesh_instance.mesh = quad
var material := StandardMaterial3D.new()
material.albedo_texture = load("res://sprites/character.png")
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.cull_mode = BaseMaterial3D.CULL_DISABLED # Show both sides
mesh_instance.material_override = materialOption 3: Full 3D Models (Blender/Asset Library)
# Import .glb, .fbx models
var character := load("res://models/character.glb").instantiate()
add_child(character)
# Access animations
var anim_player := character.get_node("AnimationPlayer")
anim_player.play("idle")---
Lighting Considerations
Minimum Lighting Setup
# Add to main scene
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-45, 30, 0)
sun.light_energy = 1.0
sun.shadow_enabled = true
add_child(sun)
# Ambient light
var env := WorldEnvironment.new()
var environment := Environment.new()
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color(0.3, 0.3, 0.4) # Subtle blue
environment.ambient_light_energy = 0.5
env.environment = environment
add_child(env)---
UI Adaptation
# ✅ GOOD: Keep 2D UI overlay
# Scene structure:
# Main (Node3D)
# ├─ WorldEnvironment
# ├─ DirectionalLight3D
# ├─ Player (CharacterBody3D)
# └─ CanvasLayer # 2D UI on top of 3D world
# └─ Control (HUD)
# UI remains 2D (Control nodes, Sprite2D for HUD elements)---
Performance Budgeting
2D vs 3D Performance
| Metric | 2D Budget | 3D Budget | Notes |
|---|---|---|---|
| Draw calls | 100-200 | 50-100 | Use fewer meshes |
| Vertices | Unlimited | 100K-500K | LOD important |
| Lights | N/A | 3-5 shadowed | Expensive |
| Transparent objects | Many | <10 | Sorting overhead |
| Particle systems | Many | 2-3 max | GPU godot-particles only |
Optimization Checklist
# 1. Use LOD for distant objects
var mesh_instance := MeshInstance3D.new()
mesh_instance.lod_bias = 1.0 # Lower detail sooner
# 2. Occlusion culling
# Use OccluderInstance3D for large walls/buildings
# 3. Reduce shadow distance
var sun := DirectionalLight3D.new()
sun.directional_shadow_max_distance = 50.0 # Don't render far shadows
# 4. Use unlit materials for distant objects
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED---
Input Scheme Changes
2D → 3D Input Mapping
# 2D: left/right for horizontal movement
Input.get_axis("left", "right")
# 3D: Add forward/back, use get_vector()
var input := Input.get_vector("left", "right", "forward", "back")
# Returns Vector2(horizontal, vertical) for 3D movement
# Configure in Project Settings → Input Map:
# forward: W, Up Arrow
# back: S, Down Arrow
# left: A, Left Arrow
# right: D, Right Arrow
# Mouse look (lock cursor)
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
rotate_camera(event.relative)---
Edge Cases
Physics Not Working
# Problem: Forgot to set collision layers for 3D
# Solution: Reconfigure layers
var body := CharacterBody3D.new()
body.collision_layer = 0b0001 # What AM I?
body.collision_mask = 0b0110 # What do I DETECT?Camera Clipping Through Walls
# SpringArm3D automatically pulls camera forward when obstructed
spring_arm.spring_length = 10.0
spring_arm.collision_mask = 0b0100 # Layer 3: WorldPlayer Falling Through Floor
# Problem: StaticBody3D floor has no CollisionShape3D
# Solution: Add collision
var floor_collision := CollisionShape3D.new()
var box_shape := BoxShape3D.new()
box_shape.size = Vector3(100, 1, 100)
floor_collision.shape = box_shape
floor.add_child(floor_collision)---
Decision Tree: When to Go 3D
| Factor | Stay 2D | Go 3D |
|---|---|---|
| Gameplay | Platformer, top-down, no depth needed | Exploration, first-person, 3D space combat |
| Art budget | Pixel art, limited resources | 3D models available or necessary |
| Performance target | Mobile, web, low-end | Desktop, console, high-end mobile |
| Development time | Limited | Have time for 3D learning curve |
| Team skills | 2D artists only | 3D artists or asset library |
---
Expert Techniques & Optimizations
1. Vector Math over Euler Angles
When moving a 3D character, rely heavily on Transform3D basis vectors rather than calculating trigonometric angles. To move forward locally, extract the negative Z-axis of your transform's basis: velocity = transform.basis.z * speed.
2. Understanding Coordinate Discrepancies
In 2D, the Y-axis points down. In 3D, Godot uses a right-handed system where Y-axis points UP, and forward is -Z. Translating 2D jumps to 3D requires inverting the Y velocity logic (e.g., velocity.y = JUMP_SPEED instead of -JUMP_SPEED).
3. 2.5D Navigation (Camera-Projected Paths)
For 2.5D games where actors move on a 3D floor but are displayed as 2D sprites, query the NavigationServer3D directly and project the resulting PackedVector3Array into 2D screen space (or a flattened gameplay plane) using Camera3D.unproject_position.
class_name NavigationBridge2D5D extends Node
## Projects 3D NavigationServer paths to 2D screenspace for 2.5D movement.
static func query_2_5d_path(camera: Camera3D, map_rid: RID, start_2d: Vector2, target_2d: Vector2) -> PackedVector2Array:
# 1. Project 2D screen points to the 3D ground plane (Y=0).
var start_3d := camera.project_position(start_2d, 0.0)
var target_3d := camera.project_position(target_2d, 0.0)
# 2. Query optimized 3D path.
var path_3d := NavigationServer3D.map_get_path(map_rid, start_3d, target_3d, true)
# 3. Project 3D world points back to 2D screenspace coordinates for the sprite.
var path_2d := PackedVector2Array()
for point in path_3d:
path_2d.append(camera.unproject_position(point))
return path_2d4. Shader-Based Billboarding (Massive Crowd Rendering)
To render millions of instances, use MultiMeshInstance3D paired with a custom Visual Shader. Use VisualShaderNodeBillboard with BILLBOARD_TYPE_FIXED_Y to ensure sprites stay upright on flat terrain.
class_name MassiveCrowdManager extends MultiMeshInstance3D
## Efficiently manages millions of camera-facing instances via GPU hardware.
func _ready() -> void:
# 1. Configure the MultiMesh for 3D transforms.
multimesh = MultiMesh.new()
multimesh.transform_format = MultiMesh.TRANSFORM_3D
multimesh.instance_count = 10000
# 2. Build a ShaderMaterial using VisualShaderNodeBillboard.
var material := ShaderMaterial.new()
# Note: Logic assumes billboard_type=BILLBOARD_TYPE_FIXED_Y and keep_scale=true.
multimesh.mesh = QuadMesh.new()
multimesh.mesh.surface_set_material(0, material)
# 3. Populate transforms. The GPU handles orientation.
for i in range(multimesh.instance_count):
var pos := Vector3(randf() * 100, 0, randf() * 100)
multimesh.set_instance_transform(i, Transform3D(Basis(), pos))5. Lighting Migration Tool (2D to 3D Converter)
A robust EditorScript for mapping PointLight2D properties to OmniLight3D. Uses EditorInterface.get_edited_scene_root() to ensure changes are tracked by the editor.
@tool
class_name LightMigrationTool extends EditorScript
## Converts PointLight2D nodes in the active scene to OmniLight3D.
func _run() -> void:
var root := EditorInterface.get_edited_scene_root()
if not root: return
_migrate_node(root)
func _migrate_node(node: Node) -> void:
if node is PointLight2D:
var l3d := OmniLight3D.new()
l3d.light_color = node.color
l3d.light_energy = node.energy
# Approximate 3D range from 2D texture radius * scale
var radius := 128.0 # Default fallback
if node.texture: radius = node.texture.get_width() / 2.0
l3d.omni_range = radius * node.texture_scale
# Mapping 2D (x, y) to 3D (x, y, height)
l3d.position = Vector3(node.position.x, node.position.y, node.height)
node.get_parent().add_child(l3d)
l3d.owner = EditorInterface.get_edited_scene_root()
l3d.name = node.name + "_3D"
for child in node.get_children():
_migrate_node(child)Reference
- Master Skill: godot-master
📜 Aurelius Expert Audit Standards (Godot 4.6+)
This document defines the technical benchmarks for the Aurelius Protocol. These standards represent the "Gold Standard" for professional-grade Godot 4.6 development.
---
🏛️ Architectural Integrity (The "Foundational Pillars")
1. The Bridge Pattern (UI-to-Logic)
- Standard: Direct node references between UI and Game Logic are PROHIBITED.
- Expert Pattern: Use a "Bridge" Resource or a dedicated "UIController" that listens for Signal Bus events.
- Reasoning: Decouples UI skinning from core mechanics, allowing for easy UI redesigns without breaking game logic.
2. Signal Topology (The "Signal-Up" Mandate)
- Standard: Signals must flow UP the scene tree. Calls must flow DOWN.
- Violation: A child node calling
get_parent().update_score(). - Correct Protocol: Child emits
score_changed(delta); Parent connects to its child and handles the method call.
3. Composition via Node-Components
- Standard: Favor shallow inheritance (max 3 levels). Use "Actor Components" (Node-based) for reusable behaviors.
- Expert Pattern:
HitboxComponent,HealthComponent,AIControllerComponent.
---
⚡ Performance Protocol (Godot 4.6 Nuances)
1. The "Main Thread" Sanctuary
- Standard: Any operation taking > 2ms (e.g., massive JSON parsing, long-distance pathfinding) MUST be offloaded.
- Expert Pattern: Use
WorkerThreadPool.add_task()for data-heavy tasks. UseThreadonly for dedicated long-running background loops.
2. RID-Level Management (Rendering Slop)
- Standard: Direct
RenderingServercalls for thousands of objects instead of тысячиSprite2Dnodes. - Expert Pattern: Use
RenderingServer.canvas_item_create()and RIDs for high-density particle/projectile systems outside of GPUParticles.
3. Type Safety & Hashing
- Standard: Typed Dictionaries and Arrays for ALL public APIs.
- Expert Pattern: Use
StringName(&"name") for all dictionary keys, signal names, and animation calls to avoid redundant hashing at runtime.
---
🛡️ Never vs Always (Expert Checklist)
| Topic | ❌ NEVER (Legacy Slop) | ✅ ALWAYS (Expert Protocol) |
|---|---|---|
| Signals | connect("string", ...) | signal_object.connect(callable) |
| Containers | var data := {} | var data: Dictionary[int, Resource] = {} |
| Nodes | get_node("../Sibling") | @export var sibling: Node |
| Strings | var x = "name" | var x = &"name" (StringName) |
| Timers | get_tree().create_timer() | Reusable Timer node or manual delta accumulation. |
| Loading | load("res://path") | preload("res://path") or ResourceLoader background tasks. |
--- Reference version 2.0.0 | Aurelius Protocol Authorized | Godot 4.6+ Verified
Related skills
FAQ
What does godot-master do?
Expert patterns for 2D animation in Godot using AnimatedSprite2D and skeletal cutout rigs. Use when implementing sprite frame animations, procedural animation (squash/stretch), cutout bone hierarchies
When should I use godot-master?
During operate infra work for cloud & infrastructure.
Is godot-master safe to install?
Review the Security Audits panel on this listing before production use.