Invaders

Source: src/invaders.c · 695 lines · 24 functions.

Screen captures

Views of this part running on the Mini Arcade.

Space Invaders game screen on the Mini Arcade
Space Invaders showing the player, shields, and alien formation.

Module entry points

These functions are exposed to the rest of the project. Open one to follow its decisions and the helpers it calls.

How to read these diagrams

Each function has its own expandable diagram using the actual expressions and statements from this file. Decisions split into True and False; loop bodies are indented. Returns end the function immediately; break and continue follow their C meanings. Arrows between statements show their order when execution reaches them.

These are structural source diagrams before preprocessing, not runtime traces. Conditional compilation and macros remain source expressions. Ternary choices remain inside their statement. Calls are linked separately; callback execution and task scheduling are not inferred.

Functions in this file

reduceTimer() · source line 146
static uint32_t reduceTimer(uint32_t timer)
  1. Exit this functionreturn timer > STEP_MS ? timer - STEP_MS : 0;
Read original function source
static uint32_t reduceTimer(uint32_t timer)
{
    return timer > STEP_MS ? timer - STEP_MS : 0;
}
overlaps() · source line 151
static bool overlaps(float ax, float ay, float aw, float ah,
                     float bx, float by, float bw, float bh)
  1. Exit this functionreturn ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
