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_down
  • attack_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:

  • idle
  • move
  • attack

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:

  1. startup (wind-up, no damage)
  2. active (hitbox on)
  3. 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:

  1. one player spawn point
  2. one melee dummy or simple enemy
  3. one wall cluster to test movement pressure
  4. 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 _process branch
  • 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.