Combat Design·Game Design·Programming·Unreal Engine·Quod Combat Framework

Hitbox Rank and Group: Sweetspots Without Scripting

How I author melee hitboxes in my Quod Combat Framework: socket-to-socket capsules, rank and group IDs that make sweetspots pure data, multi-hit policies.

For a hammer, we may want the head to hurt more than the handle. For a spear, we may want a bonus when the player keeps enough distance to hit with only the tip. These are the sweetspots we are going to configure here.

One way to do it is to measure how far along the blade the impact landed and apply a bonus past 80%. That works for a sword. For a flail we would need a different measurement, so this can turn into a special case per weapon.

In my Quod Combat Framework I add one extra capsule and use an integer to decide which hitbox takes priority. To explain that setup, let’s start with how the hitboxes follow the animation and where their data lives.

Capsules

First a little bit of context. The framework does not use overlap events for melee. When the damage window of an attack opens ( more on that window later ) a throwaway damage dealer object sweeps the capsules of the attack every frame, from the pose of the last frame to the pose of this one. Whatever the sweeps touch is a candidate hit, and when the window closes the capsules are gone.

All the hitboxes are capsules, we do not use boxes or mesh collision. A capsule is a segment with a radius, and a sword, a spear, a club, an arm or a tail are more or less that shape. You can author them socket to socket so they follow the animation, capsule sweeps are cheap ( SweepMultiByChannel with a capsule shape ), and they are fair to the player. A hit from a clipping cloth flap feels unfair, and nobody can see the difference during a 300 ms swing anyway.

I use these two capsule shapes:

UENUM()
enum class EQuodHitboxShape : uint8
{
    SingleBone,  // one bone, capsule extends along a chosen axis
    BoneToBone   // capsule stretched between two bones, every frame
};

USTRUCT()
struct FQuodHitboxDef
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere)
    EQuodHitboxShape Shape = EQuodHitboxShape::SingleBone;

    UPROPERTY(EditAnywhere)
    FName StartBone;

    UPROPERTY(EditAnywhere)
    FName EndBone;               // BoneToBone only

    UPROPERTY(EditAnywhere)
    float Radius = 0.f;

    UPROPERTY(EditAnywhere)
    float Length = 0.f;          // SingleBone: reach along the axis

    UPROPERTY(EditAnywhere)
    float ExtraLength = 0.f;     // BoneToBone: padding past the end bone

    UPROPERTY(EditAnywhere)
    FVector LocalOffset = FVector::ZeroVector;

    UPROPERTY(EditAnywhere)
    TEnumAsByte<EAxis::Type> Axis = EAxis::Z;

    UPROPERTY(EditAnywhere)
    bool bCheckObstacles = true;
};

This is an excerpt of the struct. The full version also has editor helpers.

SingleBone covers most weapons, you anchor it on the weapon bone and extend it along an axis. BoneToBone is for things that bend, like flails, chains and tails, the capsule is recomputed between the two bones every frame and ExtraLength adds some padding so the tip of the chain still counts.

My recommendation is to author everything as SingleBone and only go BoneToBone when the weapon really bends.

Rank and group

Every active hitbox in the framework has two small integers on it, a Group and a Rank.

Group tells the system which hits count as the same hit. Hitboxes that share a group are treated as a single hitbox when deduplicating: once the group has hit a target, that group can not hit that target again during the swing.

Rank decides which hitbox applies when several of them land at the same time. When more than one box of the same group connects on the same frame, the lowest rank wins. The resolution is a dozen lines in the sweep loop:

// Several capsules may have swept through the same target this frame.
// Keep one hit per target: the one with the lowest rank.
TMap<AActor*, FQuodHitboxHit> SelectedHits;
for (const FQuodHitboxHit& Hit : HitsThisFrame)
{
    if (FQuodHitboxHit* Selected = SelectedHits.Find(Hit.Target))
    {
        if (Hit.Rank < Selected->Rank)
        {
            *Selected = Hit;
        }
    }
    else
    {
        SelectedHits.Add(Hit.Target, Hit);
    }
}
// Apply damage once per target, with the winning hitbox's payload.

Now the sweetspot recipe, with a spear:

  • Normal hitbox, a bone-to-bone capsule down the shaft, stopping below the tip. Group 0, rank 0.
  • Sweetspot, a second capsule on the tip. Group 0, rank 1, pointing at a damage row with the bonus.

When both capsules touch the target, the rank 0 shaft wins and normal damage applies. If the player keeps enough distance that only the tip touches, the sweetspot is the only candidate in its group and the bonus lands.

We did not need to measure the distance or write code for the spear. Both boxes share a group, so the target is not hit twice by the same thrust either.

You can also flip it. Author the bonus box at rank 0 and it dominates instead, whenever the hammer head touches, the head damage applies. So a sweetspot that yields or one that dominates is a matter of changing one integer.

I also use these numbers to color the debug draw. Mine picks the hue from the rank and darkens it by the group, so I can see which box wins while tuning an attack. I would set this up from the start.

