PVG
49. Hitboxes and Hurtboxes — Program Video Games

49. Hitboxes and Hurtboxes

Before diving into the code, let's clarify these concepts:

  • Hitbox: The area that deals damage to other entities. This is typically shown in red during debug mode.

  • Hurtbox: The area that receives damage when hit by a hitbox. This is typically shown in yellow during debug mode.

The key insight is that these boxes are often different sizes from the entity's collider. We make hitboxes slightly smaller and hurtboxes slightly larger than the visual sprite to create a more forgiving gameplay experience. This prevents pixel-perfect collision requirements that often feel unfair to players.

Debug Drawing Improvements

First, let's update our debug drawing system to support filled shapes and work at screen resolution rather than pixel size.

In debug.odin, add a filled field to both rectangle and circle structs:

Debug_Rect :: struct {
    pos, size: Vec2,
    thickness: f32,
    color:     rl.Color,
    filled:    bool,  // New field
}

Debug_Circle :: struct {
    pos:    Vec2,
    radius: f32,
    color:  rl.Color,
    filled: bool,  // New field
}

Add a generic debug draw procedure:

debug_draw :: proc(v: Debug_Shape) {
    append(&gs.debug_shapes, v)
}

Update the helper functions to include the filled parameter:

debug_draw_rect :: proc(pos, size: Vec2, thickness: f32, color: rl.Color, filled := false) {
    append(&gs.debug_shapes, Debug_Rect{pos, size, thickness, color, filled})
}

debug_draw_circle :: proc(pos: Vec2, radius: f32, color: rl.Color, filled := false) {
    append(&gs.debug_shapes, Debug_Circle{pos, radius, color, filled})
}

Entity System Updates

In main.odin, add hitbox and hurtbox fields to the Entity struct:

Entity :: struct {
    using collider:             Rect,
    hitbox:                     Rect,
    hurtbox:                    Rect,
    // ... rest of the fields
}

Also add them to Entity_Definition:

Entity_Definition :: struct {
    collider_size:       Vec2,
    hitbox:              Rect,  // Offset and size
    hurtbox:             Rect,  // Offset and size
    // ... rest of the fields
}

Add an on_hit_static callback to the definition:

Entity_Definition :: struct {
    // ... other fields
    on_hit_static:       proc(e: ^Entity, gs: ^Game_State, pos: Vec2, dt: f32),
}

Window Size Update - (Optional) Depending on your screen resolution

Update the window dimensions for better visibility:

WINDOW_WIDTH :: 1920
WINDOW_HEIGHT :: 1080

Entity Spawning Updates

In entity.odin, update the entity_spawn procedure to set hitbox and hurtbox from the definition:

entity_spawn :: proc(type: string, pos: Vec2) -> Entity_Id {
    // ... existing code
    
    e := Entity{
        // ... other fields
        hitbox = def.hitbox,
        hurtbox = def.hurtbox,
        // ...
        on_hit_static = def.on_hit_static,
    }
    
    // ... rest of the function
}

Demon Boss Updates

In demon_boss.odin, replace the old entity_create call with the new spawning system:

// Old code:
// entity_create(Entity { ... })

// New code:
orb := entity_get(entity_spawn("Orb", pos))
orb.vel = dir * 350

Remove the @(private = "file") annotation from fireball_on_hit_static so it can be referenced in definitions.

Update the breath attack to use a filled circle:

debug_draw_circle(pos, radius, rl.RED, true)  // true for filled

Editor Updates

In editor.odin, prevent placing entities on spikes:

if is_tile_at(coords, gs.level) || is_spawner_at(coords, gs.level) || is_spike_at(coords, gs.level) {
    return {}, {}
}

Update spawner drawing to show labels at screen coordinates:

for spawner in gs.level.spawners {
    pos := rl.GetWorldToScreen2D(spawner.pos, gs.camera)
    rl.DrawRectangleLinesEx({pos.x, pos.y, 16 * gs.camera.zoom, 16 * gs.camera.zoom}, 1, rl.ORANGE)
    rl.DrawTextEx(gs.font_18, fmt.ctprintf(spawner.type), pos, 18, 0, rl.WHITE)
}

Remove the direction indicator drawing code that showed tile neighbors.

Entity Definitions Update

Update all entity definitions with proper hitbox and hurtbox values:

