Frame-Accurate Hitbox System
Frame-Accurate Hitbox System
Frame-Accurate Hitbox System
CHALLENGE
In a game where parrying is a core mechanic, hitboxes that appear one frame too early or persist one frame too long break the game's fairness contract with the player. The challenge is twofold: first, getting hitbox spawn to align exactly with the animation frame where an attack becomes "active"; second, keeping the hitbox anchored to the enemy's moving body for its lifetime without the physics system fighting the anchor every frame. A Box2D sensor attached directly to the enemy body would work but creates fixture management complexity on a body that's already being manipulated by AI. A free-floating sensor that moves independently needs to be manually repositioned every frame.
THOUGHT PROCESS
I made hitboxes free-floating Box2D sensors that track the enemy via a stored reference and a cached offset. The hitbox's update() moves it to enemy->getPosition() + _offset every frame, and if the enemy is stunned mid-attack, the hitbox self-destructs via markRemoved. This means stunned enemies can't have lingering hitboxes, which is critical for parry feeling fair.
For spawn timing, the enemy model's getDamagingAction() returns a MeleeActionModel only on the exact frame where the attack becomes active — checking sprite->getFrame() == action->getHitboxStartFrame() - 1. LevelController polls this every fixed update; when it returns non-null, it spawns a hitbox whose duration is computed from the action's frame window: 4 * (endFrame - startFrame + 1) ticks. This makes hitbox lifetime directly proportional to the animation window defined in JSON, with no hardcoded magic numbers.
Facing direction is handled at spawn: the hitbox offset's X is negated if the enemy is facing left, and the hitbox angle is mirrored. This means artists define hitboxes in right-facing space and the code handles the flip.
SOLUTION
A) Hitbox anchoring and stun-triggered self-removal:
void Hitbox::update(float dt) {
BoxObstacle::update(dt);
setPosition(_enemy->getPosition() + _offset);
_duration -= dt / (1.0 / FPS);
if (_duration <= 0 || _enemy->isStunned()) {
markRemoved(true
B) Facing-aware spawn from LevelController::createHitbox:
void LevelController::createHitbox(std::shared_ptr<EnemyModel> enemy, Vec2 pos, Size size,
float rotation, int damage, float knockback, float duration, bool parriable) {
auto hitbox = Hitbox::alloc(enemy, pos, size, _scale, damage, knockback, duration, parriable);
hitbox->setAngle(enemy->isFacingRight() ? -rotation : rotation);
if (_worldRef->inBounds(hitbox.get())) {
addObstacle(ObstacleNodePair(hitbox, sprite
And inside Hitbox::init, the offset X is negated for left-facing enemies at construction time, so the stored _offset is already in world space:
bool Hitbox::init(...) {
if (!enemy->isFacingRight()) {
pos.x = -pos.x;
}
_offset = pos
C) Frame-accurate spawn gating in enemy models — getDamagingAction() returns non-null only on the exact trigger frame. For Minion1B's punch:
std::shared_ptr<MeleeActionModel> Minion1BModel::getDamagingAction() {
if (_isPunching && _punchSprite->getFrame() == _punch->getHitboxStartFrame() - 1) {
return _punch;
}
if (_isSlamming && _slamSprite->getFrame() == _slam->getHitboxStartFrame() - 1) {
return _slam;
}
return nullptr
Since LevelController calls this every fixed update and creates a hitbox when it gets a non-null result, the hitbox appears in the physics world on precisely the frame defined in the action's JSON data.
CHALLENGE
Simple timer-based AI (attack every N seconds) is readable but predictable and flat. Random action selection without memory is chaotic and unfair. The real challenge is building AI that feels reactive and escalating — enemies that pressure you more as a fight goes on, without becoming a coin-flip machine. Additionally, action selection can't just pick an intent; it needs to account for the enemy's current animation state, since triggering a new action mid-animation causes visual and hitbox corruption.
THOUGHT PROCESS
I used an aggression meter as a shared currency that builds over time and gets spent on actions. Each enemy accumulates aggression passively per frame in preUpdate, and aggression also increases when the enemy takes damage — meaning the player landing hits makes the enemy more dangerous, not less. Each action checks if (rand() % 100 <= _aggression) before firing, and spending aggression on an action resets a portion of it. This creates natural pacing: early in a fight, low aggression means actions rarely fire; as the fight continues, the threshold is crossed more reliably.
The state guard at the top of nextAction() prevents new action selection while any current action flag is set. This is a flat boolean check rather than a formal FSM, but it achieves the same safety guarantee: the selector is only active when the enemy is actually free to act. Boss 3 extends this with form switching — stun recovery flips _isGroundForm, which changes the entire action vocabulary available to the selector.
SOLUTION
A) Aggression-gated probabilistic action selection — aggression accumulates in the controller's preUpdate, and each action's firing condition samples against it:
_enemy->setAggression(std::min(100.0f, _enemy->getAggression() + dt * 5));
void Minion1AModel::shoot() {
faceTarget();
if (_aggression > 8) {
_aggression = 0;
_isShooting = true;
setMovement(0);
}
}
void Minion1AModel::explode() {
faceTarget();
if (rand() % 100 <= _aggression) {
_aggression -= std::max(0.0f, _aggression - 50);
_isExploding = true;
setMovement(0
shoot() uses a flat threshold (fires when aggression exceeds 8) for consistent ranged pressure. explode() uses a probabilistic check (rand() % 100 <= _aggression) so the desperation move becomes more likely the longer the fight runs.
B) State-guarded action selection in nextAction() — the selector only runs when no action flag is set:
void Minion1AModel::nextAction() {
AIMove();
if (!_isShooting && !_isExploding && _moveDuration <= 0 && !isStunned()) {
if (isTargetClose()) {
if (_hp < 25) {
explode();
} else {
avoidTarget(45);
}
} else if (isTargetFar()) {
approachTarget(45);
} else {
faceTarget();
shoot();
}
} else {
if (isStunned()) {
_isShooting = false;
_isExploding = false;
setMovement(0);
}
if (_isShooting && _shootSprite->getFrame() >= _shootSprite->getCount() - 1) {
_isShooting = false
Distance thresholds (isTargetClose(), isTargetFar()) drive positioning decisions, and HP threshold drives the desperation branch. The stun handler in the else block clears all flags immediately, so the selector regains control on the next tick once stun clears.
C) Minion2B's directional damage override — a concrete example of per-enemy behavior extending the base system without touching EnemyModel. The guard check intercepts damage() before any HP reduction occurs:
void Minion2BModel::damage(float value) {
bool targetAtFront = ((_targetPos - getPosition()).x > 0 && isFacingRight())
|| ((_targetPos - getPosition()).x < 0 && !isFacingRight());
if (targetAtFront && _targetPos.y <= getPosition().y + 2.1 && !isStunned()) {
if (value <= 10) {
_attackGuarded = true;
_moveDuration = 0;
} else {
setStun(stunFrames);
}
} else {
EnemyModel::damage(value);
Low-damage hits from the front set _attackGuarded, which causes nextAction() to immediately prioritize a guard counter on the next free tick. High-damage hits from the front bypass the guard and apply stun directly. This directional check is purely in model space — no physics queries needed.
Parry/Guard System and Combo Meter
Parry/Guard System and Combo Meter
Parry/Guard System and Combo Meter
CHALLENGE
Parrying in action games lives and dies on frame windows. Too generous and it's a spam button; too tight and it's a skill check that feels unfair. But tight parry windows aren't enough on their own — you need to reward skilled play with something more than "you didn't take damage." The design also needs to handle three distinct defensive outcomes (parry, guard, hit) from a single input, with each having different consequences for physics, animation, and game state.
THOUGHT PROCESS
I modeled the guard state as a 3-phase countdown: a short parry window at the beginning of the guard duration, a longer block window for the remainder, and a recovery state after the guard expires or is hit. _parryRem and _guardRem count down independently, meaning the player starts in the parry-active phase and transitions to guard-only after _parryRem reaches zero. The CollisionController checks these flags at collision time to route to the correct outcome: full damage, half damage (guard), or the parry response.
For the parry reward, I built a combo meter that fills on successful parries and attacks and decays on inactivity. Filling the meter to 100 triggers one of two rewards based on current HP: if the player is damaged, it heals them; if already at full HP, it grants _isNextAttackEnhanced, which deals amplified damage and a larger screen shake on the next hit. This creates a meaningful strategic choice — playing aggressively at low HP heals you, but at full HP it powers up your offense.
SOLUTION
A) Three-phase guard state managed in PlayerController::updateCooldowns():
if (_player->isGuardBegin()) {
_player->setGuardCDRem();
_player->setGuardRem();
_player->setParryRem();
_player->setGuardState(1);
_player->resetKnocked();
}
if (_player->isGuardActive() && !_player->isGuardBegin()) {
int guardRem = _player->getGuardRem();
_player->setGuardRem(guardRem - 1);
if (guardRem == 1) {
_player->setGuardReleaseRem(PLAYER_HIT_COLOR_DURATION);
_player->setGuardState(3);
}
int parryRem = _player->getParryRem();
_player->setParryRem(parryRem > 0 ? parryRem - 1 : 0
_parryRem counts down within the guard window. CollisionController checks isParryActive() (parryRem > 0) before checking isGuardActive() to correctly prioritize the parry outcome.
B) Collision routing in playerHitboxCollision():
if (!_player->isGuardActive() && !_player->isParryActive()) {
_player->damage(damage);
_player->resetCombo();
_player->setKnocked(true, knockback);
_screenShake(damage, 3);
}
else if (_player->isParryActive()) {
AudioHelper::playSfx("parry");
_player->incrementComboCounterByParry();
_player->_parryCounter++;
_player->setParryRem(0);
_player->setGuardRem(0);
_screenShake(40, 1);
if (hitbox->getIsParriable()) {
enemy->setStun(enemy->stunFrames);
}
}
else if (_player->isGuardActive()) {
_player->damage(hitbox->getDamage() / 2);
_screenShake(halfDamage, 3
The parry outcome also checks getIsParriable() on the hitbox — unparriable attacks (like Boss 3's laser) go through the block branch regardless of timing. Parrying a projectile additionally reverses its velocity and marks it as player-fired, turning it into a homing counter-attack:
projectile->setIsPlayerFired(true);
projectile->setVX(-projectile->getVX());
projectile->setVY(-projectile->getVY());
projectile->getSceneNode()->setColor(cugl::Color4(0, 255, 150, 255
C) Combo meter accumulation and dual-reward resolution:
_player->_lastComboElapsedTime += timestep;
if (_player->_lastComboElapsedTime >= 5 && _player->_comboMeter > 0) {
_player->_comboMeter = std::max(_player->_comboMeter - timestep * 10, 0.0f);
}
if (_player->_comboMeter >= 100) {
if (_player->getHP() < _player->getMaxHP()) {
_player->setHP(std::min(_player->getHP() + 20.0f, 100.0f));
} else {
_player->_isNextAttackEnhanced = true;
}
_player->_comboMeter = 0
The meter decays after 5 seconds of inactivity, preventing passive accumulation between encounters. The enhanced attack flag is consumed on the next collision hit and cleared immediately after:
if (_player->_isNextAttackEnhanced) {
_player->_isNextAttackEnhanced = false;
_screenShake(30, 5);
} else {
_player->incrementComboCounterByAttack
The screen shake magnitude — 30 versus the standard 3 — is the primary player-facing signal that the enhanced hit landed.