godot-2d-physics

Expert patterns for Godot 2D physics including collision layers/masks, Area2D triggers, raycasting, and PhysicsDirectSpaceState2D queries. Use when implementing collision detection, trigger zones, line-of-sight systems, or manual physics queries. Trigger keywords: CollisionShape2D, CollisionPolygon2D, collision_layer, collision_mask, set_collision_layer_value, set_collision_mask_value, Area2D, body_entered, body_exited, RayCast2D, force_raycast_update, PhysicsPointQueryParameters2D, PhysicsShapeQueryParameters2D, direct_space_state, move_and_collide, move_and_slide.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "godot-2d-physics" with this command: npx skills add thedivergentai/gd-agentic-skills/thedivergentai-gd-agentic-skills-godot-2d-physics

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.
  • NEVER confuse collision_layer with collision_mask — Layer = "What AM I?", Mask = "What do I DETECT?". Setting both to the same value is almost always wrong.
  • NEVER multiply velocity by delta when using move_and_slide() — move_and_slide() automatically includes timestep in calculations. Only multiply gravity (acceleration) by delta.
  • NEVER forget to call force_raycast_update() for manual raycasts — Raycasts update once per physics frame. If you change target_position/rotation mid-frame, you MUST call force_raycast_update().
  • NEVER use get_overlapping_bodies() every frame — Cache results with body_entered/body_exited signals instead. Continuous queries are expensive and unnecessary.

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.


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 false

Multipa 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 *= -1

Raycast 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].collider

Shape 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 CaseMethodWhy
Continuous trigger zoneArea2D + signalsMemory of what's inside, signals are efficient
One-time pickup (coin)Area2D + queue_free() on enterSimple, automatic cleanup
Line-of-sight checkRayCast2DEfficient, built-in
Click-to-select unitsPhysicsPointQueryParameters2DSingle query, no permanent node
AOE spellPhysicsShapeQueryParameters2DOne-shot query, flexible shape
Instant-hit weaponPhysicsRayQueryParameters2DHitscan, no projectile physics
Platformer ground checkRayCast2D or raycast downPrecise 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: Player

Raycast 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

Reference

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Automation

godot-master

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

godot-shaders-basics

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

godot-ui-theming

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

godot-particles

No summary provided by upstream source.

Repository SourceNeeds Review