godot-2d-physics
2D Physics
Expert guidance for collision detection, triggers, and raycasting in Godot 2D.
NEVER Do
- NEVER scale
CollisionShape2Dnodes — 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_layerwithcollision_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 withbody_entered/body_exitedsignals instead [16]. - NEVER modify
RigidBody2Dstate directly in_process— Use_integrate_forces()for safe, synchronized access toPhysicsDirectBodyState2D[17, 411]. - NEVER move
PhysicsBody2Dnodes in_process()— Use_physics_process(). Moving bodies outside the physics step causes stutter and unreliable collision detection. - NEVER use
RigidBody2Dfor 1000+ simple entities — UsePhysicsServer2Dto bypass node overhead for massive performance gains (Swarms/Bullets) [18, 397]. - NEVER use
Area2Dfor high-frequency blocking (Bullets) — Area signals can be delayed. Usemove_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_deferredfor immediate physics transform logic — It happens at the end of the frame. Useforce_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
PhysicsServer2DRIDs 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 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 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: 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
Expert Techniques & Optimizations
1. Low-Level Servers for Massive Swarms
If you are dealing with tens of thousands of projectiles or physics objects, the SceneTree node overhead will bottleneck the CPU. Bypass the SceneTree entirely by using PhysicsServer2D and RenderingServer to create, move, and draw bodies directly in C++ or GDScript.
2. Physics Interpolation
If your game uses a low physics tick rate to save CPU cycles (causing visible jitter), enable Physics Interpolation in the Project Settings. This keeps the physics tick rate low but interpolates visual transforms smoothly over rendered frames.
3. Safe RigidBody2D Integration
extends RigidBody2D
var thrust := Vector2(0, -250)
var torque := 20000.0
# According to the RigidBody2D documentation, we must use _integrate_forces
# to safely modify physical state without fighting the physics server.
func _integrate_forces(state: PhysicsDirectBodyState2D) -> void:
if Input.is_action_pressed("ui_up"):
# Apply force taking current rotation into account
state.apply_force(thrust.rotated(rotation))
else:
state.apply_force(Vector2.ZERO)
var rotation_dir := Input.get_axis("ui_left", "ui_right")
state.apply_torque(rotation_dir * torque)
Reference
- Master Skill: godot-master