DEV Community

saltmire
saltmire

Posted on Originally published at saltmire.github.io

RigidBody2D vs CharacterBody2D for knockback in Godot 4 — which and when

Every combat game needs knockback, and every combat game asks the same question at
some point: should the thing that just got hit be a RigidBody2D, or should you fake
the shove on the CharacterBody2D you already have? Both work. They fail in different
ways, and picking the wrong one costs you a rewrite mid-project.

The CharacterBody2D approach: a velocity you decay

Most character controllers in Godot 4 are already CharacterBody2D, driven by
move_and_slide(). Knockback here means adding a temporary velocity on top of the
normal movement input and letting it bleed off over a few frames.

extends CharacterBody2D

var knockback := Vector2.ZERO
const KNOCKBACK_FRICTION := 900.0

func apply_knockback(direction: Vector2, force: float) -> void:
    knockback = direction.normalized() * force

func _physics_process(delta: float) -> void:
    var input_velocity := get_input_velocity() # your normal movement code

    knockback = knockback.move_toward(Vector2.ZERO, KNOCKBACK_FRICTION * delta)

    velocity = input_velocity + knockback
    move_and_slide()
Enter fullscreen mode Exit fullscreen mode

This is cheap, deterministic, and stays inside move_and_slide()'s collision rules —
the body still slides along walls correctly while it's being pushed. The catch: it is
entirely your own simulation. There is no real physics response, so two bodies pushed
into each other don't interact, and the "weight" of a hit is whatever curve you hand-tune
into KNOCKBACK_FRICTION.

The RigidBody2D approach: apply_impulse and let physics do it

A RigidBody2D gives you a real physics body — mass, impulses, collision response
between bodies for free.

extends RigidBody2D

func apply_knockback(direction: Vector2, force: float) -> void:
    apply_impulse(direction.normalized() * force)
Enter fullscreen mode Exit fullscreen mode

That's the whole function. The physics engine handles the deceleration (via
linear_damp), the collisions with other rigid bodies, and bodies piling into each
other look and feel physical because they are physical. The catch: RigidBody2D
doesn't give you move_and_slide()-style character control. If your enemy or player
also needs precise, responsive movement input, you're now fighting the physics engine
for control of the same body, and that fight usually shows up as jitter against walls
or a character that "floats" a frame after landing.

The comparison

CharacterBody2D (faked) RigidBody2D (real)
Setup cost one velocity field + move_toward apply_impulse and done
Feel fully tunable, but hand-rolled physically consistent, less tunable
Body-to-body interaction none — you'd have to script it free, physics engine handles it
Movement input while knocked back trivial to blend fights the physics solver
Determinism yes mostly, minor solver variance
Good for player character, most enemies ragdolls, debris, crowds, destructibles

The hybrid most shipped games actually use

The honest answer is that neither option alone covers a whole game. The pattern that
holds up: keep your player and most enemies on CharacterBody2D with faked knockback,
because you need tight control over their movement every single frame. Switch to
RigidBody2D only for things that are allowed to lose control entirely — a dead
enemy's ragdoll, a barrel that goes flying, loose debris in a destructible wall.

A trick that gets you most of the RigidBody feel without giving up control: keep the
CharacterBody2D, but scale the knockback force by the target's own "weight" stat and
add a short window where player input is ignored so the hit actually reads:

var knockback_lock_timer := 0.0

func apply_knockback(direction: Vector2, force: float, weight: float = 1.0) -> void:
    knockback = direction.normalized() * (force / max(weight, 0.1))
    knockback_lock_timer = 0.12  # player input ignored for ~2 hit-stop frames

func _physics_process(delta: float) -> void:
    knockback_lock_timer = max(knockback_lock_timer - delta, 0.0)
    var input_velocity := Vector2.ZERO if knockback_lock_timer > 0.0 else get_input_velocity()

    knockback = knockback.move_toward(Vector2.ZERO, KNOCKBACK_FRICTION * delta)
    velocity = input_velocity + knockback
    move_and_slide()
Enter fullscreen mode Exit fullscreen mode

That weight parameter is the part people skip and then wonder why every enemy flies
the same distance regardless of size.

The number that actually decides it

If the object needs to respond to player input on the same frame it's simulating physics,
it's a CharacterBody2D with faked knockback — no exceptions, because fighting the
physics solver for input responsiveness is a losing, ongoing cost. If the object never
takes player input again after the hit (a corpse, debris, a thrown prop), it's a
RigidBody2D and you get the physics for free.

Wiring this up by hand means a direction, a force, a weight, teams so you don't
knock back your own allies, and i-frames so one hit doesn't chain into five — all before
you've written a single line of actual combat design. Saltmire Hitbox ships that whole
layer as a drop-in Hitbox2D / Hurtbox2D pair, knockback included, so the comparison
above is a five-minute decision instead of a rewrite.


If you'd rather drop this in than build it, Saltmire Hitbox does it as a ready-made tool: https://saltmire.itch.io/saltmire-hitbox

Originally published at https://saltmire.github.io/godot-4-knockback-rigidbody-vs-characterbody.html

Top comments (0)