Glitchblade

Role: Team Lead, Programmer, Game Designer, Level Designer


Tool: CUGL (Cornell University Game Library)


Team: Heptabyte, Team of 9


Timeline: 1 semester (5 months)


Completion Date: May, 2025

Play ->

Glitchblade

Role: Team Lead, Programmer, Game Designer, Level Designer


Tool: CUGL (Cornell University Game Library)


Team: Heptabyte, Team of 9


Timeline: 1 semester (5 months)


Completion Date: May, 2025

Play ->

Glitchblade

Role: Team Lead, Programmer, Game Designer, Level Designer


Tool: CUGL (Cornell University Game Library)


Team: Heptabyte, Team of 9


Timeline: 1 semester (5 months)


Completion Date: May, 2025

Play ->

GAME DESIGN

design pillars

Fast-Paced Combat

Aggressive enemies keep players on edge, forcing quick reactions and seamless shifts between attack and defense. The result is a tense, adrenaline-fueled flow.

High Risk, High Reward

Every move has stakes. Bold parries and risky positioning can backfire — or unleash stuns, deflections, and powerful finishers that reward skill and mastery.

Intuitive Controls

Minimal UI and familiar swipe gestures make the game instantly accessible. Simple inputs let newcomers dive in and veterans focus on precision, not complexity.

GAME DESIGN

design pillars

Fast-Paced Combat

Aggressive enemies keep players on edge, forcing quick reactions and seamless shifts between attack and defense. The result is a tense, adrenaline-fueled flow.

High Risk, High Reward

Every move has stakes. Bold parries and risky positioning can backfire — or unleash stuns, deflections, and powerful finishers that reward skill and mastery.

Intuitive Controls

Minimal UI and familiar swipe gestures make the game instantly accessible. Simple inputs let newcomers dive in and veterans focus on precision, not complexity.

GAME DESIGN

design pillars

Fast-Paced Combat

Aggressive enemies keep players on edge, forcing quick reactions and seamless shifts between attack and defense. The result is a tense, adrenaline-fueled flow.

High Risk, High Reward

Every move has stakes. Bold parries and risky positioning can backfire — or unleash stuns, deflections, and powerful finishers that reward skill and mastery.

Intuitive Controls

Minimal UI and familiar swipe gestures make the game instantly accessible. Simple inputs let newcomers dive in and veterans focus on precision, not complexity.

TECHNICAL CHALLENGES

Software Architecture

The codebase for Glitchblade follows an MVC-inspired structure that separates responsibilities between models, controllers, and views. At the top level, the application is launched through a central Main → App entry point, which initializes the game and passes control to different scene controllers. This ensures that setup and flow remain centralized rather than scattered across the codebase.

The view layer is handled through a single SceneNode tree, which renders the game world. Controllers never draw directly to the screen. Instead, they update models, and the view reflects those changes, maintaining a strict boundary between logic and presentation.

The controller layer is where most of the gameplay logic lives. It is divided into scene controllers—such as TitleScene, LevelSelectScene, LoadingScene, and GameScene—each responsible for a specific stage of the player’s experience. Scene controllers handle transitions and overall flow, while the real depth occurs inside the game controllers that operate within the GameScene. Here, the LevelController orchestrates the state of a level, coordinating with specialized controllers like the PlayerController, EnemyController, and CollisionController. Player input is routed through the InputController, which interprets swipe gestures and taps into meaningful in-game actions. Enemies are managed by the EnemyController and its type-specific subclasses, ensuring that each enemy type maintains its own behavior logic. The CollisionController ties the system together by resolving interactions among all collidable objects—hitboxes, projectiles, and characters.

The model layer represents the authoritative state of the game world. At its center is the LevelModel, which aggregates the data for the player, enemies, projectiles, and environmental hazards. Collidable models, such as PlayerModel, EnemyModel, ProjectileModel, and HitboxModel, encapsulate the properties of interactive game entities. Combat is made data-driven through ActionModel and its specialized forms (MeleeActionModel and RangedActionModel), which define the timing, damage, and behavior of attacks. This structure makes it easy to extend or adjust combat without rewriting core systems.

The core gameplay loop below reflects this architecture in action. Each cycle begins with state checks—verifying whether the level is complete or whether the player is still alive. Depending on these conditions, the game either transitions to victory or defeat screens, or continues into moment-to-moment play. Player input is gathered and mapped to intuitive gestures: swiping left or right executes a dash, tapping performs a guard or parry, swiping up triggers a jump, and dragging allows for strafing. Cooldown checks and collision detection ensure that every action is governed by timing and risk.

Once the player’s actions are processed, enemy AI determines its own behavior and performs attacks, keeping pressure on the player. Physics updates then advance positions and projectiles, while the collision system resolves interactions—whether a parry deflects a projectile, a dash avoids a strike, or a hit connects to deal damage. Player and enemy states are updated in real time, handling health, stun states, and phase changes for bosses. When enemies are defeated, the level state is updated to progress toward completion. Finally, the view layer renders all game objects and HUD elements, with minimal on-screen UI to maintain immersion and clarity.

This architecture ensures that Glitchblade’s design pillars—fast-paced combat, high risk–high reward mechanics, and intuitive controls—are supported at a code level. The modular structure makes each system easy to maintain and expand, while the gameplay loop emphasizes responsiveness, fairness, and clarity, delivering a combat experience that feels both fluid and deliberate.

Animation System

The enemy animation system is divided into three main behaviors: looping animations, one-time animations, and synchronized VFX animations.

  1. Looping Animations: The playAnimation() method advances the sprite’s frame on a timed interval (E_ANIMATION_UPDATE_FRAME). Once the last frame is reached, it loops back to the first, ensuring continuous cycles for idle or patrol animations. If the sprite is invisible, the frame resets to 0.


  2. One-Time Animations: The playAnimationOnce() method also increments frames on the same interval, but stops once the final frame is reached. This is used for actions like attacks or deaths where the animation should not loop automatically.

  3. VFX Animations: The playVFXAnimation() method synchronizes a visual effect sprite with an action sprite. When the action sprite hits a specific startFrame, the VFX resets to frame 0 and then plays forward as the action continues, stopping at the final frame.


void EnemyModel::playAnimation(std::shared_ptr<scene2::SpriteNode> sprite) {
    if (sprite->isVisible()) {
        frameCounter = (frameCounter + 1) % E_ANIMATION_UPDATE_FRAME;
        if (frameCounter % E_ANIMATION_UPDATE_FRAME == 0) {
            sprite->setFrame((sprite->getFrame() + 1) % sprite->getCount());
        }
    }
    else {
        sprite->setFrame(0);
    }
}

void EnemyModel::playAnimationOnce(std::shared_ptr<scene2::SpriteNode> sprite)
{
    if (sprite->isVisible()) {
        frameCounter = (frameCounter + 1) % E_ANIMATION_UPDATE_FRAME;
        if (frameCounter % E_ANIMATION_UPDATE_FRAME == 0 && sprite->getFrame() < sprite->getCount() - 1) {
            sprite->setFrame(sprite->getFrame() + 1);
        }
    }
    else {
        sprite->setFrame(0);
    }
}

void EnemyModel::playVFXAnimation(std::shared_ptr<scene2::SpriteNode> actionSprite, std::shared_ptr<scene2::SpriteNode> vfxSprite, int startFrame)
{
    if (actionSprite->isVisible()) {
        if (actionSprite->getFrame() == startFrame) {
            vfxSprite->setFrame(0);
        }

        else if (actionSprite->getFrame() > startFrame) {
            if (frameCounter % E_ANIMATION_UPDATE_FRAME == 0 && vfxSprite->getFrame() < vfxSprite->getCount() - 1) {
                vfxSprite->setFrame(vfxSprite->getFrame() + 1);
            }
        }
	}
    else {
        vfxSprite->setFrame(0);
    }
}

void EnemyModel::updateAnimation

Below is an example of an implementation of Boss 1's animation system.

The updateAnimation() method manages Boss 1’s entire visual state, functioning as a lightweight animation controller. It determines which sprites are visible based on the boss’s condition: stun overrides all other actions, walking only appears when the boss is moving without attacking, and individual attack sprites such as slam, stab, shoot, or explode are shown when their flags are active. The idle sprite is displayed when no actions are in progress, while the spawn sprite is enabled during the spawning sequence. To maintain consistency, the explosion VFX sprite only becomes visible during the final frames of the explosion animation, keeping the effect synchronized with the attack.