By the way, Smash Ultimate does the same thing. Every attack is a handful of spheres ( plus “extended hitboxes”, which are capsules with another name ), each hitbox has an ID, and when several connect on the same frame the lowest ID takes priority. SmashWiki’s hitbox page documents all of this, and that is how Marth’s tipper exists.

Damage windows on the montage

None of these capsules is activated from code either. Hitboxes fire from an anim notify state placed on the attack montage, the damage window. NotifyBegin activates the capsules of the window and NotifyEnd removes them. These are the active frames of the attack, but as a colored bar on the montage that a designer can drag in Persona.

UCLASS()
class UQuodANS_DamageWindow : public UAnimNotifyState
{
    GENERATED_BODY()

public:
    // Empty = derive the row name from the animation name.
    UPROPERTY(EditAnywhere)
    FName HitboxRow;

    // Empty = same row name as HitboxRow.
    UPROPERTY(EditAnywhere)
    FName DamageRow;

    UPROPERTY(EditAnywhere)
    uint8 Rank = 0;

    UPROPERTY(EditAnywhere)
    uint8 Group = 0;

    // NotifyBegin: resolve the rows against the equipped weapon,
    //              activate the capsules with this Rank/Group.
    // NotifyEnd:   remove exactly this window's capsules.
};

The notify only stores names, which hitbox row and damage row to use, plus the rank and group of everything it activates.

So the spear thrust has two overlapping damage windows on the timeline, the normal one at rank 0, group 0, covering the whole swing, and the sweetspot one at rank 1, same group, pointing at the tip capsule. Since they are bars, you can make the sweetspot window shorter so the tip bonus only exists at full extension, which is simply dragging an edge. When any of the two windows ends, it removes only its own capsules.

If the notify does not name a row, I use the animation name. The montage Attack_Spear_Light1 looks up the row Attack_Spear_Light1, so most windows do not need their properties edited.

I also override the display name of the notify to include its rank and group ( “DAMAGE r0 g0” ). This lets me read the attack without clicking into each notify’s properties.

Same thing can be done for parry windows, i-frames on the dodge montage, input buffer windows and cancel windows, we may go over some of those in future articles.

Other parameters of an attack

An attack row also has settings for wall contact and repeated hits.

The attack rows have three hitbox arrays instead of one, damage hitboxes, physical hitboxes and environment hitboxes. The physical set is what makes your weapon rebound off a wall mid swing, and it only bounces off near vertical geometry, nothing feels worse than a greatsword bouncing off a small rock. The environment set only spawns sparks and impact effects on the world, once per swing and only if you did not hit anyone. The three can have different shapes.

Each attack also has a multi hit policy. Mine is an enum:

enum class EQuodHitPolicy : uint8
{
    FirstHitOnly,   // the first contact ends the attack
    OncePerTarget,  // each target can be hit once per swing
    Unlimited,      // no dedup at all
    RehitAfterDelay // the same target can be re-hit after N seconds
};

OncePerTarget is your normal swing. FirstHitOnly is for attacks where you only care about the first contact, like a grab attempt. RehitAfterDelay with a half second window is my default, and it is what you want for long windows like a spinning attack, the same enemy can be hit again but on a timer instead of every frame.

Finally, a wall check. For every candidate hit we do a line trace from the attacker toward the impact point, and if there is a wall in between the hit is rejected. The per hitbox bCheckObstacles flag is the opt out, as at some point some attack needs to ignore the rule. Ex: a shockwave that travels through walls, or a boss arm wrapping around a pillar.

Where the data lives

I keep the hitbox and damage tables on the weapon. The animation only needs the row names.

Each weapon data asset has its movesets per stance, its own hitbox table and its own damage table. The damage window on the montage only stores row names, and when the window opens those names are resolved against the tables of the equipped weapon. So one shared greatsword montage serves every greatsword in the game, and each weapon brings its own reach, sweetspot and numbers. If a designer wants the spear tip bonus lower, they edit one row in one weapon table.

Recap

For the spear sweetspot, add a capsule in the same group with a higher rank. The lowest rank wins if both touch, so the bonus only applies when the tip hits alone.

The capsules follow one bone and an axis for rigid weapons, or stretch between bones for flails and tails. Damage-window notifies activate them from the montage, using row names resolved against the equipped weapon. That is where we edit the data when tuning the attack.

Keep reading

Dashes and Knockbacks with Root Motion Sources

Dashes and Knockbacks with Root Motion Sources

No root motion in the clip. The task builds the motion from plain numbers and hands it to the movement component, the animation is just cosmetic.
How Enemies Decide to Dodge

How Enemies Decide to Dodge

Dodge chance is a GAS attribute, pressure is a stacking effect on top of it, and the AI simply rolls dice.
Building Souls-Style Input Buffering on Top of GAS

Building Souls-Style Input Buffering on Top of GAS

A buffered press is simply a struct, a gate tag and one drain function that an anim notify state calls.