Lesson 5: Combat System and Weapons
In Lesson 4 you made the player feel good to move. Now it is time to give that movement a purpose by adding combat.
This lesson focuses on a single outcome: you will have a player that can perform an attack, hit enemies, and apply damage in a way that is easy to extend later with new weapons or abilities. You will start with a simple melee swing, wire up hit detection, and add just enough feedback (sound, effects, and camera shake hooks) to make hits feel satisfying.
What You Will Build
By the end of this lesson you will have:
- A basic weapon or attack component attached to the player.
- A hitbox that activates during an attack and checks for enemies.
- A simple health component for enemies that takes damage and triggers death.
- Enough hooks for feedback (sound, particles, screen shake) to polish in later lessons.
You will keep things intentionally small. The goal is a clean pattern you can reuse instead of a giant “do everything” script.
Step 1 – Decide your combat style for this project
Before writing code, choose a direction that matches your game concept:
- Close-range melee (sword, punch, claws) using a short hitbox in front of the player.
- Ranged projectiles (bullets, fireballs) spawned from the player and moving forward.
- Hybrid (melee first, ranged later) where you start with melee and add ranged in a future lesson.
For this lesson you will implement melee first, because it is easier to visualise and teaches the patterns you will reuse for projectiles.
Later, you can create a Projectile scene that follows the same damage interface.
Write down:
- What your primary attack is (slash, punch, blast).
- How fast you want attacks to be.
- Whether you want to allow attacking in the air or only on the ground.
You will encode those decisions as small configuration values, not hard-coded magic.
Step 2 – Create a reusable health component for enemies
Start with something enemies can lose when hit.
Create a script called health.gd that you can attach to any enemy:
extends Node
@export var max_health: int = 3
var current_health: int
signal died
signal damaged(amount: int)
func _ready() -> void:
current_health = max_health
func apply_damage(amount: int) -> void:
if amount <= 0 or current_health <= 0:
return
current_health -= amount
emit_signal("damaged", amount)
if current_health <= 0:
current_health = 0
emit_signal("died")
Attach this script to a Node (or directly to the enemy root) in your enemy scene.
Connect the died signal to logic that plays a death animation, spawns particles, and removes the enemy from the scene.
For now you can keep it simple:
- On
died, callqueue_free()on the enemy scene root. - Optionally, play a sound or spawn a small particle effect node.
Step 3 – Add an attack hitbox to the player
Next, you need a hitbox that appears only while the attack is active and checks for overlapping enemies.
In your Player scene:
- Add an
Area2Das a child of the player (for exampleAttackArea). - Add a
CollisionShape2DtoAttackAreaand shape it as a short rectangle in front of the player. - Set the
CollisionLayer/CollisionMaskso the attack only checks enemy bodies or areas.
You can start with the hitbox always visible in the editor, then later toggle its visibility or even move it based on facing direction.
Create a script on AttackArea:
extends Area2D
@export var damage: int = 1
var _active: bool = false
func _ready() -> void:
monitoring = false
connect("body_entered", _on_body_entered)
func start_attack_window() -> void:
_active = true
monitoring = true
func end_attack_window() -> void:
_active = false
monitoring = false
func _on_body_entered(body: Node) -> void:
if not _active:
return
if body.has_method("apply_damage"):
body.apply_damage(damage)
elif body.has_node("Health"):
var health = body.get_node("Health")
if health.has_method("apply_damage"):
health.apply_damage(damage)
This script assumes that either the enemy root has an apply_damage method or has a child node called Health using the component you created in Step 2.
You can adapt the lookup to match your enemy scene structure.
Step 4 – Wire attack input and animation timing
Now you will connect the hitbox to player input so that:
- Pressing an attack button triggers an animation.
- During a small window of that animation, the attack area becomes active.
First, add an attack action to the Input Map:
- Go to Project → Project Settings → Input Map.
- Add an action called
attack. - Bind it to a key (for example
J,Space, or a gamepad button).
In your Player script (from Lesson 4), add some new state:
@export var attack_cooldown: float = 0.3
@export var attack_duration: float = 0.15
var _attack_timer: float = 0.0
var _can_attack: bool = true
var _is_attacking: bool = false
@onready var _attack_area: Area2D = $AttackArea
Update your _physics_process (or equivalent) to handle attacks:
func _physics_process(delta: float) -> void:
_handle_attack(delta)
if _is_attacking:
_handle_attack_movement(delta)
else:
_handle_normal_movement(delta)
Implement the helpers:
func _handle_attack(delta: float) -> void:
if _is_attacking:
_attack_timer -= delta
if _attack_timer <= 0.0:
_end_attack()
return
if not _can_attack:
_attack_timer -= delta
if _attack_timer <= 0.0:
_can_attack = true
return
if Input.is_action_just_pressed("attack") and _can_attack:
_start_attack()
func _start_attack() -> void:
_is_attacking = true
_can_attack = false
_attack_timer = attack_duration
if _attack_area:
_attack_area.start_attack_window()
# TODO: trigger attack animation and sound here
func _end_attack() -> void:
_is_attacking = false
_attack_timer = attack_cooldown
if _attack_area:
_attack_area.end_attack_window()
Keep your existing movement logic inside _handle_normal_movement.
In _handle_attack_movement you can either lock movement completely or allow slow movement while attacking.
Step 5 – Give enemies a simple body to hit
To test the system quickly, create a very simple enemy:
- Create a new scene
EnemywithCharacterBody2DorArea2Das root. - Add a sprite and collision shape.
- Add a node called
Healthand attach yourhealth.gdscript. - Optionally, give the enemy a basic patrol script or let it stand still for now.
Make sure the enemy’s collision layer/mask matches what your AttackArea is checking.
Drop a few enemies into your main level room and run the game.
When you press the attack button near an enemy you should see:
- The attack animation or at least a short pause as the attack window runs.
- Enemies disappearing when their health reaches zero.
If nothing happens, check:
- That
AttackArea’sbody_enteredsignal is connected. - That enemies have the
Healthnode orapply_damagemethod. - That your collision layers and masks are configured correctly.
Step 6 – Add basic feedback so hits feel good
Even a mechanically correct combat system will feel weak without feedback. You do not need final assets yet; just enough cues for the player’s brain to register “I hit something”.
Consider adding:
- A short hit sound (connected to the
damagedsignal inhealth.gd). - A quick flash or tint change on the enemy when taking damage.
- A tiny camera shake on strong hits (you can add the implementation in a later lesson).
- A small particle effect that spawns at the hit position.
In health.gd you can connect the damaged signal in the enemy scene to a method that:
- Plays a sound from an
AudioStreamPlayer2D. - Tweens the sprite’s modulation to white or red then back.
The important part here is to keep these feedback hooks separate from the core damage logic so you can iterate without breaking combat rules.
Step 7 – Mini challenge: build a combat test room
To make sure your combat system really works, create a small “combat lab” room:
- Place the player and a handful of enemies at different distances.
- Add simple platforms or obstacles so you can test attacking from different heights.
- Experiment with attack speed, damage, and cooldown values.
Try these variations:
- A heavy attack with slower wind-up but more damage.
- A quick jab attack that does low damage but can be chained rapidly.
- Enemies with more health so you can feel the rhythm of multiple hits.
Record any values that feel good in comments or a small design document so you can reuse them when balancing the full game.
Troubleshooting and common mistakes
Attack does nothing when I press the button
- Confirm the
attackaction is defined in the Input Map and bound to a key or button. - Ensure
_start_attackis actually called (add aprint()temporarily if needed).
Hits never register on enemies
- Check that the
AttackAreacollision mask includes the enemy layer. - Verify that enemies either expose
apply_damageor have aHealthnode with that method. - Make sure
start_attack_windowis called andmonitoringis set totrue.
Enemies die instantly or never die
- Inspect
max_healthon theHealthcomponent anddamageonAttackArea. - Confirm you are not calling
apply_damagemultiple times in a single frame accidentally.
Combat feels unresponsive
- Reduce
attack_cooldownand fine tuneattack_duration. - Allow some limited movement while attacking so the player does not feel locked.
- Add more obvious animation, sound, and screen feedback for successful hits.
Recap and next steps
In this lesson you:
- Designed a simple, reusable pattern for 2D combat in Godot 4.
- Created a health component, attack hitbox, and attack timing logic.
- Wired up enemies so they can take damage and die.
- Added the first layer of feedback to make hits feel satisfying.
In Lesson 6: Enemy AI and Behavior Systems, you will give your enemies more life by adding patrols, chase logic, and attack behaviours so your new combat system has something interesting to push against.
Keep your combat test room handy — it will be invaluable for iterating quickly as AI and level design come online.