Hitboxes and Hurtboxes from Sprite Sheets
Hitboxes, hurtboxes and pushboxes for 2D sprites: active frames, per-frame boxes from a sprite sheet, and code for Godot 4, Unity and GameMaker.
By SpriteLab team9 min read
The short answer
Give every animated character three kinds of box: a pushbox (or collision box) that collides with walls and other bodies and never changes, a hurtbox that covers the body and can be damaged, and a hitbox that only exists on the frames where an attack can land. Store hurtbox and hitbox rectangles per frame, in pixels relative to the frame, and move them with the animation. Turn the hitbox on during the active frames only and make sure each swing hits each target once.
Three boxes, three jobs
Mixing these up is behind most 'my sword pushes enemies through walls' and 'I got hit by the end of an animation' bugs. Keep them on separate collision layers so each only meets what it should.
| Box | What it does | Changes per frame? | Collides with |
|---|---|---|---|
| Pushbox | Stops the character at walls and other bodies | No (or rarely: crouching) | World, other pushboxes |
| Hurtbox | Where the character can be hit | Yes, follows the body | Enemy hitboxes |
| Hitbox | What deals damage | Yes, only on active frames | Enemy hurtboxes |
Startup, active and recovery frames
Every attack has three phases. Startup is the wind-up before it can hit; active frames are when the hitbox exists; recovery is the time after, when the attacker is vulnerable. The ratio of the three is what makes an attack feel fast, heavy, safe or risky, and it is the main thing you tune in a combat system — not the drawings.
In a 6-frame sword swing, frames 1–2 are typically startup, frames 3–4 active, frames 5–6 recovery. A light jab might have one startup frame and one active frame; a heavy overhead can have four startup frames. If the art is generated or drawn by someone else, decide the active frames after looking at the animation: the hitbox should appear on the first frame where the weapon is clearly travelling at the target.
Converting sprite frames into game frames
Animations play at 8–12 fps, but the game logic usually runs at 60 updates per second, and combat timing is easier to reason about in those game frames. At 10 fps each sprite frame stays on screen for 6 game frames (100 ms); at 12 fps for 5 (about 83 ms). A 6-frame attack at 10 fps lasts 36 game frames, 0.6 seconds.
That gives you concrete numbers to tune. If frames 3 and 4 of that attack are active, the hitbox exists for 12 game frames — 200 ms — which is generous; making only frame 3 active halves it. Startup of 2 sprite frames is 12 game frames, about the shortest wind-up a player can still react to visually. Recovery of 2 frames leaves a 200 ms window to punish a missed swing. When an attack feels too fast or too slow, change the timing of individual frames first and keep the drawings.
Do the hit detection in the physics or fixed update, not in rendering, and read the current animation frame there. Otherwise a slow frame can skip the one update where the hitbox overlapped.
Storing boxes per frame
The simplest portable format is a list of rectangles per animation, one entry per frame, in pixels of the frame with the origin at the top-left. Store null when there is no box. Keeping it in a data file rather than in code lets you tweak numbers without recompiling and reuse them across engines.
This is the format of the hitboxes.json file in SpriteLab's engine pack:
{
"character": "knight",
"animations": {
"attack": {
"fps": 10,
"loop": false,
"frameWidth": 256,
"frameHeight": 256,
"frames": [
{ "hurtbox": { "x": 92, "y": 60, "w": 70, "h": 172 }, "hitbox": null },
{ "hurtbox": { "x": 90, "y": 62, "w": 72, "h": 170 }, "hitbox": null },
{ "hurtbox": { "x": 96, "y": 64, "w": 70, "h": 168 }, "hitbox": { "x": 170, "y": 70, "w": 78, "h": 60 } }
]
}
}
}How the automatic boxes are found
SpriteLab detects these boxes from the pixels, which is worth understanding before you trust them. The hurtbox is the range of columns and rows that contain a solid share of the sprite's opaque pixels, so a thin sword or a wisp of fire does not stretch the body. The hitbox is made of the opaque pixels that stick out beyond that body and were not already there in the animation's first frame — so a sword resting at the character's side is ignored, and the same sword swung forward counts as a strike. When nothing reaches out, the hitbox is null.
This works well for clear strikes: a weapon swing, a punch, a breath attack, a lunge. It cannot know your design: it does not separate startup from active frames, it may mark a big follow-through as a hitbox, and it boxes the whole reach of a wide swing as one rectangle. Use the numbers as a first draft, then disable the hitbox on frames you consider startup or recovery and shrink boxes that feel unfair.