// Player
player_def := Entity_Definition {
    collider_size     = {14, 38},
    hurtbox           = {3, 4, 8, 30},  // offset_x, offset_y, width, height
    // ... rest of the definition
}

// Walker
gs.entity_definitions["Walker"] = Entity_Definition {
    collider_size     = {24, 12},
    hitbox            = {0, 0, 24, 12},
    hurtbox           = {-6, 0, 36, 12},
    // ... rest of the definition
}

// Update animation offsets for Walker
walker.animations["walk"] = Animation {
    size           = {36, 28},
    offset         = {6, 16},
    offset_flipped = {6, 16},
    // ... rest of the animation
}

// Jumper
gs.entity_definitions["Jumper"] = Entity_Definition {
    collider_size     = {18, 16},
    hitbox            = {3, 2, 12, 12},
    hurtbox           = {1, 0, 16, 16},
    // ... rest of the definition
}

// Update Jumper animations with offsets
jumper.animations["idle"] = Animation {
    size = {50, 48},
    offset = {16, 32},
    offset_flipped = {16, 32},
    time = 1,
}
jumper.animations["hop"] = Animation {
    size   = {50, 48},
    offset = {16, 32},
    offset_flipped = {16, 32},
    // ... rest of the animation
}

// Charger
gs.entity_definitions["Charger"] = Entity_Definition {
    collider_size     = {32, 12},
    hitbox            = {3, 0, 26, 12},
    hurtbox           = {-2, -8, 36, 20},
    // ... rest of the definition
}

// Update Charger walk animation
charger.animations["walk"] = Animation {
    size  = {64, 35},
    offset = {16, 23},
    offset_flipped = {16, 23},
    // ... rest of the animation
}

Remove the unused "charge" animation from the Charger.

Update the Demon Boss:

gs.entity_definitions["Demon_Boss"] = Entity_Definition {
    collider_size     = {32, 64},
    hurtbox           = {2, 2, 28, 60},
    hitbox            = {8, 16, 16, 32},
    on_hit_damage     = 1,  // Now deals damage on contact
    // ... rest of the definition
}

Update the Orb definition:

gs.entity_definitions["Orb"] = Entity_Definition {
    collider_size     = {16, 16},
    hitbox            = {4, 4, 8, 8},
    health            = 30,
    on_hit_damage     = 2,
    texture           = texture_load("assets/textures/orb_32x32.png"),
    initial_animation = "idle",
    flags             = {.Frozen, .Flying, .Immortal},  // Changed from Kinematic
    on_hit_static     = fireball_on_hit_static,
}

// Fix the orb animation (use pointer to modify the definition)
if orb, orb_ok := &gs.entity_definitions["Orb"]; orb_ok {
    orb.animations["idle"] = Animation {
        size           = {32, 32},
        offset         = {8, 8},
        offset_flipped = {8, 8},
        time           = 1,
        tint           = {0, 255, 255, 255},
    }
}

Rendering Updates

In main.odin, reorganise the rendering order. Move particles above rl.EndMode2D():

// Draw particles before ending 2D mode
for p in gs.particles {
    pos := linalg.round(p.pos)
    color := p.color
    color.a = u8(f32(p.color.a) * (1 - p.transparency))
    rl.DrawRectangleV(pos, p.size, color)
}

rl.EndMode2D()

Move all debug drawing outside of the zoomed camera mode:

