Can you write this from memory?
Spawn a bullet at the player's position in the game world. Instantiate BulletScene, set its global_position to the current global_position, and add it as a child of get_parent(). Use a variable called bullet.
Projectiles combine several Godot fundamentals: scene instancing, Vector2 direction math, collision detection, and cleanup.
This page gives you a "default" projectile pattern you can adapt: spawn bullet → set direction/speed → move in _physics_process → detect hit via Area2D → free after impact or lifetime.
Before diving into code, here's the standard scene structure:
Bullet (Area2D)
├── CollisionShape2D # CircleShape2D or CapsuleShape2D
├── Sprite2D # Your bullet graphic
├── VisibleOnScreenNotifier2D # Optional: cleanup off-screen
└── Timer # Optional: lifetime (alternative to counter)
1. Node2D + Manual Movement (Simplest)
extends Node2D
var direction := Vector2.RIGHT
var speed := 800.0
func _process(delta: float) -> void:
position += direction * speed * delta
No collision shape—relies on raycasting or manual overlap checks.
2. Area2D Hitbox (Most Common)
extends Area2D
var direction := Vector2.RIGHT
var speed := 800.0
var lifetime := 0.0
const MAX_LIFETIME := 3.0
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
global_position += direction * speed * delta
lifetime += delta
if lifetime >= MAX_LIFETIME:
queue_free()
func _on_body_entered(body: Node) -> void:
if body.is_in_group(&"damageable") and body.has_method(&"take_damage"):
body.call(&"take_damage", 1)
queue_free()
Why _physics_process? Area2D signals (body_entered) fire based on physics overlaps. Using _physics_process keeps movement synchronized with collision detection—the same reason all movement code belongs there. _process runs at variable rates and after the physics step—you can get "moved but physics didn't see it yet" weirdness.
Why has_method check? Prevents hard errors if something enters the group but doesn't implement take_damage(). Makes your bullet reusable across projects.
Signal not firing? Make sure
monitoring = trueon your Area2D—it's the #1 cause of "my bullet never hits." See collision debugging for the full checklist.
3. CharacterBody2D / RigidBody2D (Rare)
Use CharacterBody2D only if you need move_and_slide() behavior (bouncing, sliding). Usually overkill.
RigidBody2D can work with Continuous Collision Detection (continuous_cd property) for fast-moving physics objects. CCD sweeps the body's path to detect collisions it would otherwise tunnel through. It's slower and not perfect—Godot forums report it still misses in edge cases. For most games, Area2D + raycast is more reliable.
If a bullet is fast and the target is thin, the bullet can "teleport" through:
Frame 1: Bullet here ●
Frame 2: Bullet here ●
(Target was in between, but never overlapped)
This is a physics frame-rate issue, not a layer/mask issue.
Fix 1: RayCast2D (Point-Sized Bullets)
Cast a ray in the direction of travel before moving:
# Raycast solution
func _physics_process(delta: float) -> void:
var motion := direction * speed * delta
raycast.target_position = motion
raycast.force_raycast_update()
if raycast.is_colliding():
hit(raycast.get_collider())
queue_free()
else:
global_position += motion
Fix 2: ShapeCast2D (Wide Bullets / Lasers)
RayCast2D checks a line; ShapeCast2D sweeps a shape along a path. Use it for:
- Wide projectiles (missiles, grenades)
- Laser beams
- Detecting multiple targets at once (
get_collision_count())
# ShapeCast solution for wide projectiles
func _physics_process(delta: float) -> void:
var motion := direction * speed * delta
shape_cast.target_position = motion
shape_cast.force_shapecast_update()
if shape_cast.is_colliding():
# ShapeCast can detect multiple objects
for i in shape_cast.get_collision_count():
var collider := shape_cast.get_collider(i)
hit(collider)
queue_free()
else:
global_position += motion
Fix 3: Longer Collision Shape
Make the bullet's CollisionShape2D a capsule stretched in the direction of travel. Simple but less accurate than raycasting.
# In the shooter script
const BulletScene: PackedScene = preload("res://scenes/bullet.tscn")
func shoot(direction: Vector2) -> void:
var bullet := BulletScene.instantiate() as Area2D
get_tree().current_scene.add_child(bullet)
bullet.global_position = muzzle.global_position
bullet.init(direction, 900.0)
# In the bullet script
func init(dir: Vector2, spd: float) -> void:
direction = dir.normalized()
speed = spd
rotation = direction.angle()
Shotgun Spread
Spawn multiple bullets with angle offsets:
func shoot_shotgun(base_direction: Vector2, pellet_count: int = 5, spread_angle: float = 30.0) -> void:
var half_spread := deg_to_rad(spread_angle / 2.0)
var step := deg_to_rad(spread_angle) / (pellet_count - 1) if pellet_count > 1 else 0.0
for i in pellet_count:
var angle_offset := -half_spread + step * i
var pellet_dir := base_direction.rotated(angle_offset)
spawn_bullet(pellet_dir)
Burst Fire
Spawn N bullets over time using a timer or coroutine:
func shoot_burst(direction: Vector2, count: int = 3, delay: float = 0.1) -> void:
for i in count:
spawn_bullet(direction)
await get_tree().create_timer(delay).timeout
Spiral Pattern
Rotate direction over time (boss patterns, bullet hell):
var spiral_angle := 0.0
func _on_fire_timer_timeout() -> void:
var dir := Vector2.RIGHT.rotated(spiral_angle)
spawn_bullet(dir)
spiral_angle += deg_to_rad(15) # 15° per shot
When to Use GDScript Projectiles
- Any ranged attack: bullets, arrows, fireballs, enemy shots
- Vampire Survivors-style auto-attacks where you spawn many short-lived projectiles
- Boss patterns (spreads, bursts, spirals) where a consistent projectile base saves time
Check Your Understanding: GDScript Projectiles
Check Your Understanding: How do you avoid instancing thousands of bullets causing stutter?
Start by keeping bullet scenes lightweight (few nodes, simple scripts). If instancing/freeing still spikes, use pooling: maintain a list of inactive bullets. When "freeing," disable monitoring + hide + move off-screen + return to pool. When "spawning," reset position/direction, enable monitoring, show, remove from pool. Also cap fire rates, spread spawns across frames, and avoid per-bullet expensive work like get_nodes_in_group() each frame.
What You'll Practice: GDScript Projectiles
Common GDScript Projectiles Pitfalls
- Using _process instead of _physics_process for bullet movement—Area2D signals are physics-based, causing sync issues
- body_entered not firing because monitoring = false (check this first!)
- Forgetting delta when moving bullets—speed becomes frame-rate dependent
- Calling body.take_damage() without has_method check—hard error if target doesn't implement it
- Bullets hitting the shooter because collision layers/masks weren't configured
- Spawning bullets and never freeing them—memory/performance leak
- Fast bullets tunneling through thin targets—use RayCast2D/ShapeCast2D or longer collision shapes
GDScript Projectiles FAQ
Should a bullet be Area2D or CharacterBody2D?
Area2D is usually best: simple overlap events, easy cleanup, no physics response. Use CharacterBody2D only if you need move_and_slide behavior (bouncing off walls, sliding). RigidBody2D with continuous_cd can work but is slower and still misses edge cases.
Why is body_entered not firing on my bullet?
Check that monitoring = true on your Area2D—it's the most common cause. Also verify: (1) CollisionShape2D exists and is enabled, (2) collision layers/masks match the target, (3) you're using body_entered for PhysicsBody2D targets (use area_entered for Area2D). See the collisions page for the full debug checklist.
Should I use _process or _physics_process for bullets?
_physics_process. Area2D overlap signals fire based on physics steps. Using _process causes the bullet to move out of sync with collision detection, leading to "moved but physics didn't see it" issues. Keep all movement and collision code in _physics_process.
How do I prevent bullets from hitting the shooter?
Options: (1) spawn bullet outside shooter's collider, (2) set collision masks to ignore shooter layer, (3) store owner reference and check in callback: if body == owner: return.
How do I ensure bullets don't live forever?
Always add a max lifetime: either a Timer node (connect timeout to queue_free) or an elapsed-time counter in _physics_process. queue_free() when lifetime expires.
How do I aim a projectile at a moving target?
For basic aiming: direction = global_position.direction_to(target.global_position). For leading shots, predict where the target will be based on their velocity and bullet travel time.
Why does my projectile miss at high speed?
Tunneling: the bullet moves so far per frame it skips over thin targets. Solutions: (1) RayCast2D to check the travel path each frame, (2) ShapeCast2D for wide projectiles, (3) longer collision shape (capsule), (4) RigidBody2D with continuous_cd (slower, not perfect).
When should I use ShapeCast2D instead of RayCast2D?
RayCast2D checks a line—good for point-sized bullets. ShapeCast2D sweeps a shape along a path—use it for wide projectiles (missiles, grenades), laser beams, or when you need to detect multiple objects along the path (get_collision_count).
How do I rotate the sprite to face direction?
Set rotation = direction.angle() when initializing. For sprites that face right by default, this works directly. For other orientations, add an offset.
Can bullets hit TileMap walls?
Yes. body_entered receives a TileMap if the TileSet has physics collision shapes configured. Make sure your bullet's collision mask includes the TileMap's layer.
GDScript Projectiles Syntax Quick Reference
const BulletScene: PackedScene = preload("res://scenes/bullet.tscn")
func shoot(dir: Vector2) -> void:
var bullet := BulletScene.instantiate() as Area2D
get_tree().current_scene.add_child(bullet)
bullet.global_position = muzzle.global_position
bullet.init(dir, 900.0)extends Area2D
var direction := Vector2.RIGHT
var speed := 800.0
var lifetime := 0.0
const MAX_LIFETIME := 3.0
func init(dir: Vector2, spd: float) -> void:
direction = dir.normalized()
speed = spd
rotation = direction.angle()
func _ready() -> void:
body_entered.connect(_on_hit)
func _physics_process(delta: float) -> void:
global_position += direction * speed * delta
lifetime += delta
if lifetime >= MAX_LIFETIME:
queue_free()
func _on_hit(body: Node) -> void:
if body.is_in_group(&"damageable") and body.has_method(&"take_damage"):
body.call(&"take_damage", 1)
queue_free()extends Area2D
@onready var raycast := $RayCast2D
var direction := Vector2.RIGHT
var speed := 1200.0
func _physics_process(delta: float) -> void:
var motion := direction * speed * delta
raycast.target_position = motion
raycast.force_raycast_update()
if raycast.is_colliding():
hit(raycast.get_collider())
queue_free()
else:
global_position += motionfunc shoot_shotgun(base_dir: Vector2, pellets: int = 5, spread: float = 30.0) -> void:
var half := deg_to_rad(spread / 2.0)
var step := deg_to_rad(spread) / (pellets - 1) if pellets > 1 else 0.0
for i in pellets:
var angle := -half + step * i
spawn_bullet(base_dir.rotated(angle))var owner_ref: Node = null
func _on_hit(body: Node) -> void:
if body == owner_ref:
return
# Handle hit...GDScript Projectiles Sample Exercises
Define a callback for projectile-to-enemy hitbox overlap detection. Name the function `_on_area_entered`. The function should receive an Area2D parameter and destroy the bullet.
func _on_area_entered(area):
queue_free()Initialize closest as null and best_dist as INF for nearest-enemy search.
var closest = null
var best_dist = INFIf dist < best_dist, update best_dist and closest. (Assume dist and enemy exist.)
if dist < best_dist:
best_dist = dist
closest = enemy+ 24 more exercises