Godot 4: moving boxes with the animation
Put two Area2D nodes under the character — Hurtbox and Hitbox — each with a CollisionShape2D using a RectangleShape2D (tick 'Local to Scene' on the shape so instances do not share it). On frame_changed, read the current frame's boxes and convert them from frame pixels to node coordinates. AnimatedSprite2D is centred by default, so subtract half the frame size, mirror x when the sprite is flipped, and multiply by the sprite's scale.
extends CharacterBody2D
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var hurt_shape: CollisionShape2D = $Hurtbox/CollisionShape2D
@onready var hit_shape: CollisionShape2D = $Hitbox/CollisionShape2D
var boxes: Dictionary = {}
func _ready() -> void:
var text := FileAccess.get_file_as_string("res://spritelab_knight/hitboxes.json")
boxes = JSON.parse_string(text)["animations"]
sprite.frame_changed.connect(_sync_boxes)
sprite.animation_changed.connect(_sync_boxes)
_sync_boxes()
func _sync_boxes() -> void:
var anim: Dictionary = boxes.get(String(sprite.animation), {})
if anim.is_empty():
return
var frame: Dictionary = anim["frames"][sprite.frame]
var size := Vector2(anim["frameWidth"], anim["frameHeight"])
_apply(hurt_shape, frame["hurtbox"], size)
_apply(hit_shape, frame["hitbox"], size)
func _apply(shape: CollisionShape2D, box, frame_size: Vector2) -> void:
if box == null:
shape.set_deferred("disabled", true)
return
var rect_size := Vector2(box["w"], box["h"])
var center := Vector2(box["x"], box["y"]) + rect_size / 2.0 - frame_size / 2.0
if sprite.flip_h:
center.x = -center.x
(shape.shape as RectangleShape2D).size = rect_size * sprite.scale.abs()
shape.position = sprite.position + center * sprite.scale.abs()
shape.set_deferred("disabled", false)Godot only exports resources by default. Because hitboxes.json is a plain data file, add *.json to 'Filters to export non-resource files/folders' in the export preset's Resources tab, or the file will be missing from the exported game even though it works in the editor.
Unity: setting a BoxCollider2D from frame pixels
Unity measures in world units and y points up, so convert with your Pixels Per Unit and flip the y axis. With a Bottom Center pivot (recommended for characters), the frame's bottom edge is at local y = 0. JsonUtility cannot read objects keyed by animation name, so either parse the file with the Newtonsoft Json package (com.unity.nuget.newtonsoft-json) or convert it once into a ScriptableObject. The conversion itself is a few lines:
public static void ApplyBox(BoxCollider2D col, RectInt box, int frameW, int frameH,
float ppu, bool flipX)
{
float cx = (box.x + box.width / 2f - frameW / 2f) / ppu;
float cy = (frameH - box.y - box.height / 2f) / ppu; // y up, from the feet
col.offset = new Vector2(flipX ? -cx : cx, cy);
col.size = new Vector2(box.width / ppu, box.height / ppu);
}GameMaker and other engines
In GameMaker, keep the world collision on a fixed mask_index and test the strike with collision_rectangle, converting from frame pixels with sprite_xoffset and sprite_yoffset; the GameMaker strip guide has a complete example. In any other engine the conversion is the same idea: frame position minus the sprite's origin, mirrored when the sprite is flipped, times the sprite's scale.
Rules that prevent most combat bugs
- One hit per swing: keep a list of targets already hit during the current attack and clear it when the attack starts.
- Hitboxes only on active frames; never leave them on during recovery.
- Hurtboxes slightly smaller than the art, hitboxes slightly smaller than the weapon — players forgive near-misses, not hits that never touched.
- Put boxes on dedicated layers so a hitbox never collides with walls or with its owner.
- Draw the boxes in a debug view (Godot: Debug → Visible Collision Shapes) and step through attacks one frame at a time.
- If you flip the sprite, flip the boxes in the same frame, not one frame later.
Frequently asked questions
What is the difference between a hitbox and a hurtbox?
A hitbox deals damage and exists only while an attack is active. A hurtbox is the area that can receive damage, usually covering the body on every frame.
Should hitboxes match the sprite exactly?
No. Most games make them slightly smaller than the art so hits feel fair, and use simple rectangles rather than pixel-precise shapes.
Can hitboxes be generated automatically from a sprite sheet?
A first draft can. SpriteLab's engine pack includes per-frame hurtboxes and hitboxes detected from the pixels; you still choose the active frames and tune sizes for your game.
Why does my character get stuck in walls when attacking?
The world collision uses the animated sprite's mask, which grows when the weapon swings. Use a fixed collision box for movement and separate per-frame hitboxes for attacks.