if gs.debug_draw_enabled {
    // Draw entity hitboxes and hurtboxes
    for e in gs.entities {
        collider := rect_to_screen(e.collider, gs.camera)
        hitbox := rect_to_screen(e.hitbox, gs.camera)
        hurtbox := rect_to_screen(e.hurtbox, gs.camera)

        rl.DrawRectangleRec(hitbox, {255, 0, 0, 60})     // Red with transparency
        rl.DrawRectangleLinesEx(hurtbox, 1, rl.YELLOW)   // Yellow outline
        rl.DrawRectangleLinesEx(collider, 1, rl.GREEN)   // Green outline
    }

    // Draw safe position
    debug_draw_rect(gs.safe_position, Vec2{16, 24}, 1, rl.Color{0, 255, 255, 80}, false)

    // Draw checkpoints
    for checkpoint in gs.level.checkpoints {
        rect := rect_to_screen({checkpoint.pos.x - 16, checkpoint.pos.y - 16, 32, 32}, gs.camera)
        rl.DrawRectangleLinesEx(rect, 1, rl.PURPLE)
    }

    // Draw uncollected power-ups
    for power_up in gs.level.power_up_spawns {
        if power_up.type not_in gs.save_data.collected_power_ups {
            rl.DrawRectangleLinesEx({power_up.pos.x, power_up.pos.y, 16, 16}, 1, rl.GOLD)
        }
    }

    // Draw debug shapes with proper screen transformation
    for s in gs.debug_shapes {
        switch v in s {
        case Debug_Line:
            rl.DrawLineEx(
                rl.GetWorldToScreen2D(v.start, gs.camera),
                rl.GetWorldToScreen2D(v.end, gs.camera),
                v.thickness,
                v.color
            )
        case Debug_Rect:
            pos := rl.GetWorldToScreen2D(v.pos, gs.camera)
            rect := rect_scale({pos.x, pos.y, v.size.x, v.size.y}, gs.camera.zoom)
            if v.filled {
                rl.DrawRectangleRec(rect, v.color)
            } else {
                rl.DrawRectangleLinesEx(rect, v.thickness, v.color)
            }
        case Debug_Circle:
            pos := rl.GetWorldToScreen2D(v.pos, gs.camera)
            if v.filled {
                rl.DrawCircleV(pos, v.radius * gs.camera.zoom, v.color)
            } else {
                rl.DrawCircleLinesV(pos, v.radius * gs.camera.zoom, v.color)
            }
        }
    }
}

Remove the old debug drawing code for doors, checkpoints, power-ups, and spawners from inside the zoomed camera mode.

Physics Updates

In physics.odin, update the hitbox and hurtbox positions each frame:

// Update hitbox and hurtbox positions based on entity position
entity.hitbox.x = entity.x + entity.definition.hitbox.x
entity.hitbox.y = entity.y + entity.definition.hitbox.y
entity.hurtbox.x = entity.x + entity.definition.hurtbox.x
entity.hurtbox.y = entity.y + entity.definition.hurtbox.y

Change collision detection to use hitbox vs hurtbox instead of collider vs collider:

// Hitbox<->Hurtbox collisions
// Enemies have a constant Hitbox around them
for &other, o_id in entities {
    other_id := Entity_Id(o_id)
    if entity_id == other_id do continue
    if entity.hitbox.width == 0 && entity.hitbox.height == 0 do continue

    if rl.CheckCollisionRecs(entity.hitbox, other.hurtbox) {
        // ... rest of the collision handling
    }
}

Player Attack Updates

In player.odin, add debug drawing for the attack hitbox:

player_attack_callback :: proc(gs: ^Game_State, player: ^Entity) {
    center := Vec2{player.x, player.y}
    center += {.Left in player.flags ? -30 + player.collider.width : 30, 20}
    // Draw attack hitbox
    debug_draw_circle(center, 25, rl.RED, true)  // true for filled

    for &e, i in gs.entities {
        id := Entity_Id(i)
        if id == gs.player_id do continue
        if .Dead in e.flags do continue
        if .Immortal in e.flags do continue

        // Check against hurtbox instead of collider
        if rl.CheckCollisionCircleRec(center, 25, e.hurtbox) {
            entity_damage(Entity_Id(i), 1)
            // ... rest of the damage handling
        }
    }
}

Utility Functions

In util.odin, add new utility functions for rectangle manipulation:

import rl "vendor:raylib"

rect_scale :: #force_inline proc(r: Rect, s: f32) -> Rect {
    return Rect{r.x, r.y, r.width * s, r.height * s}
}

rect_to_screen :: #force_inline proc(r: Rect, c: rl.Camera2D) -> Rect {
    pos := rl.GetWorldToScreen2D({r.x, r.y}, c)
    return rect_scale({pos.x, pos.y, r.width, r.height}, c.zoom)
}

Summary

With these changes, we now have a proper hitbox and hurtbox system that:

  1. Separates damage dealing (hitbox) from damage receiving (hurtbox) areas

  2. Allows for more forgiving collision detection through size differences

  3. Provides clear visual feedback in debug mode

  4. Works at screen resolution for crisp debug rendering

  5. Properly updates positions based on entity movement

The system makes combat feel more fair and gives us fine control over the difficulty and feel of enemy encounters. Players can now see exactly where they can hit enemies and where they can be hit, making the game mechanics more transparent and learnable.