Lesson Goal
In Lesson 3 you built clean scene ownership and signal flow.
Now you will ship your first real gameplay loop: move, attack, receive damage, and recover with readable combat feedback.
By the end of this lesson, you will:
- implement responsive top-down movement with acceleration and friction
- add a simple attack state with a timed hitbox window
- apply consistent damage + knockback rules
- validate a mini combat arena that is stable enough for AI in the next lesson
Step 1 - Lock Input Actions and Controller Paths
Before changing player code, confirm your input map is stable:
move_left,move_right,move_up,move_downattack_primary- optional
dash(stub only for now)
Keep keyboard and controller mappings both active. This prevents future rework when you QA controller support.
Mini task:
Open Project Settings -> Input Map and verify every action has at least one keyboard and one gamepad binding.
Step 2 - Build Responsive Movement with CharacterBody2D
Use acceleration toward target velocity and friction toward zero velocity for cleaner feel than instant speed snaps.
extends CharacterBody2D
@export var max_speed: float = 180.0
@export var acceleration: float = 1100.0
@export var friction: float = 1200.0
func _physics_process(delta: float) -> void:
var input_vector := Input.get_vector("move_left", "move_right", "move_up", "move_down")
var target_velocity := input_vector * max_speed
if input_vector != Vector2.ZERO:
velocity = velocity.move_toward(target_velocity, acceleration * delta)
else:
velocity = velocity.move_toward(Vector2.ZERO, friction * delta)
move_and_slide()
Tune numbers in editor while running. Do not tune from memory.
Pro tip:
Use lower friction than acceleration only if you want slight glide. For tighter combat readability, keep them close.
Step 3 - Add a Minimal Combat State Boundary
Start with three states:
idlemoveattack
When entering attack, lock movement for a short window (for example 0.18-0.25s), then return to idle or move.
This protects you from "attack while turning while receiving hit while interaction prompt opens" chaos.
Common mistake:
Trying to solve everything with animation tree first. Get logic stable, then layer animation complexity.
Step 4 - Time the Hitbox Window Instead of Permanent Damage Areas
Attach an Area2D hitbox to the player weapon node and toggle monitoring only during the active attack frames.
Suggested timeline:
- startup (wind-up, no damage)
- active (hitbox on)
- recovery (hitbox off, movement still locked briefly)
This makes combat readable and easier to tune per weapon later.
func begin_attack() -> void:
state = "attack"
$WeaponHitbox.monitoring = false
await get_tree().create_timer(0.08).timeout # startup
$WeaponHitbox.monitoring = true
await get_tree().create_timer(0.10).timeout # active
$WeaponHitbox.monitoring = false
await get_tree().create_timer(0.08).timeout # recovery
state = "idle"
Step 5 - Apply Damage and Knockback Consistently
Set a single damage contract so enemies and player can share patterns later:
- one hit applies
damage_amount - same hit applies directional knockback from attacker -> target vector
- short invulnerability (i-frames) prevents accidental double-hit on overlap
func apply_hit(damage_amount: int, source_global_pos: Vector2) -> void:
if invulnerable:
return
current_health = max(current_health - damage_amount, 0)
var knock_dir := (global_position - source_global_pos).normalized()
velocity += knock_dir * 240.0
invulnerable = true
health_changed.emit(current_health, max_health)
await get_tree().create_timer(0.20).timeout
invulnerable = false
Keep knockback values readable first, realistic second.
Step 6 - Build a Combat Validation Room
Create a tiny test arena with:
- one player spawn point
- one melee dummy or simple enemy
- one wall cluster to test movement pressure
- one UI health label tied to
health_changed
Validation checks:
- movement feels responsive at target camera zoom
- attack can miss and hit intentionally
- single enemy contact does not multi-hit in one frame
- knockback direction is consistent and understandable
If any check fails, tune now before adding enemy AI complexity.
Troubleshooting
- Player slides forever: friction too low or not applied when input vector is zero.
- Attack always hits: hitbox monitoring never turns off after active frames.
- Enemy takes damage multiple times per swing: add i-frames or a per-attack hit registry.
- Knockback feels random: source position not captured from actual attacker node.
- Input feels delayed: test with VSync settings and ensure movement stays in
_physics_process.
Common Mistakes to Avoid
- blending movement and attack logic into one giant
_processbranch - using collision shape scale hacks instead of explicit hitbox timing
- skipping a test room and tuning only inside full levels
- letting animation timing dictate gameplay logic before state rules are stable
Mini Challenge
Add a second attack profile called heavy_attack with:
- longer startup
- wider hitbox
- stronger knockback
Then compare both attacks in your validation room and note where each feels useful.
Quick Recap
You now have:
- responsive movement with acceleration/friction tuning
- an attack state with active hitbox windows
- consistent damage + knockback with short invulnerability
- a combat validation room ready for enemy behavior work
In the next lesson, you will add enemy AI and state patterns so combat encounters feel intentional instead of random. Continue to Lesson 5: Enemy AI and State Patterns.
Bookmark this lesson and share it with your playtest partner before building bigger levels.