Once sprite visibility is set, animations are updated according to their type. Looping animations such as walking and idle repeat continuously, while one-shot animations such as slam, stab, stun, shoot, explode, and spawn advance only once before halting at their last frame. The explosion VFX animation is explicitly linked to the explosion sprite, beginning at the precise hitbox activation frame to ensure the visuals match gameplay timing. Damage feedback is applied through a separate effect, reinforcing the impact of player attacks. Finally, the boss’s orientation is corrected by flipping the node and its child nodes horizontally, ensuring that the character always faces its target.

void Boss1Model::updateAnimation()
{
    if (isKnockbackActive()){
        CULog("knockback active for boss 1");
    }

    _stunSprite->setVisible(isStunned());

    _walkSprite->setVisible(!isStunned() && !_isStabbing && !_isSlamming && !_isShooting && !_isExploding && (isMoveLeft() || isMoveRight()));

    _slamSprite->setVisible(!isStunned() && _isSlamming);

    _stabSprite->setVisible(!isStunned() && _isStabbing);

	_shootSprite->setVisible(!isStunned() && _isShooting);

	_explodeSprite->setVisible(!isStunned() && _isExploding);

    _explodeVFXSprite->setVisible(_explodeSprite->isVisible() && _explodeSprite->getFrame() >= _explodeSprite->getCount() - _explodeVFXSprite->getCount());

    _idleSprite->setVisible(!isStunned() && !_isStabbing && !_isSlamming && !_isShooting && !_isExploding && !(isMoveLeft() || isMoveRight()));

	_spawnSprite->setVisible(isSpawning);

    playAnimation(_walkSprite);
    playAnimation(_idleSprite);
    playAnimationOnce(_slamSprite);
    playAnimationOnce(_stabSprite);
    playAnimationOnce(_stunSprite);
	playAnimationOnce(_shootSprite);
	playAnimationOnce(_explodeSprite);
	playAnimationOnce(_spawnSprite);

    playVFXAnimation(_explodeSprite, _explodeVFXSprite, _explode->getHitboxStartFrame() - 1);

    playDamagedEffect();

    _node->setScale(Vec2(isFacingRight() ? 1 : -1, 1));
    _node->getChild(_node->getChildCount() - 2)->setScale(Vec2(isFacingRight() ? 1 : -1, 1));
    _node->getChild(_node->getChildCount() - 1)->setScale(Vec2(isFacingRight() ? 1 : -1, 1

Enemy AI SYSTEM

The enemy AI runs as a form-swapping state machine that chooses an action only when the boss is free (not stunned and not mid-attack), then steers movement and timings so hitboxes and VFX line up with animation frames. To illustrate the example, we'll look into the implementation of Boss 3's AI:

void Boss3Model::nextAction() {
    int r = rand();
    AIMove();
	if (!isStunned() && !_isUppercutting && !_isSlamming && !getIsJumping() && !_isDashing && !_isGroundDashStarting && !_isGroundDashEnding
        && !_isShootStarting && !_isShootAttacking && !_isLaserAttacking && !_isShootWaiting) {
        if (_isGroundForm) {
            handleGroundAction(r);
        }
        else {
            handleAirAction(r);
        }
    }
    else {
        if (isStunned()) {
            _isUppercutting = false;
			_isSlamming = false;
			_isJumpStarting = false;
			_isJumpWaiting = false;
			_isJumpEnding = false;
			_isDashing = false;
			_isGroundDashStarting = false;
			_isGroundDashEnding = false;
			_isShootStarting = false;
			_isShootAttacking = false;
			_isLaserAttacking = false;
			_isShootWaiting = false;
			
            setMovement(0);

            if (_groundStunSprite->getFrame() >= _groundStunSprite->getCount() - 1 || _airStunSprite->getFrame() >= _airStunSprite->getCount() - 1) {
				_isGroundForm = !_isGroundForm;
                if (!_isGroundForm) { setGravityScale(0); }
            }
        }
        if (_isUppercutting && _uppercutSprite->getFrame() >= _uppercutSprite->getCount() - 1) {
            _isUppercutting = false;
            setMovement(0);
        }
		if (_isSlamming && _slamSprite->getFrame() >= _slamSprite->getCount() - 1) {
			_isSlamming = false;
			setMovement(0);
		}
        if (_isJumpStarting || _isJumpWaiting || _isJumpEnding) {
            handleJump();
        }
        if (_isDashing || _isGroundDashStarting || _isGroundDashEnding) {
            if (_isGroundForm) {
                handleGroundDash();
            }
            else {
                if (_isDashing && _dashSprite->getFrame() >= _dashSprite->getCount() - 1) {
                    _isDashing = false;
                    setMovement(0);
                }
            }
        }
        if (_isShootStarting || _isShootAttacking || _isLaserAttacking || _isShootWaiting) {
			handleShoot();
        }
    }
}

void Boss3Model::handleGroundAction(int r) {
    if (isTargetClose()) {
        if (r % 3 == 0) { // Uppercut
            uppercut();
        }
        else if (r % 3 == 1) { // Slam
            slam();
        }
        else { // Jump
            jump();
        }
    }
    else {
        if (r % 3 == 0) {
            dash();
        }
        else {
            jump();
        }
    }
}

void Boss3Model::handleAirAction(int r) {
    if (std::abs(_targetPos.y - getPosition().y) <= 1.5 && r % 4 == 0) {
        dash();
    }
    else if (!isTargetFar()) {
        if (r % 3 == 0) {
            avoidTarget(60);
        }
        else if (r % 3 == 1) {
            approachTarget(60);
        }
        else if (_targetPos.y - getPosition().y <= -8){
            shoot(1);
        }
        
    }
    else {
        if (r % 3 == 0) {
            approachTarget(60);
        }
        else if (r % 3 == 1) {
            avoidTarget(60);
        }
        else if (_targetPos.y - getPosition().y <= -8) {
            shoot(3);
        }
    }
}

void Boss3Model::AIMove() {
    float dist = getPosition().x - _targetPos.x;
    float dir_val = dist > 0 ? -1 : 1;
    int face = _faceRight ? 1 : -1;

    if (_isGroundForm) {
        if (!getIsJumping() && !_isDashing) {
            setVerticalMovement(0);
            if (_moveDuration == 0) {
                setMovement(0);
            }
            else {
                setMovement(_moveDirection * dir_val * getForce());
                setMoveLeft(dist > 0);
                setMoveRight(dist < 0);
                _moveDuration--;
            }
        }
        else if (getIsJumping() && isJumpingUp()) {
            setMovement(dir_val * getForce() * 2);
            setVerticalMovement(getForce() * 2);
        }
        else if (_isDashing && _dashSprite->getFrame() >= _dash->getHitboxStartFrame() - 1) {
            setMovement(face * 15000);
        }
    }
    else {
        if (_moveDuration > 0) {
            if (_worldTop - getPosition().y <= 6) { // near top, quickly move down
                setVerticalMovement(-getForce()*3);
            }
            else {
                if (getPosition().y <= 10) {
                    setVerticalMovement(rand() % 10 <= 7 ? getForce()*3 : -getForce()*3);
                }
                else {
                    setVerticalMovement(rand() % 10 <= 7 ? -getForce()*3 : getForce()*3);
                }
            }

            setMovement(_moveDirection * dir_val * getForce() * 8);
            setMoveLeft(dist > 0);
            setMoveRight(dist < 0);
            _moveDuration--;
        }
        else if (_isDashing && _dashSprite->getFrame() >= _dash->getHitboxStartFrame() - 1) {
            setVerticalMovement(0);
            setMovement(face * 15000);
        }
        else if (_isLaserAttacking && _shootLaserSprite->getFrame() >= _laser->getHitboxStartFrame() - 1) {
            setVerticalMovement(0);
            setMovement(face * 5000);
        }
        else {
            setMovement(0);
        }
    }
    
}

void Boss3Model::uppercut() {
    faceTarget();
    _isUppercutting = true;
    setMovement(0);
}

void Boss3Model::slam() {
	faceTarget();
	_isSlamming = true;
	setMovement(0);
}

void Boss3Model::jump() {
    if (getPosition().y < 4.2) {
        faceTarget();
        _isJumpStarting = true;
        setMovement(0);
    }
}

void Boss3Model::handleJump() {
    if (_isJumpStarting && _jumpStartSprite->getFrame() >= _jumpStartSprite->getCount() - 1) {
        _isJumpStarting = false;
        _isJumpWaiting = true;
    }
    else if (_isJumpWaiting && getPosition().y < 4.6 && getLinearVelocity().y < 0) {
        _isJumpWaiting = false;
        _isJumpEnding = true;
    }
    else if (_isJumpEnding && _jumpEndSprite->getFrame() >= _jumpEndSprite->getCount() - 1) {
        _isJumpEnding = false;
		setMovement(0);
    }
}

bool Boss3Model::isJumpingUp() {
	return _isJumpStarting && _jumpStartSprite->getFrame() >= 6;
}

void Boss3Model::dash() {
	faceTarget();
    if (_isGroundForm) {
		_isGroundDashStarting = true;
    }
    else {
        _isDashing = true;
        setMovement(0);
        setVerticalMovement(0);
    }
}

void Boss3Model::handleGroundDash() {
	if (_isGroundDashStarting && _groundTransformSprite->getFrame() >= _groundTransformSprite->getCount() - 1) {
		_isGroundDashStarting = false;
		_isDashing = true;
	}
	else if (_isDashing && _dashSprite->getFrame() >= _dashSprite->getCount() - 1) {
		_isDashing = false;
		_isGroundDashEnding = true;
    }
    else if (_isGroundDashEnding && _airTransformSprite->getFrame() >= _airTransformSprite->getCount() - 1) {
		_isGroundDashEnding = false;
        setMovement(0);
    }
}

void Boss3Model::shoot(int repeat) {
	faceTarget();
	_isShootStarting = true;
	_shootCount = repeat;
}

void Boss3Model::handleShoot() {
	if (_isShootStarting && _shootStartSprite->getFrame() >= _shootStartSprite->getCount() - 1) {
        if (rand() % 2 == 0) { // shoot projectile
            _isShootStarting = false;
            _isShootAttacking = true;
            _shootCount--;
        }
        else {
            _isShootStarting = false;
            _isLaserAttacking = true;
            _shootCount = 0;
        }
	}
	else if (_isShootAttacking && _shootAttackSprite->getFrame() >= _shootAttackSprite->getCount() - 1) {
        // wait for a bit
		_isShootAttacking = false;
		_isShootWaiting = true;
	}
	else if (_isShootWaiting && _shootWaitSprite->getFrame() >= _shootWaitSprite->getCount() - 1) {
		if (_shootCount <= 0) { // stop repeated shooting
			_isShootWaiting = false;
		}
		else { // return to shooting
			_isShootWaiting = false;
			_isShootAttacking = true;
			_shootCount--;
		}
    }
    else if (_isLaserAttacking && _shootLaserSprite->getFrame() >= _shootLaserSprite->getCount() - 1) {
        _isLaserAttacking = false;
		_isShootWaiting = true;
    }
}

void Boss3Model::laser() {
    faceTarget();
}

std::shared_ptr<MeleeActionModel> Boss3Model::getDamagingAction() {
	if (_isUppercutting && _uppercutSprite->getFrame() == _uppercut->getHitboxStartFrame() - 1) {
		return _uppercut;
	}
    else if (_isSlamming && _slamSprite->getFrame() == _slam->getHitboxStartFrame() - 1) {
        return _slam;
	}
	else if (_isJumpWaiting && _jumpWaitSprite->getFrame() == _jump->getHitboxStartFrame() - 1) {
		return _jump;
	}
	else if (_isDashing && _dashSprite->getFrame() == _dash->getHitboxStartFrame() - 1) {
		return _dash;
    }
    else if (_isLaserAttacking && _shootLaserSprite->getFrame() == _laser->getHitboxStartFrame() - 1) {
        return _laser;
    }
    return nullptr;
}

std::shared_ptr<Projectile> Boss3Model::getProjectile() {
    std::vector<int> frames = _shoot->getProjectileSpawnFrames();
    int count = 0;
    for (int frame : frames) {
        if (_isShootAttacking && _shootAttackSprite->getFrame() == frame && frameCounter == 0) {
            return _shoot->getProjectiles()[count];
        }
        count++;
    }
    return nullptr


Each update begins in nextAction() by calling AIMove() and, if the boss is not locked into any action, routing to either handleGroundAction or handleAirAction based on the current form. Ground form favors close-range choices—uppercut, slam, or a jump—while distance prompts a dash or a jump; air form reacts to the target’s vertical offset with dashes, short approach/avoid bursts, or conditional shooting. A random integer (r) is sampled and reduced with modulo checks, giving controlled unpredictability within each context.

If the boss is stunned, nextAction() immediately clears all active flags, zeroes movement, and waits until the stun animation finishes; on recovery, the form flips between ground and air and gravity is disabled when switching into air form. Actions that have finished their one-shot animations (for example, uppercut or slam) also clear their flags here, returning control to the selector on the next tick.

AIMove() handles locomotion for both forms. On the ground, the boss nudges toward or away from the target for a timed duration, cleanly halts when the timer expires, leaps forward during the ascending part of a jump, and injects a large horizontal impulse once a dash reaches its hitbox-active frame. In the air, it biases vertical velocity to keep the boss within a playable band (dropping quickly near the ceiling, otherwise bobbing up or down based on height), applies strong lateral movement while a move timer runs, and delivers distinct surges during dash or laser attack windows so the motion matches the attack’s animation frames.

Each ability helper only sets intent and timing flags. uppercut(), slam(), and jump() face the target and prime their respective animations, with jump broken into start/wait/end phases managed by handleJump(); dash() chooses a ground dash sequence (start → dash → end) via handleGroundDash() or an immediate air dash; and shoot(repeat) enters a shooting sequence managed by handleShoot(), which randomly branches into either repeated projectile volleys (attack → wait loops for _shootCount times) or a single laser attack, then cools down.

Damage and projectiles are frame-accurate. getDamagingAction() returns the current melee (or laser) action only on the exact animation frame where its hitbox begins, and getProjectile() emits a projectile only when the shoot animation reaches one of its designated spawn frames and the per-frame counter is aligned, preventing duplicate spawns. Together, these checks ensure that Boss 3’s tells, movement, and hit windows remain readable and fair while still feeling varied and aggressive.

TECHNICAL CHALLENGES

Software Architecture

The codebase for Glitchblade follows an MVC-inspired structure that separates responsibilities between models, controllers, and views. At the top level, the application is launched through a central Main → App entry point, which initializes the game and passes control to different scene controllers. This ensures that setup and flow remain centralized rather than scattered across the codebase.

The view layer is handled through a single SceneNode tree, which renders the game world. Controllers never draw directly to the screen. Instead, they update models, and the view reflects those changes, maintaining a strict boundary between logic and presentation.

The controller layer is where most of the gameplay logic lives. It is divided into scene controllers—such as TitleScene, LevelSelectScene, LoadingScene, and GameScene—each responsible for a specific stage of the player’s experience. Scene controllers handle transitions and overall flow, while the real depth occurs inside the game controllers that operate within the GameScene. Here, the LevelController orchestrates the state of a level, coordinating with specialized controllers like the PlayerController, EnemyController, and CollisionController. Player input is routed through the InputController, which interprets swipe gestures and taps into meaningful in-game actions. Enemies are managed by the EnemyController and its type-specific subclasses, ensuring that each enemy type maintains its own behavior logic. The CollisionController ties the system together by resolving interactions among all collidable objects—hitboxes, projectiles, and characters.

The model layer represents the authoritative state of the game world. At its center is the LevelModel, which aggregates the data for the player, enemies, projectiles, and environmental hazards. Collidable models, such as PlayerModel, EnemyModel, ProjectileModel, and HitboxModel, encapsulate the properties of interactive game entities. Combat is made data-driven through ActionModel and its specialized forms (MeleeActionModel and RangedActionModel), which define the timing, damage, and behavior of attacks. This structure makes it easy to extend or adjust combat without rewriting core systems.

The core gameplay loop below reflects this architecture in action. Each cycle begins with state checks—verifying whether the level is complete or whether the player is still alive. Depending on these conditions, the game either transitions to victory or defeat screens, or continues into moment-to-moment play. Player input is gathered and mapped to intuitive gestures: swiping left or right executes a dash, tapping performs a guard or parry, swiping up triggers a jump, and dragging allows for strafing. Cooldown checks and collision detection ensure that every action is governed by timing and risk.

Once the player’s actions are processed, enemy AI determines its own behavior and performs attacks, keeping pressure on the player. Physics updates then advance positions and projectiles, while the collision system resolves interactions—whether a parry deflects a projectile, a dash avoids a strike, or a hit connects to deal damage. Player and enemy states are updated in real time, handling health, stun states, and phase changes for bosses. When enemies are defeated, the level state is updated to progress toward completion. Finally, the view layer renders all game objects and HUD elements, with minimal on-screen UI to maintain immersion and clarity.

This architecture ensures that Glitchblade’s design pillars—fast-paced combat, high risk–high reward mechanics, and intuitive controls—are supported at a code level. The modular structure makes each system easy to maintain and expand, while the gameplay loop emphasizes responsiveness, fairness, and clarity, delivering a combat experience that feels both fluid and deliberate.

Animation System

The enemy animation system is divided into three main behaviors: looping animations, one-time animations, and synchronized VFX animations.

  1. Looping Animations: The playAnimation() method advances the sprite’s frame on a timed interval (E_ANIMATION_UPDATE_FRAME). Once the last frame is reached, it loops back to the first, ensuring continuous cycles for idle or patrol animations. If the sprite is invisible, the frame resets to 0.


  2. One-Time Animations: The playAnimationOnce() method also increments frames on the same interval, but stops once the final frame is reached. This is used for actions like attacks or deaths where the animation should not loop automatically.

  3. VFX Animations: The playVFXAnimation() method synchronizes a visual effect sprite with an action sprite. When the action sprite hits a specific startFrame, the VFX resets to frame 0 and then plays forward as the action continues, stopping at the final frame.


void EnemyModel::playAnimation(std::shared_ptr<scene2::SpriteNode> sprite) {
    if (sprite->isVisible()) {
        frameCounter = (frameCounter + 1) % E_ANIMATION_UPDATE_FRAME;
        if (frameCounter % E_ANIMATION_UPDATE_FRAME == 0) {
            sprite->setFrame((sprite->getFrame() + 1) % sprite->getCount());
        }
    }
    else {
        sprite->setFrame(0);
    }
}

void EnemyModel::playAnimationOnce(std::shared_ptr<scene2::SpriteNode> sprite)
{
    if (sprite->isVisible()) {
        frameCounter = (frameCounter + 1) % E_ANIMATION_UPDATE_FRAME;
        if (frameCounter % E_ANIMATION_UPDATE_FRAME == 0 && sprite->getFrame() < sprite->getCount() - 1) {
            sprite->setFrame(sprite->getFrame() + 1);
        }
    }
    else {
        sprite->setFrame(0);
    }
}

void EnemyModel::playVFXAnimation(std::shared_ptr<scene2::SpriteNode> actionSprite, std::shared_ptr<scene2::SpriteNode> vfxSprite, int startFrame)
{
    if (actionSprite->isVisible()) {
        if (actionSprite->getFrame() == startFrame) {
            vfxSprite->setFrame(0);
        }

        else if (actionSprite->getFrame() > startFrame) {
            if (frameCounter % E_ANIMATION_UPDATE_FRAME == 0 && vfxSprite->getFrame() < vfxSprite->getCount() - 1) {
                vfxSprite->setFrame(vfxSprite->getFrame() + 1);
            }
        }
	}
    else {
        vfxSprite->setFrame(0);
    }
}

void EnemyModel::updateAnimation

Below is an example of an implementation of Boss 1's animation system.

The updateAnimation() method manages Boss 1’s entire visual state, functioning as a lightweight animation controller. It determines which sprites are visible based on the boss’s condition: stun overrides all other actions, walking only appears when the boss is moving without attacking, and individual attack sprites such as slam, stab, shoot, or explode are shown when their flags are active. The idle sprite is displayed when no actions are in progress, while the spawn sprite is enabled during the spawning sequence. To maintain consistency, the explosion VFX sprite only becomes visible during the final frames of the explosion animation, keeping the effect synchronized with the attack.

Once sprite visibility is set, animations are updated according to their type. Looping animations such as walking and idle repeat continuously, while one-shot animations such as slam, stab, stun, shoot, explode, and spawn advance only once before halting at their last frame. The explosion VFX animation is explicitly linked to the explosion sprite, beginning at the precise hitbox activation frame to ensure the visuals match gameplay timing. Damage feedback is applied through a separate effect, reinforcing the impact of player attacks. Finally, the boss’s orientation is corrected by flipping the node and its child nodes horizontally, ensuring that the character always faces its target.

void Boss1Model::updateAnimation()
{
    if (isKnockbackActive()){
        CULog("knockback active for boss 1");
    }

    _stunSprite->setVisible(isStunned());

    _walkSprite->setVisible(!isStunned() && !_isStabbing && !_isSlamming && !_isShooting && !_isExploding && (isMoveLeft() || isMoveRight()));

    _slamSprite->setVisible(!isStunned() && _isSlamming);

    _stabSprite->setVisible(!isStunned() && _isStabbing);

	_shootSprite->setVisible(!isStunned() && _isShooting);

	_explodeSprite->setVisible(!isStunned() && _isExploding);

    _explodeVFXSprite->setVisible(_explodeSprite->isVisible() && _explodeSprite->getFrame() >= _explodeSprite->getCount() - _explodeVFXSprite->getCount());

    _idleSprite->setVisible(!isStunned() && !_isStabbing && !_isSlamming && !_isShooting && !_isExploding && !(isMoveLeft() || isMoveRight()));

	_spawnSprite->setVisible(isSpawning);

    playAnimation(_walkSprite);
    playAnimation(_idleSprite);
    playAnimationOnce(_slamSprite);
    playAnimationOnce(_stabSprite);
    playAnimationOnce(_stunSprite);
	playAnimationOnce(_shootSprite);
	playAnimationOnce(_explodeSprite);
	playAnimationOnce(_spawnSprite);

    playVFXAnimation(_explodeSprite, _explodeVFXSprite, _explode->getHitboxStartFrame() - 1);

    playDamagedEffect();

    _node->setScale(Vec2(isFacingRight() ? 1 : -1, 1));
    _node->getChild(_node->getChildCount() - 2)->setScale(Vec2(isFacingRight() ? 1 : -1, 1));
    _node->getChild(_node->getChildCount() - 1)->setScale(Vec2(isFacingRight() ? 1 : -1, 1

Enemy AI SYSTEM

The enemy AI runs as a form-swapping state machine that chooses an action only when the boss is free (not stunned and not mid-attack), then steers movement and timings so hitboxes and VFX line up with animation frames. To illustrate the example, we'll look into the implementation of Boss 3's AI:

void Boss3Model::nextAction() {
    int r = rand();
    AIMove();
	if (!isStunned() && !_isUppercutting && !_isSlamming && !getIsJumping() && !_isDashing && !_isGroundDashStarting && !_isGroundDashEnding
        && !_isShootStarting && !_isShootAttacking && !_isLaserAttacking && !_isShootWaiting) {
        if (_isGroundForm) {
            handleGroundAction(r);
        }
        else {
            handleAirAction(r);
        }
    }
    else {
        if (isStunned()) {
            _isUppercutting = false;
			_isSlamming = false;
			_isJumpStarting = false;
			_isJumpWaiting = false;
			_isJumpEnding = false;
			_isDashing = false;
			_isGroundDashStarting = false;
			_isGroundDashEnding = false;
			_isShootStarting = false;
			_isShootAttacking = false;
			_isLaserAttacking = false;
			_isShootWaiting = false;
			
            setMovement(0);

            if (_groundStunSprite->getFrame() >= _groundStunSprite->getCount() - 1 || _airStunSprite->getFrame() >= _airStunSprite->getCount() - 1) {
				_isGroundForm = !_isGroundForm;
                if (!_isGroundForm) { setGravityScale(0); }
            }
        }
        if (_isUppercutting && _uppercutSprite->getFrame() >= _uppercutSprite->getCount() - 1) {
            _isUppercutting = false;
            setMovement(0);
        }
		if (_isSlamming && _slamSprite->getFrame() >= _slamSprite->getCount() - 1) {
			_isSlamming = false;
			setMovement(0);
		}
        if (_isJumpStarting || _isJumpWaiting || _isJumpEnding) {
            handleJump();
        }
        if (_isDashing || _isGroundDashStarting || _isGroundDashEnding) {
            if (_isGroundForm) {
                handleGroundDash();
            }
            else {
                if (_isDashing && _dashSprite->getFrame() >= _dashSprite->getCount() - 1) {
                    _isDashing = false;
                    setMovement(0);
                }
            }
        }
        if (_isShootStarting || _isShootAttacking || _isLaserAttacking || _isShootWaiting) {
			handleShoot();
        }
    }
}

void Boss3Model::handleGroundAction(int r) {
    if (isTargetClose()) {
        if (r % 3 == 0) { // Uppercut
            uppercut();
        }
        else if (r % 3 == 1) { // Slam
            slam();
        }
        else { // Jump
            jump();
        }
    }
    else {
        if (r % 3 == 0) {
            dash();
        }
        else {
            jump();
        }
    }
}

void Boss3Model::handleAirAction(int r) {
    if (std::abs(_targetPos.y - getPosition().y) <= 1.5 && r % 4 == 0) {
        dash();
    }
    else if (!isTargetFar()) {
        if (r % 3 == 0) {
            avoidTarget(60);
        }
        else if (r % 3 == 1) {
            approachTarget(60);
        }
        else if (_targetPos.y - getPosition().y <= -8){
            shoot(1);
        }
        
    }
    else {
        if (r % 3 == 0) {
            approachTarget(60);
        }
        else if (r % 3 == 1) {
            avoidTarget(60);
        }
        else if (_targetPos.y - getPosition().y <= -8) {
            shoot(3);
        }
    }
}

void Boss3Model::AIMove() {
    float dist = getPosition().x - _targetPos.x;
    float dir_val = dist > 0 ? -1 : 1;
    int face = _faceRight ? 1 : -1;

    if (_isGroundForm) {
        if (!getIsJumping() && !_isDashing) {
            setVerticalMovement(0);
            if (_moveDuration == 0) {
                setMovement(0);
            }
            else {
                setMovement(_moveDirection * dir_val * getForce());
                setMoveLeft(dist > 0);
                setMoveRight(dist < 0);
                _moveDuration--;
            }
        }
        else if (getIsJumping() && isJumpingUp()) {
            setMovement(dir_val * getForce() * 2);
            setVerticalMovement(getForce() * 2);
        }
        else if (_isDashing && _dashSprite->getFrame() >= _dash->getHitboxStartFrame() - 1) {
            setMovement(face * 15000);
        }
    }
    else {
        if (_moveDuration > 0) {
            if (_worldTop - getPosition().y <= 6) { // near top, quickly move down
                setVerticalMovement(-getForce()*3);
            }
            else {
                if (getPosition().y <= 10) {
                    setVerticalMovement(rand() % 10 <= 7 ? getForce()*3 : -getForce()*3);
                }
                else {
                    setVerticalMovement(rand() % 10 <= 7 ? -getForce()*3 : getForce()*3);
                }
            }

            setMovement(_moveDirection * dir_val * getForce() * 8);
            setMoveLeft(dist > 0);
            setMoveRight(dist < 0);
            _moveDuration--;
        }
        else if (_isDashing && _dashSprite->getFrame() >= _dash->getHitboxStartFrame() - 1) {
            setVerticalMovement(0);
            setMovement(face * 15000);
        }
        else if (_isLaserAttacking && _shootLaserSprite->getFrame() >= _laser->getHitboxStartFrame() - 1) {
            setVerticalMovement(0);
            setMovement(face * 5000);
        }
        else {
            setMovement(0);
        }
    }
    
}

void Boss3Model::uppercut() {
    faceTarget();
    _isUppercutting = true;
    setMovement(0);
}

void Boss3Model::slam() {
	faceTarget();
	_isSlamming = true;
	setMovement(0);
}

void Boss3Model::jump() {
    if (getPosition().y < 4.2) {
        faceTarget();
        _isJumpStarting = true;
        setMovement(0);
    }
}

void Boss3Model::handleJump() {
    if (_isJumpStarting && _jumpStartSprite->getFrame() >= _jumpStartSprite->getCount() - 1) {
        _isJumpStarting = false;
        _isJumpWaiting = true;
    }
    else if (_isJumpWaiting && getPosition().y < 4.6 && getLinearVelocity().y < 0) {
        _isJumpWaiting = false;
        _isJumpEnding = true;
    }
    else if (_isJumpEnding && _jumpEndSprite->getFrame() >= _jumpEndSprite->getCount() - 1) {
        _isJumpEnding = false;
		setMovement(0);
    }
}

bool Boss3Model::isJumpingUp() {
	return _isJumpStarting && _jumpStartSprite->getFrame() >= 6;
}

void Boss3Model::dash() {
	faceTarget();
    if (_isGroundForm) {
		_isGroundDashStarting = true;
    }
    else {
        _isDashing = true;
        setMovement(0);
        setVerticalMovement(0);
    }
}

void Boss3Model::handleGroundDash() {
	if (_isGroundDashStarting && _groundTransformSprite->getFrame() >= _groundTransformSprite->getCount() - 1) {
		_isGroundDashStarting = false;
		_isDashing = true;
	}
	else if (_isDashing && _dashSprite->getFrame() >= _dashSprite->getCount() - 1) {
		_isDashing = false;
		_isGroundDashEnding = true;
    }
    else if (_isGroundDashEnding && _airTransformSprite->getFrame() >= _airTransformSprite->getCount() - 1) {
		_isGroundDashEnding = false;
        setMovement(0);
    }
}

void Boss3Model::shoot(int repeat) {
	faceTarget();
	_isShootStarting = true;
	_shootCount = repeat;
}

void Boss3Model::handleShoot() {
	if (_isShootStarting && _shootStartSprite->getFrame() >= _shootStartSprite->getCount() - 1) {
        if (rand() % 2 == 0) { // shoot projectile
            _isShootStarting = false;
            _isShootAttacking = true;
            _shootCount--;
        }
        else {
            _isShootStarting = false;
            _isLaserAttacking = true;
            _shootCount = 0;
        }
	}
	else if (_isShootAttacking && _shootAttackSprite->getFrame() >= _shootAttackSprite->getCount() - 1) {
        // wait for a bit
		_isShootAttacking = false;
		_isShootWaiting = true;
	}
	else if (_isShootWaiting && _shootWaitSprite->getFrame() >= _shootWaitSprite->getCount() - 1) {
		if (_shootCount <= 0) { // stop repeated shooting
			_isShootWaiting = false;
		}
		else { // return to shooting
			_isShootWaiting = false;
			_isShootAttacking = true;
			_shootCount--;
		}
    }
    else if (_isLaserAttacking && _shootLaserSprite->getFrame() >= _shootLaserSprite->getCount() - 1) {
        _isLaserAttacking = false;
		_isShootWaiting = true;
    }
}

void Boss3Model::laser() {
    faceTarget();
}

std::shared_ptr<MeleeActionModel> Boss3Model::getDamagingAction() {
	if (_isUppercutting && _uppercutSprite->getFrame() == _uppercut->getHitboxStartFrame() - 1) {
		return _uppercut;
	}
    else if (_isSlamming && _slamSprite->getFrame() == _slam->getHitboxStartFrame() - 1) {
        return _slam;
	}
	else if (_isJumpWaiting && _jumpWaitSprite->getFrame() == _jump->getHitboxStartFrame() - 1) {
		return _jump;
	}
	else if (_isDashing && _dashSprite->getFrame() == _dash->getHitboxStartFrame() - 1) {
		return _dash;
    }
    else if (_isLaserAttacking && _shootLaserSprite->getFrame() == _laser->getHitboxStartFrame() - 1) {
        return _laser;
    }
    return nullptr;
}

std::shared_ptr<Projectile> Boss3Model::getProjectile() {
    std::vector<int> frames = _shoot->getProjectileSpawnFrames();
    int count = 0;
    for (int frame : frames) {
        if (_isShootAttacking && _shootAttackSprite->getFrame() == frame && frameCounter == 0) {
            return _shoot->getProjectiles()[count];
        }
        count++;
    }
    return nullptr


Each update begins in nextAction() by calling AIMove() and, if the boss is not locked into any action, routing to either handleGroundAction or handleAirAction based on the current form. Ground form favors close-range choices—uppercut, slam, or a jump—while distance prompts a dash or a jump; air form reacts to the target’s vertical offset with dashes, short approach/avoid bursts, or conditional shooting. A random integer (r) is sampled and reduced with modulo checks, giving controlled unpredictability within each context.

If the boss is stunned, nextAction() immediately clears all active flags, zeroes movement, and waits until the stun animation finishes; on recovery, the form flips between ground and air and gravity is disabled when switching into air form. Actions that have finished their one-shot animations (for example, uppercut or slam) also clear their flags here, returning control to the selector on the next tick.

AIMove() handles locomotion for both forms. On the ground, the boss nudges toward or away from the target for a timed duration, cleanly halts when the timer expires, leaps forward during the ascending part of a jump, and injects a large horizontal impulse once a dash reaches its hitbox-active frame. In the air, it biases vertical velocity to keep the boss within a playable band (dropping quickly near the ceiling, otherwise bobbing up or down based on height), applies strong lateral movement while a move timer runs, and delivers distinct surges during dash or laser attack windows so the motion matches the attack’s animation frames.

Each ability helper only sets intent and timing flags. uppercut(), slam(), and jump() face the target and prime their respective animations, with jump broken into start/wait/end phases managed by handleJump(); dash() chooses a ground dash sequence (start → dash → end) via handleGroundDash() or an immediate air dash; and shoot(repeat) enters a shooting sequence managed by handleShoot(), which randomly branches into either repeated projectile volleys (attack → wait loops for _shootCount times) or a single laser attack, then cools down.

Damage and projectiles are frame-accurate. getDamagingAction() returns the current melee (or laser) action only on the exact animation frame where its hitbox begins, and getProjectile() emits a projectile only when the shoot animation reaches one of its designated spawn frames and the per-frame counter is aligned, preventing duplicate spawns. Together, these checks ensure that Boss 3’s tells, movement, and hit windows remain readable and fair while still feeling varied and aggressive.

TECHNICAL CHALLENGES

Software Architecture

The codebase for Glitchblade follows an MVC-inspired structure that separates responsibilities between models, controllers, and views. At the top level, the application is launched through a central Main → App entry point, which initializes the game and passes control to different scene controllers. This ensures that setup and flow remain centralized rather than scattered across the codebase.

The view layer is handled through a single SceneNode tree, which renders the game world. Controllers never draw directly to the screen. Instead, they update models, and the view reflects those changes, maintaining a strict boundary between logic and presentation.

The controller layer is where most of the gameplay logic lives. It is divided into scene controllers—such as TitleScene, LevelSelectScene, LoadingScene, and GameScene—each responsible for a specific stage of the player’s experience. Scene controllers handle transitions and overall flow, while the real depth occurs inside the game controllers that operate within the GameScene. Here, the LevelController orchestrates the state of a level, coordinating with specialized controllers like the PlayerController, EnemyController, and CollisionController. Player input is routed through the InputController, which interprets swipe gestures and taps into meaningful in-game actions. Enemies are managed by the EnemyController and its type-specific subclasses, ensuring that each enemy type maintains its own behavior logic. The CollisionController ties the system together by resolving interactions among all collidable objects—hitboxes, projectiles, and characters.

The model layer represents the authoritative state of the game world. At its center is the LevelModel, which aggregates the data for the player, enemies, projectiles, and environmental hazards. Collidable models, such as PlayerModel, EnemyModel, ProjectileModel, and HitboxModel, encapsulate the properties of interactive game entities. Combat is made data-driven through ActionModel and its specialized forms (MeleeActionModel and RangedActionModel), which define the timing, damage, and behavior of attacks. This structure makes it easy to extend or adjust combat without rewriting core systems.

The core gameplay loop below reflects this architecture in action. Each cycle begins with state checks—verifying whether the level is complete or whether the player is still alive. Depending on these conditions, the game either transitions to victory or defeat screens, or continues into moment-to-moment play. Player input is gathered and mapped to intuitive gestures: swiping left or right executes a dash, tapping performs a guard or parry, swiping up triggers a jump, and dragging allows for strafing. Cooldown checks and collision detection ensure that every action is governed by timing and risk.

Once the player’s actions are processed, enemy AI determines its own behavior and performs attacks, keeping pressure on the player. Physics updates then advance positions and projectiles, while the collision system resolves interactions—whether a parry deflects a projectile, a dash avoids a strike, or a hit connects to deal damage. Player and enemy states are updated in real time, handling health, stun states, and phase changes for bosses. When enemies are defeated, the level state is updated to progress toward completion. Finally, the view layer renders all game objects and HUD elements, with minimal on-screen UI to maintain immersion and clarity.

This architecture ensures that Glitchblade’s design pillars—fast-paced combat, high risk–high reward mechanics, and intuitive controls—are supported at a code level. The modular structure makes each system easy to maintain and expand, while the gameplay loop emphasizes responsiveness, fairness, and clarity, delivering a combat experience that feels both fluid and deliberate.

Animation System

The enemy animation system is divided into three main behaviors: looping animations, one-time animations, and synchronized VFX animations.

  1. Looping Animations: The playAnimation() method advances the sprite’s frame on a timed interval (E_ANIMATION_UPDATE_FRAME). Once the last frame is reached, it loops back to the first, ensuring continuous cycles for idle or patrol animations. If the sprite is invisible, the frame resets to 0.


  2. One-Time Animations: The playAnimationOnce() method also increments frames on the same interval, but stops once the final frame is reached. This is used for actions like attacks or deaths where the animation should not loop automatically.

  3. VFX Animations: The playVFXAnimation() method synchronizes a visual effect sprite with an action sprite. When the action sprite hits a specific startFrame, the VFX resets to frame 0 and then plays forward as the action continues, stopping at the final frame.


void EnemyModel::playAnimation(std::shared_ptr<scene2::SpriteNode> sprite) {
    if (sprite->isVisible()) {
        frameCounter = (frameCounter + 1) % E_ANIMATION_UPDATE_FRAME;
        if (frameCounter % E_ANIMATION_UPDATE_FRAME == 0) {
            sprite->setFrame((sprite->getFrame() + 1) % sprite->getCount());
        }
    }
    else {
        sprite->setFrame(0);
    }
}

void EnemyModel::playAnimationOnce(std::shared_ptr<scene2::SpriteNode> sprite)
{
    if (sprite->isVisible()) {
        frameCounter = (frameCounter + 1) % E_ANIMATION_UPDATE_FRAME;
        if (frameCounter % E_ANIMATION_UPDATE_FRAME == 0 && sprite->getFrame() < sprite->getCount() - 1) {
            sprite->setFrame(sprite->getFrame() + 1);
        }
    }
    else {
        sprite->setFrame(0);
    }
}

void EnemyModel::playVFXAnimation(std::shared_ptr<scene2::SpriteNode> actionSprite, std::shared_ptr<scene2::SpriteNode> vfxSprite, int startFrame)
{
    if (actionSprite->isVisible()) {
        if (actionSprite->getFrame() == startFrame) {
            vfxSprite->setFrame(0);
        }

        else if (actionSprite->getFrame() > startFrame) {
            if (frameCounter % E_ANIMATION_UPDATE_FRAME == 0 && vfxSprite->getFrame() < vfxSprite->getCount() - 1) {
                vfxSprite->setFrame(vfxSprite->getFrame() + 1);
            }
        }
	}
    else {
        vfxSprite->setFrame(0);
    }
}

void EnemyModel::updateAnimation

Below is an example of an implementation of Boss 1's animation system.

The updateAnimation() method manages Boss 1’s entire visual state, functioning as a lightweight animation controller. It determines which sprites are visible based on the boss’s condition: stun overrides all other actions, walking only appears when the boss is moving without attacking, and individual attack sprites such as slam, stab, shoot, or explode are shown when their flags are active. The idle sprite is displayed when no actions are in progress, while the spawn sprite is enabled during the spawning sequence. To maintain consistency, the explosion VFX sprite only becomes visible during the final frames of the explosion animation, keeping the effect synchronized with the attack.

Once sprite visibility is set, animations are updated according to their type. Looping animations such as walking and idle repeat continuously, while one-shot animations such as slam, stab, stun, shoot, explode, and spawn advance only once before halting at their last frame. The explosion VFX animation is explicitly linked to the explosion sprite, beginning at the precise hitbox activation frame to ensure the visuals match gameplay timing. Damage feedback is applied through a separate effect, reinforcing the impact of player attacks. Finally, the boss’s orientation is corrected by flipping the node and its child nodes horizontally, ensuring that the character always faces its target.

void Boss1Model::updateAnimation()
{
    if (isKnockbackActive()){
        CULog("knockback active for boss 1");
    }

    _stunSprite->setVisible(isStunned());

    _walkSprite->setVisible(!isStunned() && !_isStabbing && !_isSlamming && !_isShooting && !_isExploding && (isMoveLeft() || isMoveRight()));

    _slamSprite->setVisible(!isStunned() && _isSlamming);

    _stabSprite->setVisible(!isStunned() && _isStabbing);

	_shootSprite->setVisible(!isStunned() && _isShooting);

	_explodeSprite->setVisible(!isStunned() && _isExploding);

    _explodeVFXSprite->setVisible(_explodeSprite->isVisible() && _explodeSprite->getFrame() >= _explodeSprite->getCount() - _explodeVFXSprite->getCount());

    _idleSprite->setVisible(!isStunned() && !_isStabbing && !_isSlamming && !_isShooting && !_isExploding && !(isMoveLeft() || isMoveRight()));

	_spawnSprite->setVisible(isSpawning);

    playAnimation(_walkSprite);
    playAnimation(_idleSprite);
    playAnimationOnce(_slamSprite);
    playAnimationOnce(_stabSprite);
    playAnimationOnce(_stunSprite);
	playAnimationOnce(_shootSprite);
	playAnimationOnce(_explodeSprite);
	playAnimationOnce(_spawnSprite);

    playVFXAnimation(_explodeSprite, _explodeVFXSprite, _explode->getHitboxStartFrame() - 1);

    playDamagedEffect();

    _node->setScale(Vec2(isFacingRight() ? 1 : -1, 1));
    _node->getChild(_node->getChildCount() - 2)->setScale(Vec2(isFacingRight() ? 1 : -1, 1));
    _node->getChild(_node->getChildCount() - 1)->setScale(Vec2(isFacingRight() ? 1 : -1, 1

Enemy AI SYSTEM

The enemy AI runs as a form-swapping state machine that chooses an action only when the boss is free (not stunned and not mid-attack), then steers movement and timings so hitboxes and VFX line up with animation frames. To illustrate the example, we'll look into the implementation of Boss 3's AI:

void Boss3Model::nextAction() {
    int r = rand();
    AIMove();
	if (!isStunned() && !_isUppercutting && !_isSlamming && !getIsJumping() && !_isDashing && !_isGroundDashStarting && !_isGroundDashEnding
        && !_isShootStarting && !_isShootAttacking && !_isLaserAttacking && !_isShootWaiting) {
        if (_isGroundForm) {
            handleGroundAction(r);
        }
        else {
            handleAirAction(r);
        }
    }
    else {
        if (isStunned()) {
            _isUppercutting = false;
			_isSlamming = false;
			_isJumpStarting = false;
			_isJumpWaiting = false;
			_isJumpEnding = false;
			_isDashing = false;
			_isGroundDashStarting = false;
			_isGroundDashEnding = false;
			_isShootStarting = false;
			_isShootAttacking = false;
			_isLaserAttacking = false;
			_isShootWaiting = false;
			
            setMovement(0);

            if (_groundStunSprite->getFrame() >= _groundStunSprite->getCount() - 1 || _airStunSprite->getFrame() >= _airStunSprite->getCount() - 1) {
				_isGroundForm = !_isGroundForm;
                if (!_isGroundForm) { setGravityScale(0); }
            }
        }
        if (_isUppercutting && _uppercutSprite->getFrame() >= _uppercutSprite->getCount() - 1) {
            _isUppercutting = false;
            setMovement(0);
        }
		if (_isSlamming && _slamSprite->getFrame() >= _slamSprite->getCount() - 1) {
			_isSlamming = false;
			setMovement(0);
		}
        if (_isJumpStarting || _isJumpWaiting || _isJumpEnding) {
            handleJump();
        }
        if (_isDashing || _isGroundDashStarting || _isGroundDashEnding) {
            if (_isGroundForm) {
                handleGroundDash();
            }
            else {
                if (_isDashing && _dashSprite->getFrame() >= _dashSprite->getCount() - 1) {
                    _isDashing = false;
                    setMovement(0);
                }
            }
        }
        if (_isShootStarting || _isShootAttacking || _isLaserAttacking || _isShootWaiting) {
			handleShoot();
        }
    }
}

void Boss3Model::handleGroundAction(int r) {
    if (isTargetClose()) {
        if (r % 3 == 0) { // Uppercut
            uppercut();
        }
        else if (r % 3 == 1) { // Slam
            slam();
        }
        else { // Jump
            jump();
        }
    }
    else {
        if (r % 3 == 0) {
            dash();
        }
        else {
            jump();
        }
    }
}

void Boss3Model::handleAirAction(int r) {
    if (std::abs(_targetPos.y - getPosition().y) <= 1.5 && r % 4 == 0) {
        dash();
    }
    else if (!isTargetFar()) {
        if (r % 3 == 0) {
            avoidTarget(60);
        }
        else if (r % 3 == 1) {
            approachTarget(60);
        }
        else if (_targetPos.y - getPosition().y <= -8){
            shoot(1);
        }
        
    }
    else {
        if (r % 3 == 0) {
            approachTarget(60);
        }
        else if (r % 3 == 1) {
            avoidTarget(60);
        }
        else if (_targetPos.y - getPosition().y <= -8) {
            shoot(3);
        }
    }
}

void Boss3Model::AIMove() {
    float dist = getPosition().x - _targetPos.x;
    float dir_val = dist > 0 ? -1 : 1;
    int face = _faceRight ? 1 : -1;

    if (_isGroundForm) {
        if (!getIsJumping() && !_isDashing) {
            setVerticalMovement(0);
            if (_moveDuration == 0) {
                setMovement(0);
            }
            else {
                setMovement(_moveDirection * dir_val * getForce());
                setMoveLeft(dist > 0);
                setMoveRight(dist < 0);
                _moveDuration--;
            }
        }
        else if (getIsJumping() && isJumpingUp()) {
            setMovement(dir_val * getForce() * 2);
            setVerticalMovement(getForce() * 2);
        }
        else if (_isDashing && _dashSprite->getFrame() >= _dash->getHitboxStartFrame() - 1) {
            setMovement(face * 15000);
        }
    }
    else {
        if (_moveDuration > 0) {
            if (_worldTop - getPosition().y <= 6) { // near top, quickly move down
                setVerticalMovement(-getForce()*3);
            }
            else {
                if (getPosition().y <= 10) {
                    setVerticalMovement(rand() % 10 <= 7 ? getForce()*3 : -getForce()*3);
                }
                else {
                    setVerticalMovement(rand() % 10 <= 7 ? -getForce()*3 : getForce()*3);
                }
            }

            setMovement(_moveDirection * dir_val * getForce() * 8);
            setMoveLeft(dist > 0);
            setMoveRight(dist < 0);
            _moveDuration--;
        }
        else if (_isDashing && _dashSprite->getFrame() >= _dash->getHitboxStartFrame() - 1) {
            setVerticalMovement(0);
            setMovement(face * 15000);
        }
        else if (_isLaserAttacking && _shootLaserSprite->getFrame() >= _laser->getHitboxStartFrame() - 1) {
            setVerticalMovement(0);
            setMovement(face * 5000);
        }
        else {
            setMovement(0);
        }
    }
    
}

void Boss3Model::uppercut() {
    faceTarget();
    _isUppercutting = true;
    setMovement(0);
}

void Boss3Model::slam() {
	faceTarget();
	_isSlamming = true;
	setMovement(0);
}

void Boss3Model::jump() {
    if (getPosition().y < 4.2) {
        faceTarget();
        _isJumpStarting = true;
        setMovement(0);
    }
}

void Boss3Model::handleJump() {
    if (_isJumpStarting && _jumpStartSprite->getFrame() >= _jumpStartSprite->getCount() - 1) {
        _isJumpStarting = false;
        _isJumpWaiting = true;
    }
    else if (_isJumpWaiting && getPosition().y < 4.6 && getLinearVelocity().y < 0) {
        _isJumpWaiting = false;
        _isJumpEnding = true;
    }
    else if (_isJumpEnding && _jumpEndSprite->getFrame() >= _jumpEndSprite->getCount() - 1) {
        _isJumpEnding = false;
		setMovement(0);
    }
}

bool Boss3Model::isJumpingUp() {
	return _isJumpStarting && _jumpStartSprite->getFrame() >= 6;
}

void Boss3Model::dash() {
	faceTarget();
    if (_isGroundForm) {
		_isGroundDashStarting = true;
    }
    else {
        _isDashing = true;
        setMovement(0);
        setVerticalMovement(0);
    }
}

void Boss3Model::handleGroundDash() {
	if (_isGroundDashStarting && _groundTransformSprite->getFrame() >= _groundTransformSprite->getCount() - 1) {
		_isGroundDashStarting = false;
		_isDashing = true;
	}
	else if (_isDashing && _dashSprite->getFrame() >= _dashSprite->getCount() - 1) {
		_isDashing = false;
		_isGroundDashEnding = true;
    }
    else if (_isGroundDashEnding && _airTransformSprite->getFrame() >= _airTransformSprite->getCount() - 1) {
		_isGroundDashEnding = false;
        setMovement(0);
    }
}

void Boss3Model::shoot(int repeat) {
	faceTarget();
	_isShootStarting = true;
	_shootCount = repeat;
}

void Boss3Model::handleShoot() {
	if (_isShootStarting && _shootStartSprite->getFrame() >= _shootStartSprite->getCount() - 1) {
        if (rand() % 2 == 0) { // shoot projectile
            _isShootStarting = false;
            _isShootAttacking = true;
            _shootCount--;
        }
        else {
            _isShootStarting = false;
            _isLaserAttacking = true;
            _shootCount = 0;
        }
	}
	else if (_isShootAttacking && _shootAttackSprite->getFrame() >= _shootAttackSprite->getCount() - 1) {
        // wait for a bit
		_isShootAttacking = false;
		_isShootWaiting = true;
	}
	else if (_isShootWaiting && _shootWaitSprite->getFrame() >= _shootWaitSprite->getCount() - 1) {
		if (_shootCount <= 0) { // stop repeated shooting
			_isShootWaiting = false;
		}
		else { // return to shooting
			_isShootWaiting = false;
			_isShootAttacking = true;
			_shootCount--;
		}
    }
    else if (_isLaserAttacking && _shootLaserSprite->getFrame() >= _shootLaserSprite->getCount() - 1) {
        _isLaserAttacking = false;
		_isShootWaiting = true;
    }
}

void Boss3Model::laser() {
    faceTarget();
}

std::shared_ptr<MeleeActionModel> Boss3Model::getDamagingAction() {
	if (_isUppercutting && _uppercutSprite->getFrame() == _uppercut->getHitboxStartFrame() - 1) {
		return _uppercut;
	}
    else if (_isSlamming && _slamSprite->getFrame() == _slam->getHitboxStartFrame() - 1) {
        return _slam;
	}
	else if (_isJumpWaiting && _jumpWaitSprite->getFrame() == _jump->getHitboxStartFrame() - 1) {
		return _jump;
	}
	else if (_isDashing && _dashSprite->getFrame() == _dash->getHitboxStartFrame() - 1) {
		return _dash;
    }
    else if (_isLaserAttacking && _shootLaserSprite->getFrame() == _laser->getHitboxStartFrame() - 1) {
        return _laser;
    }
    return nullptr;
}

std::shared_ptr<Projectile> Boss3Model::getProjectile() {
    std::vector<int> frames = _shoot->getProjectileSpawnFrames();
    int count = 0;
    for (int frame : frames) {
        if (_isShootAttacking && _shootAttackSprite->getFrame() == frame && frameCounter == 0) {
            return _shoot->getProjectiles()[count];
        }
        count++;
    }
    return nullptr


Each update begins in nextAction() by calling AIMove() and, if the boss is not locked into any action, routing to either handleGroundAction or handleAirAction based on the current form. Ground form favors close-range choices—uppercut, slam, or a jump—while distance prompts a dash or a jump; air form reacts to the target’s vertical offset with dashes, short approach/avoid bursts, or conditional shooting. A random integer (r) is sampled and reduced with modulo checks, giving controlled unpredictability within each context.

If the boss is stunned, nextAction() immediately clears all active flags, zeroes movement, and waits until the stun animation finishes; on recovery, the form flips between ground and air and gravity is disabled when switching into air form. Actions that have finished their one-shot animations (for example, uppercut or slam) also clear their flags here, returning control to the selector on the next tick.

AIMove() handles locomotion for both forms. On the ground, the boss nudges toward or away from the target for a timed duration, cleanly halts when the timer expires, leaps forward during the ascending part of a jump, and injects a large horizontal impulse once a dash reaches its hitbox-active frame. In the air, it biases vertical velocity to keep the boss within a playable band (dropping quickly near the ceiling, otherwise bobbing up or down based on height), applies strong lateral movement while a move timer runs, and delivers distinct surges during dash or laser attack windows so the motion matches the attack’s animation frames.

Each ability helper only sets intent and timing flags. uppercut(), slam(), and jump() face the target and prime their respective animations, with jump broken into start/wait/end phases managed by handleJump(); dash() chooses a ground dash sequence (start → dash → end) via handleGroundDash() or an immediate air dash; and shoot(repeat) enters a shooting sequence managed by handleShoot(), which randomly branches into either repeated projectile volleys (attack → wait loops for _shootCount times) or a single laser attack, then cools down.

Damage and projectiles are frame-accurate. getDamagingAction() returns the current melee (or laser) action only on the exact animation frame where its hitbox begins, and getProjectile() emits a projectile only when the shoot animation reaches one of its designated spawn frames and the per-frame counter is aligned, preventing duplicate spawns. Together, these checks ensure that Boss 3’s tells, movement, and hit windows remain readable and fair while still feeling varied and aggressive.

LEVEL DESIGN

Challenges

Boss Encounters

How can we design bosses that feel challenging and memorable without overwhelming the player?

Enemy Placement and Pacing

How do we arrange enemies to keep combat dynamic while maintaining a fair difficulty curve (i.e. how to keep player in a flow state)?

Introducing New Mechanics

How can the game teach mechanics naturally through play instead of heavy-handed tutorials?

Solutions

Boss Encounters

Designed unique, well-telegraphed attack patterns with clear phase transitions, supported by minions introduced earlier in the level to ease players into the boss’s moveset.

Enemy Placement and Pacing

Combined enemy types so players could apply familiar strategies in new contexts, while alternating combat arenas with platforming sections to balance intensity and give players breathing room.

Introducing New Mechanics

Built levels that use safe spaces, escalating obstacles, and enemy encounters as organic teaching moments, reinforced with subtle UI prompts to guide players without breaking immersion.

Boss Encounters

Designed unique, well-telegraphed attack patterns with clear phase transitions, supported by minions introduced earlier in the level to ease players into the boss’s moveset.

Enemy Placement and Pacing

Combined enemy types so players could apply familiar strategies in new contexts, while alternating combat arenas with platforming sections to balance intensity and give players breathing room.

Introducing New Mechanics

Built levels that use safe spaces, escalating obstacles, and enemy encounters as organic teaching moments, reinforced with subtle UI prompts to guide players without breaking immersion.

LEVEL DESIGN

Challenges

Boss Encounters

How can we design bosses that feel challenging and memorable without overwhelming the player?

Enemy Placement and Pacing

How do we arrange enemies to keep combat dynamic while maintaining a fair difficulty curve (i.e. how to keep player in a flow state)?

Introducing New Mechanics

How can the game teach mechanics naturally through play instead of heavy-handed tutorials?

Solutions

Boss Encounters

Designed unique, well-telegraphed attack patterns with clear phase transitions, supported by minions introduced earlier in the level to ease players into the boss’s moveset.

Enemy Placement and Pacing

Combined enemy types so players could apply familiar strategies in new contexts, while alternating combat arenas with platforming sections to balance intensity and give players breathing room.

Introducing New Mechanics

Built levels that use safe spaces, escalating obstacles, and enemy encounters as organic teaching moments, reinforced with subtle UI prompts to guide players without breaking immersion.

Boss Encounters

Designed unique, well-telegraphed attack patterns with clear phase transitions, supported by minions introduced earlier in the level to ease players into the boss’s moveset.

Enemy Placement and Pacing

Combined enemy types so players could apply familiar strategies in new contexts, while alternating combat arenas with platforming sections to balance intensity and give players breathing room.

Introducing New Mechanics

Built levels that use safe spaces, escalating obstacles, and enemy encounters as organic teaching moments, reinforced with subtle UI prompts to guide players without breaking immersion.

LEVEL DESIGN

Challenges

Boss Encounters

How can we design bosses that feel challenging and memorable without overwhelming the player?

Enemy Placement and Pacing

How do we arrange enemies to keep combat dynamic while maintaining a fair difficulty curve (i.e. how to keep player in a flow state)?

Introducing New Mechanics

How can the game teach mechanics naturally through play instead of heavy-handed tutorials?

Solutions

Boss Encounters

Designed unique, well-telegraphed attack patterns with clear phase transitions, supported by minions introduced earlier in the level to ease players into the boss’s moveset.

Enemy Placement and Pacing

Combined enemy types so players could apply familiar strategies in new contexts, while alternating combat arenas with platforming sections to balance intensity and give players breathing room.

Introducing New Mechanics

Built levels that use safe spaces, escalating obstacles, and enemy encounters as organic teaching moments, reinforced with subtle UI prompts to guide players without breaking immersion.

Boss Encounters

Designed unique, well-telegraphed attack patterns with clear phase transitions, supported by minions introduced earlier in the level to ease players into the boss’s moveset.

Enemy Placement and Pacing

Combined enemy types so players could apply familiar strategies in new contexts, while alternating combat arenas with platforming sections to balance intensity and give players breathing room.

Introducing New Mechanics

Built levels that use safe spaces, escalating obstacles, and enemy encounters as organic teaching moments, reinforced with subtle UI prompts to guide players without breaking immersion.

Copyright © 2025, Andy Pang. All rights reserved.

Copyright © 2025, Andy Pang. All rights reserved.

Copyright © 2025, Andy Pang. All rights reserved.