Read original function source
static bool overlaps(float ax, float ay, float aw, float ah,
                     float bx, float by, float bw, float bh)
{
    return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
addPoints() · source line 157
static void addPoints(InvaderType type)
  1. Executeconst uint32_t points = artwork[type].points;
  2. Executescore = score > UINT32_MAX - points ? UINT32_MAX : score + points;
  3. WHILE loopextra_lives_awarded < sizeof(extra_life_scores) / sizeof(extra_life_scores[0]) && score >= extra_life_scores[extra_lives_awarded]

    Repeat body while the loop condition permits; then continue below.

      1. Execute++lives;
      2. ExecuteESP_LOGI("invaders", "EXTRA LIFE: %u POINTS, %u LIVES", (unsigned)extra_life_scores[extra_lives_awarded], (unsigned)lives);
      3. Execute++extra_lives_awarded;
Read original function source
static void addPoints(InvaderType type)
{
    const uint32_t points = artwork[type].points;
    score = score > UINT32_MAX - points ? UINT32_MAX : score + points;
    while (extra_lives_awarded < sizeof(extra_life_scores) / sizeof(extra_life_scores[0]) &&
           score >= extra_life_scores[extra_lives_awarded]) {
        ++lives;
        ESP_LOGI("invaders", "EXTRA LIFE: %u POINTS, %u LIVES",
                 (unsigned)extra_life_scores[extra_lives_awarded], (unsigned)lives);
        ++extra_lives_awarded;
    }
}
startHitEffect() · source line 170
static void startHitEffect(int x, int y, InvaderType type)
  1. FOR loopunsigned i = 0; i < MAX_HIT_EFFECTS; ++i

    Repeat body while the loop condition permits; then continue below.

      1. DecisionhitEffects[i].remaining != 0
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      2. ExecutehitEffects[i] = (HitEffect){x, y, artwork[type].color, HIT_FRAME_MS * 2U};
      3. Exit this functionreturn;
  2. ExecuteESP_LOGW("invaders", "No free hit effect slot");
Read original function source
static void startHitEffect(int x, int y, InvaderType type)
{
    for (unsigned i = 0; i < MAX_HIT_EFFECTS; ++i) {
        if (hitEffects[i].remaining != 0) continue;
        hitEffects[i] = (HitEffect){x, y, artwork[type].color, HIT_FRAME_MS * 2U};
        return;
    }
    ESP_LOGW("invaders", "No free hit effect slot");
}
updateHitEffects() · source line 180
static void updateHitEffects(uint32_t elapsed)
  1. FOR loopunsigned i = 0; i < MAX_HIT_EFFECTS; ++i

    Repeat body while the loop condition permits; then continue below.

      1. ExecuteHitEffect *effect = &hitEffects[i];
      2. Executeeffect->remaining = effect->remaining > elapsed ? effect->remaining - elapsed : 0;
Read original function source
static void updateHitEffects(uint32_t elapsed)
{
    for (unsigned i = 0; i < MAX_HIT_EFFECTS; ++i) {
        HitEffect *effect = &hitEffects[i];
        effect->remaining = effect->remaining > elapsed ? effect->remaining - elapsed : 0;
    }
}
hitEffectsActive() · source line 188
static bool hitEffectsActive(void)
  1. FOR loopunsigned i = 0; i < MAX_HIT_EFFECTS; ++i

    Repeat body while the loop condition permits; then continue below.

      1. DecisionhitEffects[i].remaining != 0
        True
        1. Exit this functionreturn true;
        False

        Continue after this decision.

  2. Exit this functionreturn false;
Read original function source
static bool hitEffectsActive(void)
{
    for (unsigned i = 0; i < MAX_HIT_EFFECTS; ++i) {
        if (hitEffects[i].remaining != 0) return true;
    }
    return false;
}
scheduleMothership() · source line 196
static void scheduleMothership(void)
  1. ExecutemothershipActive = false;
  2. ExecutemothershipDelay = 8000U + esp_random() % 6001U;
  3. ExecutemothershipFrame = mothershipElapsed = 0;
Read original function source
static void scheduleMothership(void)
{
    mothershipActive = false;
    mothershipDelay = 8000U + esp_random() % 6001U;
    mothershipFrame = mothershipElapsed = 0;
}
beginWave() · source line 203
static void beginWave(void)

Calls into this documentation:

  1. Executememset(hitEffects, 0, sizeof(hitEffects));
  2. FOR loopunsigned row = 0; row < INVADER_ROWS; ++row

    Repeat body while the loop condition permits; then continue below.

      1. FOR loopunsigned column = 0; column < INVADER_COLUMNS; ++column

        Repeat body while the loop condition permits; then continue below.

          1. Executealive[row][column] = true;
  3. Executeremaining = INVADER_ROWS * INVADER_COLUMNS;
  4. ExecuteformationX = PLAY_LEFT + (PLAY_WIDTH - ((INVADER_COLUMNS - 1) * COLUMN_SPACING + INVADER_SIZE)) / 2;
  5. ExecuteformationY = FORMATION_TOP;
  6. ExecutemarchDirection = 1;
  7. ExecutemarchFrame = marchElapsed = 0;
  8. ExecuteplayerBullet.active = false;
  9. Executememset(enemyBullets, 0, sizeof(enemyBullets));
  10. ExecuteenemyFireDelay = 1000U;
  11. ExecutefireDelay = 0;
  12. ExecuteautoDodgeUntil = 0U;
  13. ExecuteautoDodgeDirection = 0;
  14. ExecuteautoIgnoredBullet = -1;
  15. ExecutescheduleMothership();
  16. ExecuteESP_LOGI("invaders", "WAVE %u", (unsigned)wave);
Read original function source
static void beginWave(void)
{
    memset(hitEffects, 0, sizeof(hitEffects));
    for (unsigned row = 0; row < INVADER_ROWS; ++row) {
        for (unsigned column = 0; column < INVADER_COLUMNS; ++column) {
            alive[row][column] = true;
        }
    }
    remaining = INVADER_ROWS * INVADER_COLUMNS;
    formationX = PLAY_LEFT + (PLAY_WIDTH - ((INVADER_COLUMNS - 1) * COLUMN_SPACING + INVADER_SIZE)) / 2;
    formationY = FORMATION_TOP;
    marchDirection = 1;
    marchFrame = marchElapsed = 0;
    playerBullet.active = false;
    memset(enemyBullets, 0, sizeof(enemyBullets));
    enemyFireDelay = 1000U;
    fireDelay = 0;
    autoDodgeUntil = 0U;
    autoDodgeDirection = 0;
    autoIgnoredBullet = -1;
    scheduleMothership();
    ESP_LOGI("invaders", "WAVE %u", (unsigned)wave);
}
beginGame() · source line 227
static void beginGame(uint32_t now_ms)

Calls into this documentation:

  1. Executescore = 0;
  2. Executewave = 1;
  3. Executelives = gameConfigInvadersStartingLives();
  4. Executeextra_lives_awarded = 0U;
  5. ExecutecannonX = PLAY_LEFT + (PLAY_WIDTH - CANNON_WIDTH) / 2.0f;
  6. Executeprotection = PROTECTION_MS;
  7. ExecutecontrolsReady = false;
  8. ExecutelastUpdate = now_ms;
  9. Executeaccumulator = 0;
  10. ExecutegameState = STATE_PLAYING;
  11. ExecutebeginWave();
  12. ExecuteaudioPlay(AUDIO_SFX_GAME_START);
Read original function source
static void beginGame(uint32_t now_ms)
{
    score = 0;
    wave = 1;
    lives = gameConfigInvadersStartingLives();
    extra_lives_awarded = 0U;
    cannonX = PLAY_LEFT + (PLAY_WIDTH - CANNON_WIDTH) / 2.0f;
    protection = PROTECTION_MS;
    controlsReady = false;
    lastUpdate = now_ms;
    accumulator = 0;
    gameState = STATE_PLAYING;
    beginWave();
    audioPlay(AUDIO_SFX_GAME_START);
}
finishGame() · source line 243
static void finishGame(void)

Calls into this documentation:

  1. DecisiongameState != STATE_PLAYING
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  2. ExecutegameState = STATE_FINISHED;
  3. ExecutegameOverStarted = lastUpdate;
  4. ExecuteaudioPlay(AUDIO_SFX_GAME_OVER);
  5. ExecuteESP_LOGI("invaders", "GAME OVER: SCORE %u, WAVE %u", (unsigned)score, (unsigned)wave);
  6. Executeconst uint32_t gold = score / 1000U;
  7. Decisiongold != 0
    True
      1. Decision(autoMode ? addAIGold(gold) : addUserGoldWon(gold))
        True
          1. ExecuteESP_LOGI("invaders", "GOLD WON: %u", (unsigned)gold);
        False
          1. ExecuteESP_LOGE("invaders", "Failed to save %u Gold", (unsigned)gold);
    False

    Continue after this decision.

Read original function source
static void finishGame(void)
{
    if (gameState != STATE_PLAYING) return;
    gameState = STATE_FINISHED;
    gameOverStarted = lastUpdate;
    audioPlay(AUDIO_SFX_GAME_OVER);
    ESP_LOGI("invaders", "GAME OVER: SCORE %u, WAVE %u", (unsigned)score, (unsigned)wave);
    // Award whole thousands once, when the game ends.
    const uint32_t gold = score / 1000U;
    if (gold != 0) {
        if ((autoMode ? addAIGold(gold) : addUserGoldWon(gold))) {
            ESP_LOGI("invaders", "GOLD WON: %u", (unsigned)gold);
        } else {
            ESP_LOGE("invaders", "Failed to save %u Gold", (unsigned)gold);
        }
    }
}
autoDodgeMovement() · source line 261
static int autoDodgeMovement(uint32_t now_ms)
  1. DecisionautoDodgeDirection != 0 && (int32_t)(now_ms - autoDodgeUntil) < 0
    True
      1. Exit this functionreturn autoDodgeDirection;
    False

    Continue after this decision.

  2. ExecuteautoDodgeDirection = 0;
  3. DecisionautoIgnoredBullet >= 0
    True
      1. Executeconst Bullet *ignored = &enemyBullets[autoIgnoredBullet];
      2. Decision!ignored->active || ignored->y < CANNON_Y - AUTO_DODGE_TRIGGER_Y || ignored->y > CANNON_Y + CANNON_HEIGHT
        True
          1. ExecuteautoIgnoredBullet = -1;
        False

        Continue after this decision.

    False

    Continue after this decision.

  4. Executeconst int cannonCentre = (int)cannonX + CANNON_WIDTH / 2;
  5. FOR loopunsigned i = 0; i < MAX_ENEMY_BULLETS; ++i

    Repeat body while the loop condition permits; then continue below.

      1. Executeconst Bullet *bullet = &enemyBullets[i];
      2. Decision!bullet->active || bullet->y < CANNON_Y - AUTO_DODGE_TRIGGER_Y || bullet->y > CANNON_Y + CANNON_HEIGHT
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      3. Decision(int)i == autoIgnoredBullet || bullet->x < cannonX - 8.0f || bullet->x > cannonX + CANNON_WIDTH + 8.0f
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      4. Decisionesp_random() % 100U >= AUTO_DODGE_CHANCE_PERCENT
        True
          1. ExecuteautoIgnoredBullet = (int)i;
          2. Exit this functionreturn 0;
        False

        Continue after this decision.

      5. ExecuteautoDodgeDirection = bullet->x < cannonCentre ? 1 : -1;
      6. DecisionautoDodgeDirection < 0 && cannonX <= PLAY_LEFT + 8
        True
        1. ExecuteautoDodgeDirection = 1;
        False

        Continue after this decision.

      7. DecisionautoDodgeDirection > 0 && cannonX >= PLAY_RIGHT - CANNON_WIDTH - 8
        True
        1. ExecuteautoDodgeDirection = -1;
        False

        Continue after this decision.

      8. ExecuteautoDodgeUntil = now_ms + AUTO_DODGE_MS;
      9. Exit this functionreturn autoDodgeDirection;
  6. Exit this functionreturn 0;
Read original function source
static int autoDodgeMovement(uint32_t now_ms)
{
    if (autoDodgeDirection != 0 && (int32_t)(now_ms - autoDodgeUntil) < 0) {
        return autoDodgeDirection;
    }
    autoDodgeDirection = 0;

    if (autoIgnoredBullet >= 0) {
        const Bullet *ignored = &enemyBullets[autoIgnoredBullet];
        if (!ignored->active || ignored->y < CANNON_Y - AUTO_DODGE_TRIGGER_Y ||
            ignored->y > CANNON_Y + CANNON_HEIGHT) {
            autoIgnoredBullet = -1;
        }
    }

    const int cannonCentre = (int)cannonX + CANNON_WIDTH / 2;
    for (unsigned i = 0; i < MAX_ENEMY_BULLETS; ++i) {
        const Bullet *bullet = &enemyBullets[i];
        if (!bullet->active || bullet->y < CANNON_Y - AUTO_DODGE_TRIGGER_Y ||
            bullet->y > CANNON_Y + CANNON_HEIGHT) continue;
        if ((int)i == autoIgnoredBullet ||
            bullet->x < cannonX - 8.0f || bullet->x > cannonX + CANNON_WIDTH + 8.0f) continue;
        if (esp_random() % 100U >= AUTO_DODGE_CHANCE_PERCENT) {
            autoIgnoredBullet = (int)i;
            return 0;
        }

        autoDodgeDirection = bullet->x < cannonCentre ? 1 : -1;
        if (autoDodgeDirection < 0 && cannonX <= PLAY_LEFT + 8) autoDodgeDirection = 1;
        if (autoDodgeDirection > 0 && cannonX >= PLAY_RIGHT - CANNON_WIDTH - 8) autoDodgeDirection = -1;
        autoDodgeUntil = now_ms + AUTO_DODGE_MS;
        return autoDodgeDirection;
    }
    return 0;
}
autoMovement() · source line 297
static int autoMovement(uint32_t now_ms)

Calls into this documentation:

  1. Executeconst int dodgeMovement = autoDodgeMovement(now_ms);
  2. DecisiondodgeMovement != 0
    True
    1. Exit this functionreturn dodgeMovement;
    False

    Continue after this decision.

  3. Executeconst int cannonCentre = (int)cannonX + CANNON_WIDTH / 2;
  4. Executeint targetX = cannonCentre;
  5. Executeint bestDistance = PLAY_RIGHT - PLAY_LEFT;
  6. FOR loopint column = 0; column < INVADER_COLUMNS; ++column

    Repeat body while the loop condition permits; then continue below.

      1. Executeint lowestRow = -1;
      2. FOR loopint row = INVADER_ROWS - 1; row >= 0; --row

        Repeat body while the loop condition permits; then continue below.

          1. Decisionalive[row][column]
            True
              1. ExecutelowestRow = row;
              2. Exit enclosing loop or switchbreak;
            False

            Continue after this decision.

      3. DecisionlowestRow < 0
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      4. Executeconst int invaderX = formationX + column * COLUMN_SPACING + INVADER_SIZE / 2;
      5. Executeconst int distance = invaderX > cannonCentre ? invaderX - cannonCentre : cannonCentre - invaderX;
      6. Decisiondistance < bestDistance
        True
          1. ExecutebestDistance = distance;
          2. ExecutetargetX = invaderX;
        False

        Continue after this decision.

  7. Exit this functionreturn targetX > cannonCentre + 2 ? 1 : targetX < cannonCentre - 2 ? -1 : 0;
Read original function source
static int autoMovement(uint32_t now_ms)
{
    const int dodgeMovement = autoDodgeMovement(now_ms);
    if (dodgeMovement != 0) return dodgeMovement;

    const int cannonCentre = (int)cannonX + CANNON_WIDTH / 2;
    int targetX = cannonCentre;
    int bestDistance = PLAY_RIGHT - PLAY_LEFT;
    for (int column = 0; column < INVADER_COLUMNS; ++column) {
        int lowestRow = -1;
        for (int row = INVADER_ROWS - 1; row >= 0; --row) {
            if (alive[row][column]) {
                lowestRow = row;
                break;
            }
        }
        if (lowestRow < 0) continue;
        const int invaderX = formationX + column * COLUMN_SPACING + INVADER_SIZE / 2;
        const int distance = invaderX > cannonCentre ? invaderX - cannonCentre : cannonCentre - invaderX;
        if (distance < bestDistance) {
            bestDistance = distance;
            targetX = invaderX;
        }
    }

    return targetX > cannonCentre + 2 ? 1 : targetX < cannonCentre - 2 ? -1 : 0;
}
marchFormation() · source line 325
static void marchFormation(void)

Calls into this documentation:

  1. Executeint left = PLAY_RIGHT, right = PLAY_LEFT;
  2. FOR loopint row = 0; row < INVADER_ROWS; ++row

    Repeat body while the loop condition permits; then continue below.

      1. FOR loopint column = 0; column < INVADER_COLUMNS; ++column

        Repeat body while the loop condition permits; then continue below.

          1. Decision!alive[row][column]
            True
            1. Next loop iterationcontinue;
            False

            Continue after this decision.

          2. Executeconst int x = formationX + column * COLUMN_SPACING;
          3. Decisionx < left
            True
            1. Executeleft = x;
            False

            Continue after this decision.

          4. Decisionx + INVADER_SIZE > right
            True
            1. Executeright = x + INVADER_SIZE;
            False

            Continue after this decision.

  3. Decisionleft + marchDirection * 4 < PLAY_LEFT + 3 || right + marchDirection * 4 > PLAY_RIGHT - 3
    True
      1. ExecuteformationY += 4;
      2. ExecutemarchDirection = -marchDirection;
    False
      1. ExecuteformationX += marchDirection * 4;
  4. ExecutemarchFrame ^= 1U;
  5. FOR loopint row = 0; row < INVADER_ROWS; ++row

    Repeat body while the loop condition permits; then continue below.

      1. DecisionformationY + row * ROW_SPACING + INVADER_SIZE < CANNON_Y
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      2. FOR loopint column = 0; column < INVADER_COLUMNS; ++column

        Repeat body while the loop condition permits; then continue below.

          1. Decisionalive[row][column]
            True
              1. ExecutefinishGame();
              2. Exit this functionreturn;
            False

            Continue after this decision.

Read original function source
static void marchFormation(void)
{
    int left = PLAY_RIGHT, right = PLAY_LEFT;
    for (int row = 0; row < INVADER_ROWS; ++row) {
        for (int column = 0; column < INVADER_COLUMNS; ++column) {
            if (!alive[row][column]) continue;
            const int x = formationX + column * COLUMN_SPACING;
            if (x < left) left = x;
            if (x + INVADER_SIZE > right) right = x + INVADER_SIZE;
        }
    }
    if (left + marchDirection * 4 < PLAY_LEFT + 3 ||
        right + marchDirection * 4 > PLAY_RIGHT - 3) {
        formationY += 4;
        marchDirection = -marchDirection;
    } else {
        formationX += marchDirection * 4;
    }
    // The whole formation changes pose once per marching step, including drops.
    marchFrame ^= 1U;
    for (int row = 0; row < INVADER_ROWS; ++row) {
        if (formationY + row * ROW_SPACING + INVADER_SIZE < CANNON_Y) continue;
        for (int column = 0; column < INVADER_COLUMNS; ++column) {
            if (alive[row][column]) {
                finishGame();
                return;
            }
        }
    }
}
fireEnemyBullet() · source line 356
static void fireEnemyBullet(void)

Calls into this documentation:

  1. FOR loopunsigned slot = 0; slot < MAX_ENEMY_BULLETS; ++slot

    Repeat body while the loop condition permits; then continue below.

      1. DecisionenemyBullets[slot].active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      2. Executeconst unsigned first = esp_random() % INVADER_COLUMNS;
      3. FOR loopunsigned offset = 0; offset < INVADER_COLUMNS; ++offset

        Repeat body while the loop condition permits; then continue below.

          1. Executeconst unsigned column = (first + offset) % INVADER_COLUMNS;
          2. FOR loopint row = INVADER_ROWS - 1; row >= 0; --row

            Repeat body while the loop condition permits; then continue below.

              1. Decision!alive[row][column]
                True
                1. Next loop iterationcontinue;
                False

                Continue after this decision.

              2. ExecuteenemyBullets[slot] = (Bullet){ .x = formationX + (int)column * COLUMN_SPACING + INVADER_SIZE / 2 - 1, .y = formationY + row * ROW_SPACING + INVADER_SIZE, .active = true, };
              3. ExecuteaudioPlay(AUDIO_SFX_ENEMY_SHOT);
              4. Exit this functionreturn;
      4. Exit this functionreturn;
Read original function source
static void fireEnemyBullet(void)
{
    for (unsigned slot = 0; slot < MAX_ENEMY_BULLETS; ++slot) {
        if (enemyBullets[slot].active) continue;
        const unsigned first = esp_random() % INVADER_COLUMNS;
        for (unsigned offset = 0; offset < INVADER_COLUMNS; ++offset) {
            const unsigned column = (first + offset) % INVADER_COLUMNS;
            // Only the lowest surviving invader in a column fires.
            for (int row = INVADER_ROWS - 1; row >= 0; --row) {
                if (!alive[row][column]) continue;
                enemyBullets[slot] = (Bullet){
                    .x = formationX + (int)column * COLUMN_SPACING + INVADER_SIZE / 2 - 1,
                    .y = formationY + row * ROW_SPACING + INVADER_SIZE,
                    .active = true,
                };
                audioPlay(AUDIO_SFX_ENEMY_SHOT);
                return;
            }
        }
        return;
    }
}
updateMothership() · source line 379
static void updateMothership(void)

Calls into this documentation:

  1. Decision!mothershipActive
    True
      1. ExecutemothershipDelay = reduceTimer(mothershipDelay);
      2. DecisionmothershipDelay != 0
        True
        1. Exit this functionreturn;
        False

        Continue after this decision.

      3. ExecutemothershipDirection = (esp_random() & 1U) != 0 ? 1 : -1;
      4. ExecutemothershipX = mothershipDirection > 0 ? PLAY_LEFT - INVADER_SIZE : PLAY_RIGHT;
      5. ExecutemothershipActive = true;
      6. ExecutemothershipFrame = mothershipElapsed = 0;
    False

    Continue after this decision.

  2. ExecutemothershipX += mothershipDirection * 28.0f * STEP_MS / 1000.0f;
  3. ExecutemothershipElapsed += STEP_MS;
  4. DecisionmothershipElapsed >= 200U
    True
      1. ExecutemothershipElapsed -= 200U;
      2. ExecutemothershipFrame ^= 1U;
    False

    Continue after this decision.

  5. Decision(mothershipDirection > 0 && mothershipX >= PLAY_RIGHT) || (mothershipDirection < 0 && mothershipX + INVADER_SIZE <= PLAY_LEFT)
    True
      1. ExecutescheduleMothership();
    False

    Continue after this decision.

Read original function source
static void updateMothership(void)
{
    if (!mothershipActive) {
        mothershipDelay = reduceTimer(mothershipDelay);
        if (mothershipDelay != 0) return;
        mothershipDirection = (esp_random() & 1U) != 0 ? 1 : -1;
        mothershipX = mothershipDirection > 0 ? PLAY_LEFT - INVADER_SIZE : PLAY_RIGHT;
        mothershipActive = true;
        mothershipFrame = mothershipElapsed = 0;
    }
    mothershipX += mothershipDirection * 28.0f * STEP_MS / 1000.0f;
    mothershipElapsed += STEP_MS;
    if (mothershipElapsed >= 200U) {
        mothershipElapsed -= 200U;
        mothershipFrame ^= 1U;
    }
    if ((mothershipDirection > 0 && mothershipX >= PLAY_RIGHT) ||
        (mothershipDirection < 0 && mothershipX + INVADER_SIZE <= PLAY_LEFT)) {
        scheduleMothership();
    }
}
updatePlayerBullet() · source line 401
static void updatePlayerBullet(void)

Calls into this documentation:

  1. Decision!playerBullet.active
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  2. ExecuteplayerBullet.y -= 220.0f * STEP_MS / 1000.0f;
  3. FOR loopint row = INVADER_ROWS - 1; row >= 0; --row

    Repeat body while the loop condition permits; then continue below.

      1. FOR loopint column = 0; column < INVADER_COLUMNS; ++column

        Repeat body while the loop condition permits; then continue below.

          1. Decision!alive[row][column]
            True
            1. Next loop iterationcontinue;
            False

            Continue after this decision.

          2. Decisionoverlaps(playerBullet.x, playerBullet.y, 2, 5, formationX + column * COLUMN_SPACING, formationY + row * ROW_SPACING, INVADER_SIZE, INVADER_SIZE)
            True
              1. Executealive[row][column] = false;
              2. Execute--remaining;
              3. ExecuteplayerBullet.active = false;
              4. ExecuteaddPoints(rowTypes[row]);
              5. ExecutestartHitEffect(formationX + column * COLUMN_SPACING, formationY + row * ROW_SPACING, rowTypes[row]);
              6. ExecuteaudioPlay(AUDIO_SFX_HIT);
              7. Decisionremaining == 0U
                True
                1. ExecuteaudioPlay(AUDIO_SFX_WAVE_CLEAR);
                False

                Continue after this decision.

              8. Exit this functionreturn;
            False

            Continue after this decision.

  4. DecisionmothershipActive && overlaps(playerBullet.x, playerBullet.y, 2, 5, mothershipX, MOTHERSHIP_Y, INVADER_SIZE, INVADER_SIZE)
    True
      1. ExecuteplayerBullet.active = false;
      2. ExecuteaddPoints(TYPE_4);
      3. ExecutestartHitEffect((int)mothershipX, MOTHERSHIP_Y, TYPE_4);
      4. ExecutescheduleMothership();
      5. ExecuteaudioPlay(AUDIO_SFX_HIT);
      6. ExecuteESP_LOGI("invaders", "MOTHERSHIP HIT: 50 POINTS");
    False
    1. DecisionplayerBullet.y + 5 <= PLAY_TOP
      True
        1. ExecuteplayerBullet.active = false;
      False

      Continue after this decision.

Read original function source
static void updatePlayerBullet(void)
{
    if (!playerBullet.active) return;
    playerBullet.y -= 220.0f * STEP_MS / 1000.0f;
    // Rectangular sprite bounds are used for hits, independent of artwork bits.
    // Check bottom rows first so a shot hits the nearest invader in its path.
    for (int row = INVADER_ROWS - 1; row >= 0; --row) {
        for (int column = 0; column < INVADER_COLUMNS; ++column) {
            if (!alive[row][column]) continue;
            if (overlaps(playerBullet.x, playerBullet.y, 2, 5,
                         formationX + column * COLUMN_SPACING,
                         formationY + row * ROW_SPACING, INVADER_SIZE, INVADER_SIZE)) {
                alive[row][column] = false;
                --remaining;
                playerBullet.active = false;
                addPoints(rowTypes[row]);
                startHitEffect(formationX + column * COLUMN_SPACING,
                               formationY + row * ROW_SPACING, rowTypes[row]);
                audioPlay(AUDIO_SFX_HIT);
                if (remaining == 0U) audioPlay(AUDIO_SFX_WAVE_CLEAR);
                return;
            }
        }
    }
    if (mothershipActive && overlaps(playerBullet.x, playerBullet.y, 2, 5,
                                    mothershipX, MOTHERSHIP_Y, INVADER_SIZE, INVADER_SIZE)) {
        playerBullet.active = false;
        addPoints(TYPE_4);
        startHitEffect((int)mothershipX, MOTHERSHIP_Y, TYPE_4);
        scheduleMothership();
        audioPlay(AUDIO_SFX_HIT);
        ESP_LOGI("invaders", "MOTHERSHIP HIT: 50 POINTS");
    } else if (playerBullet.y + 5 <= PLAY_TOP) {
        playerBullet.active = false;
    }
}
simulateStep() · source line 438
static void simulateStep(int movement, bool fire)

Calls into this documentation:

  1. Decisionremaining == 0
    True
      1. Decision!hitEffectsActive()
        True
          1. Decisionwave < UINT32_MAX
            True
            1. Execute++wave;
            False

            Continue after this decision.

          2. ExecutebeginWave();
        False

        Continue after this decision.

      2. Exit this functionreturn;
    False

    Continue after this decision.

  2. Executeprotection = reduceTimer(protection);
  3. ExecutefireDelay = reduceTimer(fireDelay);
  4. ExecutecannonX += movement * 125.0f * STEP_MS / 1000.0f;
  5. DecisioncannonX < PLAY_LEFT + 1
    True
    1. ExecutecannonX = PLAY_LEFT + 1;
    False

    Continue after this decision.

  6. DecisioncannonX > PLAY_RIGHT - CANNON_WIDTH - 1
    True
    1. ExecutecannonX = PLAY_RIGHT - CANNON_WIDTH - 1;
    False

    Continue after this decision.

  7. Decisionfire && fireDelay == 0 && !playerBullet.active
    True
      1. ExecuteplayerBullet = (Bullet){cannonX + CANNON_WIDTH / 2 - 1, CANNON_Y - 5, true};
      2. ExecutefireDelay = FIRE_DELAY_MS;
      3. ExecuteaudioPlay(AUDIO_SFX_PLAYER_SHOT);
    False

    Continue after this decision.

  8. ExecuteupdateMothership();
  9. ExecuteupdatePlayerBullet();
  10. Decisionremaining == 0
    True
      1. Executememset(enemyBullets, 0, sizeof(enemyBullets));
      2. Exit this functionreturn;
    False

    Continue after this decision.

  11. Executeconst uint32_t difficulty = wave < 9U ? wave - 1U : 8U;
  12. Executeconst uint32_t base_interval = 120U + remaining * 30U;
  13. Executeconst uint32_t normal_march_interval = base_interval > 80U + difficulty * 20U ? base_interval - difficulty * 20U : 80U;
  14. Executeconst uint32_t march_interval = remaining == 1U ? 40U : normal_march_interval;
  15. ExecutemarchElapsed += STEP_MS;
  16. DecisionmarchElapsed >= march_interval
    True
      1. ExecutemarchElapsed -= march_interval;
      2. ExecutemarchFormation();
      3. DecisiongameState != STATE_PLAYING
        True
        1. Exit this functionreturn;
        False

        Continue after this decision.

    False

    Continue after this decision.

  17. ExecuteenemyFireDelay = reduceTimer(enemyFireDelay);
  18. DecisionenemyFireDelay == 0
    True
      1. ExecutefireEnemyBullet();
      2. ExecuteenemyFireDelay = 900U - difficulty * 50U + esp_random() % 301U;
    False

    Continue after this decision.

  19. FOR loopunsigned i = 0; i < MAX_ENEMY_BULLETS; ++i

    Repeat body while the loop condition permits; then continue below.

      1. ExecuteBullet *bullet = &enemyBullets[i];
      2. Decision!bullet->active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      3. Executebullet->y += (60.0f + difficulty * 4.0f) * STEP_MS / 1000.0f;
      4. Decisionbullet->y >= PLAY_BOTTOM
        True
          1. Executebullet->active = false;
        False
        1. Decisionoverlaps(bullet->x, bullet->y, 2, 5, cannonX, CANNON_Y, CANNON_WIDTH, CANNON_HEIGHT)
          True
            1. Executebullet->active = false;
            2. Decisionprotection != 0
              True
              1. Next loop iterationcontinue;
              False

              Continue after this decision.

            3. Execute--lives;
            4. ExecuteESP_LOGI("invaders", "PLAYER HIT: %u LIVES", (unsigned)lives);
            5. ExecuteaudioPlay(AUDIO_SFX_PLAYER_EXPLOSION);
            6. Decisionlives == 0
              True
                1. ExecutefinishGame();
                2. Exit this functionreturn;
              False

              Continue after this decision.

            7. ExecutecannonX = PLAY_LEFT + (PLAY_WIDTH - CANNON_WIDTH) / 2.0f;
            8. Executeprotection = PROTECTION_MS;
            9. ExecutecontrolsReady = false;
            10. ExecuteplayerBullet.active = false;
            11. Executememset(enemyBullets, 0, sizeof(enemyBullets));
            12. Exit this functionreturn;
          False

          Continue after this decision.

Read original function source
static void simulateStep(int movement, bool fire)
{
    // Pause the cleared wave until every explosion has played both frames.
    if (remaining == 0) {
        if (!hitEffectsActive()) {
            if (wave < UINT32_MAX) ++wave;
            beginWave();
        }
        return;
    }
    protection = reduceTimer(protection);
    fireDelay = reduceTimer(fireDelay);
    cannonX += movement * 125.0f * STEP_MS / 1000.0f;
    if (cannonX < PLAY_LEFT + 1) cannonX = PLAY_LEFT + 1;
    if (cannonX > PLAY_RIGHT - CANNON_WIDTH - 1) cannonX = PLAY_RIGHT - CANNON_WIDTH - 1;
    if (fire && fireDelay == 0 && !playerBullet.active) {
        playerBullet = (Bullet){cannonX + CANNON_WIDTH / 2 - 1, CANNON_Y - 5, true};
        fireDelay = FIRE_DELAY_MS;
        audioPlay(AUDIO_SFX_PLAYER_SHOT);
    }

    updateMothership();
    updatePlayerBullet();
    if (remaining == 0) {
        memset(enemyBullets, 0, sizeof(enemyBullets));
        return;
    }

    // Wave difficulty affects marching, enemy firing cadence, and bullet speed.
    const uint32_t difficulty = wave < 9U ? wave - 1U : 8U;
    // Each destroyed invader removes 30 ms from the march interval, so the
    // formation marches faster as the wave is cleared.
    const uint32_t base_interval = 120U + remaining * 30U;
    const uint32_t normal_march_interval = base_interval > 80U + difficulty * 20U
        ? base_interval - difficulty * 20U : 80U;
    // The final survivor gets a deliberate Crazy speed, independent of wave.
    const uint32_t march_interval = remaining == 1U ? 40U : normal_march_interval;
    marchElapsed += STEP_MS;
    if (marchElapsed >= march_interval) {
        marchElapsed -= march_interval;
        marchFormation();
        if (gameState != STATE_PLAYING) return;
    }

    enemyFireDelay = reduceTimer(enemyFireDelay);
    if (enemyFireDelay == 0) {
        fireEnemyBullet();
        // Higher waves shorten the delay between enemy shots; the random term
        // keeps the firing pattern from becoming perfectly regular.
        enemyFireDelay = 900U - difficulty * 50U + esp_random() % 301U;
    }
    for (unsigned i = 0; i < MAX_ENEMY_BULLETS; ++i) {
        Bullet *bullet = &enemyBullets[i];
        if (!bullet->active) continue;
        // Enemy bullet travel speed increases by wave, not by each shot.
        bullet->y += (60.0f + difficulty * 4.0f) * STEP_MS / 1000.0f;
        if (bullet->y >= PLAY_BOTTOM) {
            bullet->active = false;
        } else if (overlaps(bullet->x, bullet->y, 2, 5,
                            cannonX, CANNON_Y, CANNON_WIDTH, CANNON_HEIGHT)) {
            bullet->active = false;
            if (protection != 0) continue;
            --lives;
            ESP_LOGI("invaders", "PLAYER HIT: %u LIVES", (unsigned)lives);
            audioPlay(AUDIO_SFX_PLAYER_EXPLOSION);
            if (lives == 0) {
                finishGame();
                return;
            }
            cannonX = PLAY_LEFT + (PLAY_WIDTH - CANNON_WIDTH) / 2.0f;
            protection = PROTECTION_MS;
            controlsReady = false;
            playerBullet.active = false;
            memset(enemyBullets, 0, sizeof(enemyBullets));
            return;
        }
    }
}
startInvaders() · source line 517
void startInvaders(uint32_t now_ms)

Calls into this documentation:

  1. Executestarfield_initialize(&stars, 45, PLAY_LEFT, PLAY_TOP, PLAY_WIDTH, PLAY_BOTTOM - PLAY_TOP, STARFIELD_GREYSCALE);
  2. Executerunning = true;
  3. ExecutegameState = STATE_READY;
  4. ExecutelastUpdate = now_ms;
  5. Executeaccumulator = score = 0;
  6. Executewave = 1;
  7. Executelives = 3;
  8. Executeextra_lives_awarded = 0U;
  9. Executememset(hitEffects, 0, sizeof(hitEffects));
  10. ExecutecontrolsReady = false;
  11. ExecuteautoMode = false;
  12. ExecutemodeSelectionReset(&modeInput);
  13. ExecuteESP_LOGI("invaders", "START");
Read original function source
void startInvaders(uint32_t now_ms)
{
    starfield_initialize(&stars, 45, PLAY_LEFT, PLAY_TOP, PLAY_WIDTH,
                         PLAY_BOTTOM - PLAY_TOP, STARFIELD_GREYSCALE);
    running = true;
    gameState = STATE_READY;
    lastUpdate = now_ms;
    accumulator = score = 0;
    wave = 1;
    lives = 3;
    extra_lives_awarded = 0U;
    memset(hitEffects, 0, sizeof(hitEffects));
    controlsReady = false;
    autoMode = false;
    modeSelectionReset(&modeInput);
    ESP_LOGI("invaders", "START");
}
stopInvaders() · source line 535
void stopInvaders(void)

Calls into this documentation:

  1. ExecutecancelHighScoreEntry();
  2. Executerunning = false;
  3. Executememset(hitEffects, 0, sizeof(hitEffects));
  4. ExecuteESP_LOGI("invaders", "STOP");
Read original function source
void stopInvaders(void)
{
    cancelHighScoreEntry();
    running = false;
    memset(hitEffects, 0, sizeof(hitEffects));
    ESP_LOGI("invaders", "STOP");
}
invadersAction() · source line 543
void invadersAction(bool start, bool stop_auto, uint32_t now_ms)

Calls into this documentation:

  1. Decisionrunning && start && gameState == STATE_READY
    True
      1. ExecuteESP_LOGI("invaders", "START: %s", autoMode ? "AUTO" : "PLAY");
      2. ExecutebeginGame(now_ms);
    False
    1. Decisionrunning && stop_auto && autoMode && gameState == STATE_PLAYING
      True
        1. ExecuteautoMode = false;
        2. ExecutemodeSelectionReset(&modeInput);
        3. ExecutegameState = STATE_READY;
      False

      Continue after this decision.

Read original function source
void invadersAction(bool start, bool stop_auto, uint32_t now_ms)
{
    if (running && start && gameState == STATE_READY) {
        ESP_LOGI("invaders", "START: %s", autoMode ? "AUTO" : "PLAY");
        beginGame(now_ms);
    } else if (running && stop_auto && autoMode && gameState == STATE_PLAYING) {
        autoMode = false;
        modeSelectionReset(&modeInput);
        gameState = STATE_READY;
    }
}
updateInvaders() · source line 555
void updateInvaders(int x_offset, int y_offset, bool valid, bool left, bool right,
                     bool fire, uint32_t now_ms)

Calls into this documentation:

  1. Decision!running
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  2. Executeconst uint32_t elapsed = now_ms - lastUpdate;
  3. ExecutelastUpdate = now_ms;
  4. ExecuteupdateHitEffects(elapsed);
  5. Executestarfield_update(&stars, elapsed);
  6. DecisiongameState == STATE_FINISHED && now_ms - gameOverStarted >= 1000U
    True
      1. DecisionautoMode
        True
        1. ExecutebeginGame(now_ms);
        False
          1. ExecutestartHighScoreEntry(HIGH_SCORE_INVADERS, score, now_ms);
          2. ExecutegameState = STATE_SCORES;
    False

    Continue after this decision.

  7. DecisiongameState == STATE_SCORES
    True
      1. DecisionupdateHighScoreEntry(x_offset, y_offset, valid, now_ms)
        True
        1. ExecutegameState = STATE_READY;
        False

        Continue after this decision.

      2. Exit this functionreturn;
    False

    Continue after this decision.

  8. DecisiongameState == STATE_READY
    True
      1. DecisionmodeSelectionNext(&modeInput, x_offset, y_offset, valid, left, right, now_ms)
        True
          1. ExecuteautoMode = !autoMode;
        False

        Continue after this decision.

      2. Exit this functionreturn;
    False

    Continue after this decision.

  9. DecisiongameState != STATE_PLAYING
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  10. Decision!valid
    True
    1. ExecutecontrolsReady = false;
    False
    1. Decisionx_offset >= -350 && x_offset <= 350 && y_offset >= -350 && y_offset <= 350
      True
      1. ExecutecontrolsReady = true;
      False

      Continue after this decision.

  11. Executeconst int movement = left || right ? (int)right - (int)left : valid && controlsReady ? (x_offset > 600 ? 1 : x_offset < -600 ? -1 : 0) : 0;
  12. Executeconst int automatedMovement = autoMode ? autoMovement(now_ms) : 0;
  13. Executeaccumulator += elapsed > 100U ? 100U : elapsed;
  14. WHILE loopaccumulator >= STEP_MS && gameState == STATE_PLAYING

    Repeat body while the loop condition permits; then continue below.

      1. Executeaccumulator -= STEP_MS;
      2. ExecutesimulateStep(autoMode ? automatedMovement : (left || right || controlsReady) ? movement : 0, autoMode || fire);
Read original function source
void updateInvaders(int x_offset, int y_offset, bool valid, bool left, bool right,
                     bool fire, uint32_t now_ms)
{
    if (!running) return;
    const uint32_t elapsed = now_ms - lastUpdate;
    lastUpdate = now_ms;
    // Effects also expire on the game-over screen. Update before new hits are
    // created so a new explosion gets its full first frame on the next draw.
    updateHitEffects(elapsed);
    starfield_update(&stars, elapsed);
    if (gameState == STATE_FINISHED && now_ms - gameOverStarted >= 1000U) {
        if (autoMode) beginGame(now_ms);
        else {
            startHighScoreEntry(HIGH_SCORE_INVADERS, score, now_ms);
            gameState = STATE_SCORES;
        }
    }
    if (gameState == STATE_SCORES) {
        if (updateHighScoreEntry(x_offset, y_offset, valid, now_ms)) gameState = STATE_READY;
        return;
    }
    if (gameState == STATE_READY) {
        if (modeSelectionNext(&modeInput, x_offset, y_offset, valid, left, right, now_ms)) {
            autoMode = !autoMode;
        }
        return;
    }
    if (gameState != STATE_PLAYING) return;
    if (!valid) controlsReady = false;
    else if (x_offset >= -350 && x_offset <= 350 && y_offset >= -350 && y_offset <= 350)
        controlsReady = true;
    const int movement = left || right ? (int)right - (int)left
        : valid && controlsReady ? (x_offset > 600 ? 1 : x_offset < -600 ? -1 : 0) : 0;
    const int automatedMovement = autoMode ? autoMovement(now_ms) : 0;
    // Bound catch-up after a stall; collisions are checked every 10 ms.
    accumulator += elapsed > 100U ? 100U : elapsed;
    while (accumulator >= STEP_MS && gameState == STATE_PLAYING) {
        accumulator -= STEP_MS;
        simulateStep(autoMode ? automatedMovement : (left || right || controlsReady) ? movement : 0,
                     autoMode || fire);
    }
}
drawInvader() · source line 598
static void drawInvader(uint16_t *frame, InvaderType type, unsigned pose, int x, int y)

Calls into this documentation:

  1. Executedraw_bitmap_scaled(frame, x, y, artwork[type].frames[pose], 8, 8, INVADER_SIZE, INVADER_SIZE, artwork[type].color);
Read original function source
static void drawInvader(uint16_t *frame, InvaderType type, unsigned pose, int x, int y)
{
    draw_bitmap_scaled(frame, x, y, artwork[type].frames[pose], 8, 8,
                   INVADER_SIZE, INVADER_SIZE, artwork[type].color);
}
drawInvadersLayout() · source line 604
static void drawInvadersLayout(uint16_t *frame)

Calls into this documentation:

  1. Executeconst int text_x = PLAY_RIGHT + 7;
  2. Executeconst uint16_t border = 0x7bef;
  3. Executeconst uint16_t cyan = RGB565(50, 205, 255);
  4. Executechar value[16];
  5. Executefill_rect(frame, PLAY_RIGHT, 0, LCD_WIDTH - PLAY_RIGHT, LCD_HEIGHT, 0);
  6. Executedraw_rect(frame, 0, 0, LCD_WIDTH, LCD_HEIGHT, border);
  7. Executefill_rect(frame, PLAY_RIGHT, 0, 1, LCD_HEIGHT, border);
  8. Executedraw_text(frame, text_x, 12, "SCORE", 1, cyan);
  9. Executesnprintf(value, sizeof(value), "%u", (unsigned)score);
  10. Executedraw_text(frame, text_x, 28, value, 1, 0xffff);
  11. Executedraw_text(frame, text_x, 61, "LIVES", 1, cyan);
  12. Executesnprintf(value, sizeof(value), "%u", (unsigned)lives);
  13. Executedraw_text(frame, text_x, 77, value, 2, RGB565(70, 245, 130));
  14. Executedraw_text(frame, text_x, 112, "WAVE", 1, cyan);
  15. Executesnprintf(value, sizeof(value), "%u", (unsigned)wave);
  16. Executedraw_text(frame, text_x, 128, value, 1, 0xffff);
  17. DecisionautoMode
    True
    1. Executedraw_text(frame, text_x, 145, "AUTO", 1, COLOR_YELLOW);
    False

    Continue after this decision.

Read original function source
static void drawInvadersLayout(uint16_t *frame)
{
    const int text_x = PLAY_RIGHT + 7;
    const uint16_t border = 0x7bef;
    const uint16_t cyan = RGB565(50, 205, 255);
    char value[16];
    // Draw last to mask sprites entering/exiting at the partition and protect
    // all four outer edges. PLAY_RIGHT and PLAY_BOTTOM are exclusive bounds.
    fill_rect(frame, PLAY_RIGHT, 0, LCD_WIDTH - PLAY_RIGHT, LCD_HEIGHT, 0);
    draw_rect(frame, 0, 0, LCD_WIDTH, LCD_HEIGHT, border);
    fill_rect(frame, PLAY_RIGHT, 0, 1, LCD_HEIGHT, border);

    draw_text(frame, text_x, 12, "SCORE", 1, cyan);
    snprintf(value, sizeof(value), "%u", (unsigned)score);
    draw_text(frame, text_x, 28, value, 1, 0xffff);
    draw_text(frame, text_x, 61, "LIVES", 1, cyan);
    snprintf(value, sizeof(value), "%u", (unsigned)lives);
    draw_text(frame, text_x, 77, value, 2, RGB565(70, 245, 130));
    draw_text(frame, text_x, 112, "WAVE", 1, cyan);
    snprintf(value, sizeof(value), "%u", (unsigned)wave);
    draw_text(frame, text_x, 128, value, 1, 0xffff);
    if (autoMode) draw_text(frame, text_x, 145, "AUTO", 1, COLOR_YELLOW);
}
drawInvaders() · source line 628
void drawInvaders(uint16_t *frame)

Calls into this documentation:

  1. Decision!running || frame == NULL
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  2. DecisiongameState == STATE_SCORES
    True
      1. ExecutedrawHighScoreEntry(frame);
      2. Exit this functionreturn;
    False

    Continue after this decision.

  3. Executeclear(frame, 0);
  4. Executestarfield_draw(frame, &stars);
  5. DecisiongameState == STATE_READY
    True
      1. Executedraw_text_centred(frame, PLAY_CENTRE, 12, "INVADERS", 3, 0xffff);
      2. Executedraw_text_centred(frame, 66, 51, "PLAY", 2, autoMode ? COLOR_LIGHTGRAY : COLOR_YELLOW);
      3. Executedraw_text_centred(frame, 186, 51, "AUTO", 2, autoMode ? COLOR_YELLOW : COLOR_LIGHTGRAY);
      4. Executedraw_text_centred(frame, PLAY_CENTRE, 91, "D/B/STICK CHANGE A/K START", 1, 0xffff);
      5. Executedraw_text_centred(frame, PLAY_CENTRE, 107, "D/B/STICK LEFT/RIGHT MOVE", 1, 0xffff);
      6. Executedraw_text_centred(frame, PLAY_CENTRE, 123, "HOLD A FIRE", 1, 0xffff);
      7. Executedraw_text_centred(frame, PLAY_CENTRE, 139, "EXTRA LIFE: 1500 4000 7000", 1, COLOR_SKYBLUE);
      8. Executedraw_text_centred(frame, PLAY_CENTRE, 156, "BOOT BACK", 1, 0x7bef);
      9. ExecutedrawInvadersLayout(frame);
      10. Exit this functionreturn;
    False

    Continue after this decision.

  6. FOR loopint row = 0; row < INVADER_ROWS; ++row

    Repeat body while the loop condition permits; then continue below.

      1. FOR loopint column = 0; column < INVADER_COLUMNS; ++column

        Repeat body while the loop condition permits; then continue below.

          1. Decisionalive[row][column]
            True
              1. ExecutedrawInvader(frame, rowTypes[row], marchFrame, formationX + column * COLUMN_SPACING, formationY + row * ROW_SPACING);
            False

            Continue after this decision.

  7. DecisionmothershipActive
    True
      1. ExecutedrawInvader(frame, TYPE_4, mothershipFrame, (int)mothershipX, MOTHERSHIP_Y);
    False

    Continue after this decision.

  8. FOR loopunsigned i = 0; i < MAX_HIT_EFFECTS; ++i

    Repeat body while the loop condition permits; then continue below.

      1. Executeconst HitEffect *effect = &hitEffects[i];
      2. Decisioneffect->remaining == 0
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      3. Executeconst unsigned pose = effect->remaining > HIT_FRAME_MS ? 0U : 1U;
      4. Executedraw_bitmap_scaled(frame, effect->x, effect->y, invader_hit_frames[pose], 8, 8, INVADER_SIZE, INVADER_SIZE, effect->color);
  9. DecisionplayerBullet.active
    True
      1. Executefill_rect(frame, (int)playerBullet.x, (int)playerBullet.y, 2, 5, 0xffff);
    False

    Continue after this decision.

  10. FOR loopunsigned i = 0; i < MAX_ENEMY_BULLETS; ++i

    Repeat body while the loop condition permits; then continue below.

      1. DecisionenemyBullets[i].active
        True
          1. Executefill_rect(frame, (int)enemyBullets[i].x, (int)enemyBullets[i].y, 2, 5, RGB565(255, 80, 100));
        False

        Continue after this decision.

  11. DecisiongameState == STATE_PLAYING && (protection == 0 || (protection / 125U) % 2U == 0)
    True
      1. Executeconst int x = (int)cannonX;
      2. Executeconst uint16_t green = RGB565(70, 245, 130);
      3. Executefill_rect(frame, x, CANNON_Y + 5, CANNON_WIDTH, 5, green);
      4. Executefill_rect(frame, x + 3, CANNON_Y + 3, CANNON_WIDTH - 6, 3, green);
      5. Executefill_rect(frame, x + 8, CANNON_Y, 2, 4, green);
    False

    Continue after this decision.

  12. DecisiongameState == STATE_FINISHED
    True
      1. Executedraw_text_centred(frame, PLAY_CENTRE, 78, "GAME OVER", 2, COLOR_RED);
    False

    Continue after this decision.

  13. ExecutedrawInvadersLayout(frame);
Read original function source
void drawInvaders(uint16_t *frame)
{
    if (!running || frame == NULL) return;
    if (gameState == STATE_SCORES) {
        drawHighScoreEntry(frame);
        return;
    }
    // Repaint the background so moving sprites and changing legs leave no trails.
    clear(frame, 0);
    starfield_draw(frame, &stars);
    if (gameState == STATE_READY) {
        draw_text_centred(frame, PLAY_CENTRE, 12, "INVADERS", 3, 0xffff);
        draw_text_centred(frame, 66, 51, "PLAY", 2, autoMode ? COLOR_LIGHTGRAY : COLOR_YELLOW);
        draw_text_centred(frame, 186, 51, "AUTO", 2, autoMode ? COLOR_YELLOW : COLOR_LIGHTGRAY);
        draw_text_centred(frame, PLAY_CENTRE, 91, "D/B/STICK CHANGE  A/K START", 1, 0xffff);
        draw_text_centred(frame, PLAY_CENTRE, 107, "D/B/STICK LEFT/RIGHT MOVE", 1, 0xffff);
        draw_text_centred(frame, PLAY_CENTRE, 123, "HOLD A FIRE", 1, 0xffff);
        draw_text_centred(frame, PLAY_CENTRE, 139, "EXTRA LIFE: 1500  4000  7000", 1, COLOR_SKYBLUE);
        draw_text_centred(frame, PLAY_CENTRE, 156, "BOOT BACK", 1, 0x7bef);
        drawInvadersLayout(frame);
        return;
    }

    for (int row = 0; row < INVADER_ROWS; ++row) {
        for (int column = 0; column < INVADER_COLUMNS; ++column) {
            if (alive[row][column]) {
                drawInvader(frame, rowTypes[row], marchFrame,
                            formationX + column * COLUMN_SPACING, formationY + row * ROW_SPACING);
            }
        }
    }
    if (mothershipActive) {
        drawInvader(frame, TYPE_4, mothershipFrame, (int)mothershipX, MOTHERSHIP_Y);
    }
    for (unsigned i = 0; i < MAX_HIT_EFFECTS; ++i) {
        const HitEffect *effect = &hitEffects[i];
        if (effect->remaining == 0) continue;
        const unsigned pose = effect->remaining > HIT_FRAME_MS ? 0U : 1U;
        draw_bitmap_scaled(frame, effect->x, effect->y, invader_hit_frames[pose], 8, 8,
                       INVADER_SIZE, INVADER_SIZE, effect->color);
    }
    if (playerBullet.active) {
        fill_rect(frame, (int)playerBullet.x, (int)playerBullet.y, 2, 5, 0xffff);
    }
    for (unsigned i = 0; i < MAX_ENEMY_BULLETS; ++i) {
        if (enemyBullets[i].active) {
            fill_rect(frame, (int)enemyBullets[i].x, (int)enemyBullets[i].y,
                         2, 5, RGB565(255, 80, 100));
        }
    }
    if (gameState == STATE_PLAYING && (protection == 0 || (protection / 125U) % 2U == 0)) {
        const int x = (int)cannonX;
        const uint16_t green = RGB565(70, 245, 130);
        fill_rect(frame, x, CANNON_Y + 5, CANNON_WIDTH, 5, green);
        fill_rect(frame, x + 3, CANNON_Y + 3, CANNON_WIDTH - 6, 3, green);
        fill_rect(frame, x + 8, CANNON_Y, 2, 4, green);
    }
    if (gameState == STATE_FINISHED) {
        draw_text_centred(frame, PLAY_CENTRE, 78, "GAME OVER", 2, COLOR_RED);
    }
    drawInvadersLayout(frame);
}
Complete original file, including constants and data
/* Coded By JD(2026) */
#error "Reference listing only — not provided as build-ready source."
#include "invaders.h"
#include "audio.h"
#include "game_graphics.h"
#include "game_config.h"
#include "gold.h"
#include "high_scores.h"
#include "mode_selection.h"
#include "PET2001_font.h"
#include "starfield.h"
#include "esp_random.h"
#include "esp_log.h"
#include <stdio.h>
#include <string.h>

#define INVADER_ROWS 5
#define INVADER_COLUMNS 8
#define INVADER_SIZE 16
#define COLUMN_SPACING 24
#define ROW_SPACING 18
#define PLAY_LEFT 1
#define PLAY_RIGHT 248
#define PLAY_TOP 1
#define PLAY_BOTTOM (LCD_HEIGHT - 1)
#define PLAY_WIDTH (PLAY_RIGHT - PLAY_LEFT)
#define PLAY_CENTRE (PLAY_LEFT + PLAY_WIDTH / 2)
#define FORMATION_TOP 24
#define MOTHERSHIP_Y 2
#define CANNON_Y 156
#define CANNON_WIDTH 18
#define CANNON_HEIGHT 10
#define MAX_ENEMY_BULLETS 3
#define STEP_MS 10U
#define FIRE_DELAY_MS 200U
#define PROTECTION_MS 2000U
#define HIT_FRAME_MS 100U
#define MAX_HIT_EFFECTS 4
#define AUTO_DODGE_TRIGGER_Y 42.0f
#define AUTO_DODGE_CHANCE_PERCENT 65U
#define AUTO_DODGE_MS 180U

// INVADER ARTWORK: edit each type independently here.
// Every type has two frames. Each frame has eight bytes, one per row from top
// to bottom. Bit 7 is the leftmost pixel; 1 draws and 0 preserves the background.
// These native 8x8 images are drawn at 16x16. Change the byte values to create
// your artwork; keep both frames and all eight rows in each frame.
// Native 16x16/32x32 artwork will also need updated source dimensions and array
// lengths when introduced. The shared renderer already supports those sizes.

// Invader 1: bottom two formation rows, 5 points per invader.
static const uint8_t invader_1_frames[2][8] = {
    // Frame 1
    {0x42, 0x3C, 0xFF, 0x99, 0xFF, 0x24, 0x42, 0x24},
    // Frame 2
    {0x18, 0x3C, 0xFF, 0x99, 0xFF, 0x24, 0x42, 0x81}
};

// Invader 2: middle two formation rows, 15 points per invader.
static const uint8_t invader_2_frames[2][8] = {
    // Frame 1
   // {0x99, 0xBD, 0xA5, 0xFF, 0x3C, 0x3C, 0x24, 0x42},
    {0x18, 0x3C, 0x24, 0x3C, 0xFF, 0x99, 0x24, 0x42},
    // Frame 2
   // {0x42, 0x3C, 0x24, 0xFF, 0xBD, 0xBD, 0xA5, 0x24}
    {0x18, 0x3C, 0x24, 0xBD, 0xFF, 0x18, 0x24, 0x24}
};

// Invader 3: top formation row, 25 points per invader.
static const uint8_t invader_3_frames[2][8] = {
    // Frame 1
    {0x18, 0x18, 0x3C, 0x5A, 0x7E, 0x18, 0x18, 0x24},
    // Frame 2
    {0x00, 0x3C, 0x3C, 0x5A, 0x7E, 0x18, 0x24, 0x42}
};

// Invader 4: mothership, 50 points. Crosses the separate top lane in either
// direction and alternates its two frames every 200 ms while visible.
static const uint8_t invader_4_frames[2][8] = {
    // Frame 1
    {0x00, 0x18, 0x3E, 0x6B, 0x55, 0x7F, 0x49, 0x24},
    // Frame 2
    {0x18, 0x3C, 0xFF, 0x99, 0xFF, 0x24, 0x42, 0x81}
};

// Enemy hit animation: frame 1 for 100 ms, frame 2 for 100 ms, then clear.
// Drawn at 16x16 in the destroyed enemy's color, at its position when hit.
static const uint8_t invader_hit_frames[2][8] = {
    // Frame 1
    // {0x81, 0x24, 0x42, 0x18, 0x18, 0x42, 0x24, 0x81},
    {0x81, 0x5A, 0x24, 0x5A, 0x5A, 0x24, 0x5A, 0x81},
    // Frame 2
    // {0x00, 0x18, 0x3E, 0x6B, 0x55, 0x7F, 0x24, 0x48}
    {0x99, 0x24, 0x42, 0x81, 0x81, 0x42, 0x24, 0x99}
};

typedef enum { TYPE_1, TYPE_2, TYPE_3, TYPE_4 } InvaderType;
typedef struct {
    const uint8_t (*frames)[8];
    uint16_t points, color;
} InvaderArtwork;

// Type definitions: artwork, points, drawing color.
static const InvaderArtwork artwork[] = {
    {invader_1_frames, 5, RGB565(70, 245, 130)},    // Bottom two rows: 5 points.
    {invader_2_frames, 15, RGB565(50, 205, 255)},   // Middle two rows: 15 points.
    {invader_3_frames, 25, RGB565(255, 225, 70)},   // Top formation row: 25 points.
    {invader_4_frames, 50, RGB565(255, 80, 100)},   // Mothership: 50 points.
};

// Formation rows from top to bottom: 3, 2, 2, 1, 1. Eight invaders per row.
static const InvaderType rowTypes[INVADER_ROWS] = {
    TYPE_3, TYPE_2, TYPE_2, TYPE_1, TYPE_1
};

typedef enum { STATE_READY, STATE_PLAYING, STATE_FINISHED, STATE_SCORES } GameState;
typedef struct {
    float x, y;
    bool active;
} Bullet;

typedef struct {
    int x, y;
    uint16_t color;
    uint32_t remaining;
} HitEffect;

// Fixed storage only; drawing reuses the caller's existing framebuffer.
static bool alive[INVADER_ROWS][INVADER_COLUMNS];
static Bullet playerBullet, enemyBullets[MAX_ENEMY_BULLETS];
// Separate slots preserve overlapping explosions; one player shot is active at a time.
static HitEffect hitEffects[MAX_HIT_EFFECTS];
static GameState gameState;
static float cannonX, mothershipX;
static int formationX, formationY, marchDirection, mothershipDirection;
static unsigned remaining, marchFrame, mothershipFrame;
static uint32_t score, wave, lastUpdate, accumulator, marchElapsed;
static uint32_t gameOverStarted;
static uint32_t fireDelay, enemyFireDelay, protection, mothershipDelay, mothershipElapsed;
static uint32_t autoDodgeUntil;
static uint8_t lives, extra_lives_awarded;
static int autoDodgeDirection, autoIgnoredBullet;
static bool running, controlsReady, mothershipActive, autoMode;
static mode_selection_input_t modeInput;
static starfield_t stars;

static const uint32_t extra_life_scores[] = { 1500U, 4000U, 7000U };

static uint32_t reduceTimer(uint32_t timer)
{
    return timer > STEP_MS ? timer - STEP_MS : 0;
}

static bool overlaps(float ax, float ay, float aw, float ah,
                     float bx, float by, float bw, float bh)
{
    return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}

static void addPoints(InvaderType type)
{
    const uint32_t points = artwork[type].points;
    score = score > UINT32_MAX - points ? UINT32_MAX : score + points;
    while (extra_lives_awarded < sizeof(extra_life_scores) / sizeof(extra_life_scores[0]) &&
           score >= extra_life_scores[extra_lives_awarded]) {
        ++lives;
        ESP_LOGI("invaders", "EXTRA LIFE: %u POINTS, %u LIVES",
                 (unsigned)extra_life_scores[extra_lives_awarded], (unsigned)lives);
        ++extra_lives_awarded;
    }
}

static void startHitEffect(int x, int y, InvaderType type)
{
    for (unsigned i = 0; i < MAX_HIT_EFFECTS; ++i) {
        if (hitEffects[i].remaining != 0) continue;
        hitEffects[i] = (HitEffect){x, y, artwork[type].color, HIT_FRAME_MS * 2U};
        return;
    }
    ESP_LOGW("invaders", "No free hit effect slot");
}

static void updateHitEffects(uint32_t elapsed)
{
    for (unsigned i = 0; i < MAX_HIT_EFFECTS; ++i) {
        HitEffect *effect = &hitEffects[i];
        effect->remaining = effect->remaining > elapsed ? effect->remaining - elapsed : 0;
    }
}

static bool hitEffectsActive(void)
{
    for (unsigned i = 0; i < MAX_HIT_EFFECTS; ++i) {
        if (hitEffects[i].remaining != 0) return true;
    }
    return false;
}

static void scheduleMothership(void)
{
    mothershipActive = false;
    mothershipDelay = 8000U + esp_random() % 6001U;
    mothershipFrame = mothershipElapsed = 0;
}

static void beginWave(void)
{
    memset(hitEffects, 0, sizeof(hitEffects));
    for (unsigned row = 0; row < INVADER_ROWS; ++row) {
        for (unsigned column = 0; column < INVADER_COLUMNS; ++column) {
            alive[row][column] = true;
        }
    }
    remaining = INVADER_ROWS * INVADER_COLUMNS;
    formationX = PLAY_LEFT + (PLAY_WIDTH - ((INVADER_COLUMNS - 1) * COLUMN_SPACING + INVADER_SIZE)) / 2;
    formationY = FORMATION_TOP;
    marchDirection = 1;
    marchFrame = marchElapsed = 0;
    playerBullet.active = false;
    memset(enemyBullets, 0, sizeof(enemyBullets));
    enemyFireDelay = 1000U;
    fireDelay = 0;
    autoDodgeUntil = 0U;
    autoDodgeDirection = 0;
    autoIgnoredBullet = -1;
    scheduleMothership();
    ESP_LOGI("invaders", "WAVE %u", (unsigned)wave);
}

static void beginGame(uint32_t now_ms)
{
    score = 0;
    wave = 1;
    lives = gameConfigInvadersStartingLives();
    extra_lives_awarded = 0U;
    cannonX = PLAY_LEFT + (PLAY_WIDTH - CANNON_WIDTH) / 2.0f;
    protection = PROTECTION_MS;
    controlsReady = false;
    lastUpdate = now_ms;
    accumulator = 0;
    gameState = STATE_PLAYING;
    beginWave();
    audioPlay(AUDIO_SFX_GAME_START);
}

static void finishGame(void)
{
    if (gameState != STATE_PLAYING) return;
    gameState = STATE_FINISHED;
    gameOverStarted = lastUpdate;
    audioPlay(AUDIO_SFX_GAME_OVER);
    ESP_LOGI("invaders", "GAME OVER: SCORE %u, WAVE %u", (unsigned)score, (unsigned)wave);
    // Award whole thousands once, when the game ends.
    const uint32_t gold = score / 1000U;
    if (gold != 0) {
        if ((autoMode ? addAIGold(gold) : addUserGoldWon(gold))) {
            ESP_LOGI("invaders", "GOLD WON: %u", (unsigned)gold);
        } else {
            ESP_LOGE("invaders", "Failed to save %u Gold", (unsigned)gold);
        }
    }
}

static int autoDodgeMovement(uint32_t now_ms)
{
    if (autoDodgeDirection != 0 && (int32_t)(now_ms - autoDodgeUntil) < 0) {
        return autoDodgeDirection;
    }
    autoDodgeDirection = 0;

    if (autoIgnoredBullet >= 0) {
        const Bullet *ignored = &enemyBullets[autoIgnoredBullet];
        if (!ignored->active || ignored->y < CANNON_Y - AUTO_DODGE_TRIGGER_Y ||
            ignored->y > CANNON_Y + CANNON_HEIGHT) {
            autoIgnoredBullet = -1;
        }
    }

    const int cannonCentre = (int)cannonX + CANNON_WIDTH / 2;
    for (unsigned i = 0; i < MAX_ENEMY_BULLETS; ++i) {
        const Bullet *bullet = &enemyBullets[i];
        if (!bullet->active || bullet->y < CANNON_Y - AUTO_DODGE_TRIGGER_Y ||
            bullet->y > CANNON_Y + CANNON_HEIGHT) continue;
        if ((int)i == autoIgnoredBullet ||
            bullet->x < cannonX - 8.0f || bullet->x > cannonX + CANNON_WIDTH + 8.0f) continue;
        if (esp_random() % 100U >= AUTO_DODGE_CHANCE_PERCENT) {
            autoIgnoredBullet = (int)i;
            return 0;
        }

        autoDodgeDirection = bullet->x < cannonCentre ? 1 : -1;
        if (autoDodgeDirection < 0 && cannonX <= PLAY_LEFT + 8) autoDodgeDirection = 1;
        if (autoDodgeDirection > 0 && cannonX >= PLAY_RIGHT - CANNON_WIDTH - 8) autoDodgeDirection = -1;
        autoDodgeUntil = now_ms + AUTO_DODGE_MS;
        return autoDodgeDirection;
    }
    return 0;
}

static int autoMovement(uint32_t now_ms)
{
    const int dodgeMovement = autoDodgeMovement(now_ms);
    if (dodgeMovement != 0) return dodgeMovement;

    const int cannonCentre = (int)cannonX + CANNON_WIDTH / 2;
    int targetX = cannonCentre;
    int bestDistance = PLAY_RIGHT - PLAY_LEFT;
    for (int column = 0; column < INVADER_COLUMNS; ++column) {
        int lowestRow = -1;
        for (int row = INVADER_ROWS - 1; row >= 0; --row) {
            if (alive[row][column]) {
                lowestRow = row;
                break;
            }
        }
        if (lowestRow < 0) continue;
        const int invaderX = formationX + column * COLUMN_SPACING + INVADER_SIZE / 2;
        const int distance = invaderX > cannonCentre ? invaderX - cannonCentre : cannonCentre - invaderX;
        if (distance < bestDistance) {
            bestDistance = distance;
            targetX = invaderX;
        }
    }

    return targetX > cannonCentre + 2 ? 1 : targetX < cannonCentre - 2 ? -1 : 0;
}

static void marchFormation(void)
{
    int left = PLAY_RIGHT, right = PLAY_LEFT;
    for (int row = 0; row < INVADER_ROWS; ++row) {
        for (int column = 0; column < INVADER_COLUMNS; ++column) {
            if (!alive[row][column]) continue;
            const int x = formationX + column * COLUMN_SPACING;
            if (x < left) left = x;
            if (x + INVADER_SIZE > right) right = x + INVADER_SIZE;
        }
    }
    if (left + marchDirection * 4 < PLAY_LEFT + 3 ||
        right + marchDirection * 4 > PLAY_RIGHT - 3) {
        formationY += 4;
        marchDirection = -marchDirection;
    } else {
        formationX += marchDirection * 4;
    }
    // The whole formation changes pose once per marching step, including drops.
    marchFrame ^= 1U;
    for (int row = 0; row < INVADER_ROWS; ++row) {
        if (formationY + row * ROW_SPACING + INVADER_SIZE < CANNON_Y) continue;
        for (int column = 0; column < INVADER_COLUMNS; ++column) {
            if (alive[row][column]) {
                finishGame();
                return;
            }
        }
    }
}

static void fireEnemyBullet(void)
{
    for (unsigned slot = 0; slot < MAX_ENEMY_BULLETS; ++slot) {
        if (enemyBullets[slot].active) continue;
        const unsigned first = esp_random() % INVADER_COLUMNS;
        for (unsigned offset = 0; offset < INVADER_COLUMNS; ++offset) {
            const unsigned column = (first + offset) % INVADER_COLUMNS;
            // Only the lowest surviving invader in a column fires.
            for (int row = INVADER_ROWS - 1; row >= 0; --row) {
                if (!alive[row][column]) continue;
                enemyBullets[slot] = (Bullet){
                    .x = formationX + (int)column * COLUMN_SPACING + INVADER_SIZE / 2 - 1,
                    .y = formationY + row * ROW_SPACING + INVADER_SIZE,
                    .active = true,
                };
                audioPlay(AUDIO_SFX_ENEMY_SHOT);
                return;
            }
        }
        return;
    }
}

static void updateMothership(void)
{
    if (!mothershipActive) {
        mothershipDelay = reduceTimer(mothershipDelay);
        if (mothershipDelay != 0) return;
        mothershipDirection = (esp_random() & 1U) != 0 ? 1 : -1;
        mothershipX = mothershipDirection > 0 ? PLAY_LEFT - INVADER_SIZE : PLAY_RIGHT;
        mothershipActive = true;
        mothershipFrame = mothershipElapsed = 0;
    }
    mothershipX += mothershipDirection * 28.0f * STEP_MS / 1000.0f;
    mothershipElapsed += STEP_MS;
    if (mothershipElapsed >= 200U) {
        mothershipElapsed -= 200U;
        mothershipFrame ^= 1U;
    }
    if ((mothershipDirection > 0 && mothershipX >= PLAY_RIGHT) ||
        (mothershipDirection < 0 && mothershipX + INVADER_SIZE <= PLAY_LEFT)) {
        scheduleMothership();
    }
}

static void updatePlayerBullet(void)
{
    if (!playerBullet.active) return;
    playerBullet.y -= 220.0f * STEP_MS / 1000.0f;
    // Rectangular sprite bounds are used for hits, independent of artwork bits.
    // Check bottom rows first so a shot hits the nearest invader in its path.
    for (int row = INVADER_ROWS - 1; row >= 0; --row) {
        for (int column = 0; column < INVADER_COLUMNS; ++column) {
            if (!alive[row][column]) continue;
            if (overlaps(playerBullet.x, playerBullet.y, 2, 5,
                         formationX + column * COLUMN_SPACING,
                         formationY + row * ROW_SPACING, INVADER_SIZE, INVADER_SIZE)) {
                alive[row][column] = false;
                --remaining;
                playerBullet.active = false;
                addPoints(rowTypes[row]);
                startHitEffect(formationX + column * COLUMN_SPACING,
                               formationY + row * ROW_SPACING, rowTypes[row]);
                audioPlay(AUDIO_SFX_HIT);
                if (remaining == 0U) audioPlay(AUDIO_SFX_WAVE_CLEAR);
                return;
            }
        }
    }
    if (mothershipActive && overlaps(playerBullet.x, playerBullet.y, 2, 5,
                                    mothershipX, MOTHERSHIP_Y, INVADER_SIZE, INVADER_SIZE)) {
        playerBullet.active = false;
        addPoints(TYPE_4);
        startHitEffect((int)mothershipX, MOTHERSHIP_Y, TYPE_4);
        scheduleMothership();
        audioPlay(AUDIO_SFX_HIT);
        ESP_LOGI("invaders", "MOTHERSHIP HIT: 50 POINTS");
    } else if (playerBullet.y + 5 <= PLAY_TOP) {
        playerBullet.active = false;
    }
}

static void simulateStep(int movement, bool fire)
{
    // Pause the cleared wave until every explosion has played both frames.
    if (remaining == 0) {
        if (!hitEffectsActive()) {
            if (wave < UINT32_MAX) ++wave;
            beginWave();
        }
        return;
    }
    protection = reduceTimer(protection);
    fireDelay = reduceTimer(fireDelay);
    cannonX += movement * 125.0f * STEP_MS / 1000.0f;
    if (cannonX < PLAY_LEFT + 1) cannonX = PLAY_LEFT + 1;
    if (cannonX > PLAY_RIGHT - CANNON_WIDTH - 1) cannonX = PLAY_RIGHT - CANNON_WIDTH - 1;
    if (fire && fireDelay == 0 && !playerBullet.active) {
        playerBullet = (Bullet){cannonX + CANNON_WIDTH / 2 - 1, CANNON_Y - 5, true};
        fireDelay = FIRE_DELAY_MS;
        audioPlay(AUDIO_SFX_PLAYER_SHOT);
    }

    updateMothership();
    updatePlayerBullet();
    if (remaining == 0) {
        memset(enemyBullets, 0, sizeof(enemyBullets));
        return;
    }

    // Wave difficulty affects marching, enemy firing cadence, and bullet speed.
    const uint32_t difficulty = wave < 9U ? wave - 1U : 8U;
    // Each destroyed invader removes 30 ms from the march interval, so the
    // formation marches faster as the wave is cleared.
    const uint32_t base_interval = 120U + remaining * 30U;
    const uint32_t normal_march_interval = base_interval > 80U + difficulty * 20U
        ? base_interval - difficulty * 20U : 80U;
    // The final survivor gets a deliberate Crazy speed, independent of wave.
    const uint32_t march_interval = remaining == 1U ? 40U : normal_march_interval;
    marchElapsed += STEP_MS;
    if (marchElapsed >= march_interval) {
        marchElapsed -= march_interval;
        marchFormation();
        if (gameState != STATE_PLAYING) return;
    }

    enemyFireDelay = reduceTimer(enemyFireDelay);
    if (enemyFireDelay == 0) {
        fireEnemyBullet();
        // Higher waves shorten the delay between enemy shots; the random term
        // keeps the firing pattern from becoming perfectly regular.
        enemyFireDelay = 900U - difficulty * 50U + esp_random() % 301U;
    }
    for (unsigned i = 0; i < MAX_ENEMY_BULLETS; ++i) {
        Bullet *bullet = &enemyBullets[i];
        if (!bullet->active) continue;
        // Enemy bullet travel speed increases by wave, not by each shot.
        bullet->y += (60.0f + difficulty * 4.0f) * STEP_MS / 1000.0f;
        if (bullet->y >= PLAY_BOTTOM) {
            bullet->active = false;
        } else if (overlaps(bullet->x, bullet->y, 2, 5,
                            cannonX, CANNON_Y, CANNON_WIDTH, CANNON_HEIGHT)) {
            bullet->active = false;
            if (protection != 0) continue;
            --lives;
            ESP_LOGI("invaders", "PLAYER HIT: %u LIVES", (unsigned)lives);
            audioPlay(AUDIO_SFX_PLAYER_EXPLOSION);
            if (lives == 0) {
                finishGame();
                return;
            }
            cannonX = PLAY_LEFT + (PLAY_WIDTH - CANNON_WIDTH) / 2.0f;
            protection = PROTECTION_MS;
            controlsReady = false;
            playerBullet.active = false;
            memset(enemyBullets, 0, sizeof(enemyBullets));
            return;
        }
    }
}

void startInvaders(uint32_t now_ms)
{
    starfield_initialize(&stars, 45, PLAY_LEFT, PLAY_TOP, PLAY_WIDTH,
                         PLAY_BOTTOM - PLAY_TOP, STARFIELD_GREYSCALE);
    running = true;
    gameState = STATE_READY;
    lastUpdate = now_ms;
    accumulator = score = 0;
    wave = 1;
    lives = 3;
    extra_lives_awarded = 0U;
    memset(hitEffects, 0, sizeof(hitEffects));
    controlsReady = false;
    autoMode = false;
    modeSelectionReset(&modeInput);
    ESP_LOGI("invaders", "START");
}

void stopInvaders(void)
{
    cancelHighScoreEntry();
    running = false;
    memset(hitEffects, 0, sizeof(hitEffects));
    ESP_LOGI("invaders", "STOP");
}

void invadersAction(bool start, bool stop_auto, uint32_t now_ms)
{
    if (running && start && gameState == STATE_READY) {
        ESP_LOGI("invaders", "START: %s", autoMode ? "AUTO" : "PLAY");
        beginGame(now_ms);
    } else if (running && stop_auto && autoMode && gameState == STATE_PLAYING) {
        autoMode = false;
        modeSelectionReset(&modeInput);
        gameState = STATE_READY;
    }
}

void updateInvaders(int x_offset, int y_offset, bool valid, bool left, bool right,
                     bool fire, uint32_t now_ms)
{
    if (!running) return;
    const uint32_t elapsed = now_ms - lastUpdate;
    lastUpdate = now_ms;
    // Effects also expire on the game-over screen. Update before new hits are
    // created so a new explosion gets its full first frame on the next draw.
    updateHitEffects(elapsed);
    starfield_update(&stars, elapsed);
    if (gameState == STATE_FINISHED && now_ms - gameOverStarted >= 1000U) {
        if (autoMode) beginGame(now_ms);
        else {
            startHighScoreEntry(HIGH_SCORE_INVADERS, score, now_ms);
            gameState = STATE_SCORES;
        }
    }
    if (gameState == STATE_SCORES) {
        if (updateHighScoreEntry(x_offset, y_offset, valid, now_ms)) gameState = STATE_READY;
        return;
    }
    if (gameState == STATE_READY) {
        if (modeSelectionNext(&modeInput, x_offset, y_offset, valid, left, right, now_ms)) {
            autoMode = !autoMode;
        }
        return;
    }
    if (gameState != STATE_PLAYING) return;
    if (!valid) controlsReady = false;
    else if (x_offset >= -350 && x_offset <= 350 && y_offset >= -350 && y_offset <= 350)
        controlsReady = true;
    const int movement = left || right ? (int)right - (int)left
        : valid && controlsReady ? (x_offset > 600 ? 1 : x_offset < -600 ? -1 : 0) : 0;
    const int automatedMovement = autoMode ? autoMovement(now_ms) : 0;
    // Bound catch-up after a stall; collisions are checked every 10 ms.
    accumulator += elapsed > 100U ? 100U : elapsed;
    while (accumulator >= STEP_MS && gameState == STATE_PLAYING) {
        accumulator -= STEP_MS;
        simulateStep(autoMode ? automatedMovement : (left || right || controlsReady) ? movement : 0,
                     autoMode || fire);
    }
}

static void drawInvader(uint16_t *frame, InvaderType type, unsigned pose, int x, int y)
{
    draw_bitmap_scaled(frame, x, y, artwork[type].frames[pose], 8, 8,
                   INVADER_SIZE, INVADER_SIZE, artwork[type].color);
}

static void drawInvadersLayout(uint16_t *frame)
{
    const int text_x = PLAY_RIGHT + 7;
    const uint16_t border = 0x7bef;
    const uint16_t cyan = RGB565(50, 205, 255);
    char value[16];
    // Draw last to mask sprites entering/exiting at the partition and protect
    // all four outer edges. PLAY_RIGHT and PLAY_BOTTOM are exclusive bounds.
    fill_rect(frame, PLAY_RIGHT, 0, LCD_WIDTH - PLAY_RIGHT, LCD_HEIGHT, 0);
    draw_rect(frame, 0, 0, LCD_WIDTH, LCD_HEIGHT, border);
    fill_rect(frame, PLAY_RIGHT, 0, 1, LCD_HEIGHT, border);

    drawPet2001TextTransparent(frame, text_x, 12, "SCORE", cyan, 1);
    snprintf(value, sizeof(value), "%u", (unsigned)score);
    drawPet2001TextTransparent(frame, text_x, 28, value, 0xffff, 1);
    drawPet2001TextTransparent(frame, text_x, 61, "LIVES", cyan, 1);
    snprintf(value, sizeof(value), "%u", (unsigned)lives);
    drawPet2001TextTransparent(frame, text_x, 77, value, RGB565(70, 245, 130), 2);
    drawPet2001TextTransparent(frame, text_x, 112, "WAVE", cyan, 1);
    snprintf(value, sizeof(value), "%u", (unsigned)wave);
    drawPet2001TextTransparent(frame, text_x, 128, value, 0xffff, 1);
    if (autoMode) drawPet2001TextTransparent(frame, text_x, 145, "AUTO", COLOR_YELLOW, 1);
}

void drawInvaders(uint16_t *frame)
{
    if (!running || frame == NULL) return;
    if (gameState == STATE_SCORES) {
        drawHighScoreEntry(frame);
        return;
    }
    // Repaint the background so moving sprites and changing legs leave no trails.
    clear(frame, 0);
    starfield_draw(frame, &stars);
    if (gameState == STATE_READY) {
        drawPet2001TextCentredTransparent(frame, PLAY_CENTRE, 12, "INVADERS", 0xffff, 3);
        drawPet2001TextCentredTransparent(frame, 66, 51, "PLAY",
                                          autoMode ? COLOR_LIGHTGRAY : COLOR_YELLOW, 2);
        drawPet2001TextCentredTransparent(frame, 186, 51, "AUTO",
                                          autoMode ? COLOR_YELLOW : COLOR_LIGHTGRAY, 2);
        drawPet2001TextCentredTransparent(frame, PLAY_CENTRE, 91,
                                          "D/B/STICK CHANGE  A/K START", 0xffff, 1);
        drawPet2001TextCentredTransparent(frame, PLAY_CENTRE, 107,
                                          "D/B/STICK LEFT/RIGHT MOVE", 0xffff, 1);
        drawPet2001TextCentredTransparent(frame, PLAY_CENTRE, 123, "HOLD A FIRE", 0xffff, 1);
        drawPet2001TextCentredTransparent(frame, PLAY_CENTRE, 139,
                                          "EXTRA LIFE: 1500  4000  7000", COLOR_SKYBLUE, 1);
        drawPet2001TextCentredTransparent(frame, PLAY_CENTRE, 156, "BOOT BACK", 0x7bef, 1);
        drawInvadersLayout(frame);
        return;
    }

    for (int row = 0; row < INVADER_ROWS; ++row) {
        for (int column = 0; column < INVADER_COLUMNS; ++column) {
            if (alive[row][column]) {
                drawInvader(frame, rowTypes[row], marchFrame,
                            formationX + column * COLUMN_SPACING, formationY + row * ROW_SPACING);
            }
        }
    }
    if (mothershipActive) {
        drawInvader(frame, TYPE_4, mothershipFrame, (int)mothershipX, MOTHERSHIP_Y);
    }
    for (unsigned i = 0; i < MAX_HIT_EFFECTS; ++i) {
        const HitEffect *effect = &hitEffects[i];
        if (effect->remaining == 0) continue;
        const unsigned pose = effect->remaining > HIT_FRAME_MS ? 0U : 1U;
        draw_bitmap_scaled(frame, effect->x, effect->y, invader_hit_frames[pose], 8, 8,
                       INVADER_SIZE, INVADER_SIZE, effect->color);
    }
    if (playerBullet.active) {
        fill_rect(frame, (int)playerBullet.x, (int)playerBullet.y, 2, 5, 0xffff);
    }
    for (unsigned i = 0; i < MAX_ENEMY_BULLETS; ++i) {
        if (enemyBullets[i].active) {
            fill_rect(frame, (int)enemyBullets[i].x, (int)enemyBullets[i].y,
                         2, 5, RGB565(255, 80, 100));
        }
    }
    if (gameState == STATE_PLAYING && (protection == 0 || (protection / 125U) % 2U == 0)) {
        const int x = (int)cannonX;
        const uint16_t green = RGB565(70, 245, 130);
        fill_rect(frame, x, CANNON_Y + 5, CANNON_WIDTH, 5, green);
        fill_rect(frame, x + 3, CANNON_Y + 3, CANNON_WIDTH - 6, 3, green);
        fill_rect(frame, x + 8, CANNON_Y, 2, 4, green);
    }
    if (gameState == STATE_FINISHED) {
        drawPet2001TextCentredTransparent(frame, PLAY_CENTRE, 78, "GAME OVER", COLOR_RED, 2);
    }
    drawInvadersLayout(frame);
}