Tempest

Source: src/tempest.c · 431 lines · 20 functions.

Screen captures

Views of this part running on the Mini Arcade.

Tempest running on the physical Mini Arcade
Tempest displayed on the physical Mini Arcade.

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

nextLane() · source line 42
static uint8_t nextLane(uint8_t lane, int direction)
  1. Exit this functionreturn (uint8_t)((lane + direction + laneCount) % laneCount);
Read original function source
static uint8_t nextLane(uint8_t lane, int direction)
{
    return (uint8_t)((lane + direction + laneCount) % laneCount);
}
decrease() · source line 47
static uint32_t decrease(uint32_t value)
  1. Exit this functionreturn value > STEP_MS ? value - STEP_MS : 0;
Read original function source
static uint32_t decrease(uint32_t value)
{
    return value > STEP_MS ? value - STEP_MS : 0;
}
activeEnemies() · source line 52
static unsigned activeEnemies(void)
  1. Executeunsigned count = 0;
  2. FOR loopunsigned i = 0; i < MAX_ENEMIES; ++i

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

    1. Executecount += enemies[i].active;
  3. Exit this functionreturn count;
Read original function source
static unsigned activeEnemies(void)
{
    unsigned count = 0;
    for (unsigned i = 0; i < MAX_ENEMIES; ++i) count += enemies[i].active;
    return count;
}
destroyEnemy() · source line 59
static void destroyEnemy(unsigned index)
  1. Executeconst uint32_t points = enemies[index].flipper ? 150U : 100U;
  2. Executescore = score > UINT32_MAX - points ? UINT32_MAX : score + points;
  3. Executeenemies[index].active = false;
Read original function source
static void destroyEnemy(unsigned index)
{
    const uint32_t points = enemies[index].flipper ? 150U : 100U;
    score = score > UINT32_MAX - points ? UINT32_MAX : score + points;
    enemies[index].active = false;
}
updateWeb() · source line 66
static void updateWeb(void)
  1. FOR loopunsigned i = 0; i < laneCount; ++i

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

      1. Executeconst float angle = -PI / 2.0f + i * 2.0f * PI / laneCount;
      2. Executeouter[i] = (Point){108.0f + cosf(angle) * 91.0f, 88.0f + sinf(angle) * 64.0f};
      3. Executeinner[i] = (Point){108.0f + cosf(angle) * 19.0f, 88.0f + sinf(angle) * 13.0f};
Read original function source
static void updateWeb(void)
{
    for (unsigned i = 0; i < laneCount; ++i) {
        const float angle = -PI / 2.0f + i * 2.0f * PI / laneCount;
        outer[i] = (Point){108.0f + cosf(angle) * 91.0f, 88.0f + sinf(angle) * 64.0f};
        inner[i] = (Point){108.0f + cosf(angle) * 19.0f, 88.0f + sinf(angle) * 13.0f};
    }
}
beginWave() · source line 75
static void beginWave(void)

Calls into this documentation:

  1. Executememset(enemies, 0, sizeof(enemies));
  2. Executememset(shots, 0, sizeof(shots));
  3. Executeconst uint8_t previousLanes = laneCount;
  4. Decisionwave <= 8U
    True
      1. ExecutelaneCount = 7U + ((wave - 1U) / 2U) * 3U;
    False
    1. Decision(wave & 1U) != 0
      True
        1. ExecutelaneCount = 8U + esp_random() % 9U;
      False

      Continue after this decision.

  5. ExecuteplayerLane = (uint8_t)(((2U * playerLane + 1U) * laneCount) / (2U * previousLanes));
  6. ExecuteupdateWeb();
  7. Executeremaining = 8U + (wave < 12U ? wave : 12U) * 2U;
  8. ExecutespawnDue = 600U;
  9. ExecutefireDue = moveDue = heldDirection = 0;
  10. Executeprotection = 1800U;
  11. ExecutezapReady = true;
  12. ExecutegameState = STATE_PLAYING;
  13. ExecuteESP_LOGI("tempest", "WAVE %u; LANES %u", (unsigned)wave, (unsigned)laneCount);
Read original function source
static void beginWave(void)
{
    memset(enemies, 0, sizeof(enemies));
    memset(shots, 0, sizeof(shots));
    const uint8_t previousLanes = laneCount;
    if (wave <= 8U) {
        laneCount = 7U + ((wave - 1U) / 2U) * 3U;
    } else if ((wave & 1U) != 0) {
        // Keep each random web for two waves; repeated counts are allowed.
        laneCount = 8U + esp_random() % 9U;
    }
    // Map the old lane centre onto the new web to preserve angular position.
    playerLane = (uint8_t)(((2U * playerLane + 1U) * laneCount) / (2U * previousLanes));
    updateWeb();
    remaining = 8U + (wave < 12U ? wave : 12U) * 2U;
    spawnDue = 600U;
    fireDue = moveDue = heldDirection = 0;
    protection = 1800U;
    zapReady = true;
    gameState = STATE_PLAYING;
    ESP_LOGI("tempest", "WAVE %u; LANES %u", (unsigned)wave, (unsigned)laneCount);
}
beginGame() · source line 98
static void beginGame(uint32_t now_ms)

Calls into this documentation:

  1. Executescore = 0;
  2. Executewave = 1;
  3. Executelives = 3;
  4. ExecutelaneCount = MAX_LANES;
  5. ExecuteplayerLane = 0;
  6. Executeaccumulator = zapFlash = transition = 0;
  7. ExecutelastUpdate = now_ms;
  8. ExecutecontrolsReady = false;
  9. ExecutebeginWave();
  10. ExecuteaudioPlay(AUDIO_SFX_GAME_START);
Read original function source
static void beginGame(uint32_t now_ms)
{
    score = 0;
    wave = 1;
    lives = 3;
    laneCount = MAX_LANES;
    playerLane = 0;
    accumulator = zapFlash = transition = 0;
    lastUpdate = now_ms;
    controlsReady = false;
    beginWave();
    audioPlay(AUDIO_SFX_GAME_START);
}
spawnEnemy() · source line 112
static void spawnEnemy(void)
  1. FOR loopunsigned i = 0; i < MAX_ENEMIES; ++i

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

      1. Decisionenemies[i].active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      2. Executeenemies[i] = (Enemy){ .depth = 0.0f, .moveDue = 1000U + esp_random() % 800U, .lane = esp_random() % laneCount, .direction = esp_random() % 2 ? 1 : -1, .active = true, .flipper = wave >= 2U && esp_random() % 3 == 0, };
      3. Execute--remaining;
      4. ExecutespawnDue = 1100U - (wave < 10U ? wave : 10U) * 60U;
      5. Exit this functionreturn;
Read original function source
static void spawnEnemy(void)
{
    for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
        if (enemies[i].active) continue;
        enemies[i] = (Enemy){
            .depth = 0.0f,
            .moveDue = 1000U + esp_random() % 800U,
            .lane = esp_random() % laneCount,
            .direction = esp_random() % 2 ? 1 : -1,
            .active = true,
            .flipper = wave >= 2U && esp_random() % 3 == 0,
        };
        --remaining;
        spawnDue = 1100U - (wave < 10U ? wave : 10U) * 60U;
        return;
    }
}
shoot() · source line 130
static void shoot(void)

Calls into this documentation:

  1. DecisionfireDue
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  2. FOR loopunsigned i = 0; i < MAX_SHOTS; ++i

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

      1. Decisionshots[i].active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      2. Executeshots[i] = (Shot){1.0f, playerLane, true};
      3. ExecutefireDue = 140U;
      4. ExecuteaudioPlay(AUDIO_SFX_PLAYER_SHOT);
      5. Exit this functionreturn;
Read original function source
static void shoot(void)
{
    if (fireDue) return;
    for (unsigned i = 0; i < MAX_SHOTS; ++i) {
        if (shots[i].active) continue;
        shots[i] = (Shot){1.0f, playerLane, true};
        fireDue = 140U;
        audioPlay(AUDIO_SFX_PLAYER_SHOT);
        return;
    }
}
loseLife() · source line 142
static void loseLife(void)

Calls into this documentation:

  1. Execute--lives;
  2. ExecuteESP_LOGI("tempest", "LIVES %u; SCORE %u", lives, (unsigned)score);
  3. Executememset(enemies, 0, sizeof(enemies));
  4. Executememset(shots, 0, sizeof(shots));
  5. Executeprotection = 1800U;
  6. ExecutecontrolsReady = false;
  7. ExecuteheldDirection = 0;
  8. Decisionlives == 0
    True
      1. ExecutegameState = STATE_FINISHED;
      2. ExecutegameOverStarted = lastUpdate;
      3. ExecuteaudioPlay(AUDIO_SFX_GAME_OVER);
      4. Executeconst uint32_t gold = score / 1000U;
      5. Decisiongold != 0
        True
          1. DecisionaddUserGoldWon(gold)
            True
              1. ExecuteESP_LOGI("tempest", "GOLD WON: %u", (unsigned)gold);
            False
              1. ExecuteESP_LOGE("tempest", "Failed to save %u Gold", (unsigned)gold);
        False

        Continue after this decision.

    False
      1. ExecuteaudioPlay(AUDIO_SFX_LIFE_LOST);
Read original function source
static void loseLife(void)
{
    --lives; // lives minus 1
    ESP_LOGI("tempest", "LIVES %u; SCORE %u", lives, (unsigned)score);
    memset(enemies, 0, sizeof(enemies));
    memset(shots, 0, sizeof(shots));
    protection = 1800U;
    controlsReady = false;
    heldDirection = 0;
    if (lives == 0) {
        gameState = STATE_FINISHED;
        gameOverStarted = lastUpdate;
        audioPlay(AUDIO_SFX_GAME_OVER);
        // Award whole thousands once, when the game ends.
        const uint32_t gold = score / 1000U;
        if (gold != 0) {
            if (addUserGoldWon(gold)) {
                ESP_LOGI("tempest", "GOLD WON: %u", (unsigned)gold);
            } else {
                ESP_LOGE("tempest", "Failed to save %u Gold", (unsigned)gold);
            }
        }
    } else {
        audioPlay(AUDIO_SFX_LIFE_LOST);
    }
}
simulateStep() · source line 169
static void simulateStep(int direction, bool fire)

Calls into this documentation:

  1. ExecutezapFlash = decrease(zapFlash);
  2. DecisiongameState == STATE_NEXT_WAVE
    True
      1. Executetransition = decrease(transition);
      2. Decisiontransition == 0
        True
          1. Decisionwave < UINT32_MAX
            True
            1. Execute++wave;
            False

            Continue after this decision.

          2. ExecutebeginWave();
        False

        Continue after this decision.

      3. Exit this functionreturn;
    False

    Continue after this decision.

  3. Executeprotection = decrease(protection);
  4. ExecutespawnDue = decrease(spawnDue);
  5. ExecutefireDue = decrease(fireDue);
  6. ExecutemoveDue = decrease(moveDue);
  7. Decisiondirection == 0
    True
    1. ExecuteheldDirection = 0;
    False
    1. DecisionheldDirection != direction || moveDue == 0
      True
        1. ExecuteplayerLane = nextLane(playerLane, direction);
        2. ExecutemoveDue = heldDirection == direction ? 100U : 180U;
        3. ExecuteheldDirection = direction;
      False

      Continue after this decision.

  8. Decisionremaining && spawnDue == 0
    True
    1. ExecutespawnEnemy();
    False

    Continue after this decision.

  9. Decisionfire
    True
    1. Executeshoot();
    False

    Continue after this decision.

  10. Executeconst float speed = 0.075f + (wave < 15U ? wave : 15U) * 0.009f;
  11. FOR loopunsigned i = 0; i < MAX_ENEMIES; ++i

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

      1. ExecuteEnemy *enemy = &enemies[i];
      2. Decision!enemy->active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      3. Executeenemy->moveDue = decrease(enemy->moveDue);
      4. Decisionenemy->rim
        True
          1. Decisionenemy->moveDue == 0
            True
              1. Executeenemy->lane = nextLane(enemy->lane, enemy->direction);
              2. Executeenemy->moveDue = 240U;
            False

            Continue after this decision.

        False
          1. Executeenemy->depth += speed * (STEP_MS / 1000.0f);
          2. Decisionenemy->flipper && enemy->moveDue == 0
            True
              1. Executeenemy->lane = nextLane(enemy->lane, enemy->direction);
              2. Executeenemy->moveDue = 900U;
            False

            Continue after this decision.

          3. Decisionenemy->depth >= 1.0f
            True
              1. Executeenemy->depth = 1.0f;
              2. Executeenemy->rim = true;
              3. Executeenemy->moveDue = 240U;
            False

            Continue after this decision.

  12. FOR loopunsigned i = 0; i < MAX_SHOTS; ++i

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

      1. ExecuteShot *shot = &shots[i];
      2. Decision!shot->active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      3. Executeconst float before = shot->depth;
      4. Executeshot->depth -= 1.8f * (STEP_MS / 1000.0f);
      5. Executeint target = -1;
      6. Executefloat nearest = -1.0f;
      7. FOR loopunsigned j = 0; j < MAX_ENEMIES; ++j

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

          1. Executeconst Enemy *enemy = &enemies[j];
          2. Decision!enemy->active || enemy->lane != shot->lane
            True
            1. Next loop iterationcontinue;
            False

            Continue after this decision.

          3. Decisionenemy->depth <= before + 0.035f && enemy->depth >= shot->depth - 0.035f && enemy->depth > nearest
            True
              1. Executetarget = (int)j;
              2. Executenearest = enemy->depth;
            False

            Continue after this decision.

      8. Decisiontarget >= 0
        True
          1. ExecutedestroyEnemy((unsigned)target);
          2. Executeshot->active = false;
          3. ExecuteaudioPlay(AUDIO_SFX_HIT);
        False
        1. Decisionshot->depth < 0.0f
          True
          1. Executeshot->active = false;
          False

          Continue after this decision.

  13. Decisionprotection == 0
    True
      1. FOR loopunsigned i = 0; i < MAX_ENEMIES; ++i

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

          1. Decisionenemies[i].active && enemies[i].rim && enemies[i].lane == playerLane
            True
              1. ExecuteloseLife();
              2. Exit this functionreturn;
            False

            Continue after this decision.

    False

    Continue after this decision.

  14. Decisionremaining == 0 && activeEnemies() == 0
    True
      1. ExecutegameState = STATE_NEXT_WAVE;
      2. Executetransition = 1500U;
      3. Executememset(shots, 0, sizeof(shots));
      4. ExecuteaudioPlay(AUDIO_SFX_WAVE_CLEAR);
    False

    Continue after this decision.

Read original function source
static void simulateStep(int direction, bool fire)
{
    zapFlash = decrease(zapFlash);
    if (gameState == STATE_NEXT_WAVE) {
        transition = decrease(transition);
        if (transition == 0) {
            if (wave < UINT32_MAX) ++wave;
            beginWave();
        }
        return;
    }
    protection = decrease(protection);
    spawnDue = decrease(spawnDue);
    fireDue = decrease(fireDue);
    moveDue = decrease(moveDue);
    if (direction == 0) heldDirection = 0;
    else if (heldDirection != direction || moveDue == 0) {
        playerLane = nextLane(playerLane, direction);
        moveDue = heldDirection == direction ? 100U : 180U;
        heldDirection = direction;
    }
    if (remaining && spawnDue == 0) spawnEnemy();
    if (fire) shoot();
    const float speed = 0.075f + (wave < 15U ? wave : 15U) * 0.009f;
    for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
        Enemy *enemy = &enemies[i];
        if (!enemy->active) continue;
        enemy->moveDue = decrease(enemy->moveDue);
        if (enemy->rim) {
            if (enemy->moveDue == 0) {
                enemy->lane = nextLane(enemy->lane, enemy->direction);
                enemy->moveDue = 240U;
            }
        } else {
            enemy->depth += speed * (STEP_MS / 1000.0f);
            if (enemy->flipper && enemy->moveDue == 0) {
                enemy->lane = nextLane(enemy->lane, enemy->direction);
                enemy->moveDue = 900U;
            }
            if (enemy->depth >= 1.0f) {
                enemy->depth = 1.0f;
                enemy->rim = true;
                enemy->moveDue = 240U;
            }
        }
    }
    // A shot hits the first enemy along its travelled segment, including the rim.
    for (unsigned i = 0; i < MAX_SHOTS; ++i) {
        Shot *shot = &shots[i];
        if (!shot->active) continue;
        const float before = shot->depth;
        shot->depth -= 1.8f * (STEP_MS / 1000.0f);
        int target = -1;
        float nearest = -1.0f;
        for (unsigned j = 0; j < MAX_ENEMIES; ++j) {
            const Enemy *enemy = &enemies[j];
            if (!enemy->active || enemy->lane != shot->lane) continue;
            if (enemy->depth <= before + 0.035f && enemy->depth >= shot->depth - 0.035f &&
                enemy->depth > nearest) { target = (int)j; nearest = enemy->depth; }
        }
        if (target >= 0) {
            destroyEnemy((unsigned)target);
            shot->active = false;
            audioPlay(AUDIO_SFX_HIT);
        }
        else if (shot->depth < 0.0f) shot->active = false;
    }
    if (protection == 0) {
        for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
            if (enemies[i].active && enemies[i].rim && enemies[i].lane == playerLane) {
                loseLife();
                return;
            }
        }
    }
    if (remaining == 0 && activeEnemies() == 0) {
        gameState = STATE_NEXT_WAVE;
        transition = 1500U;
        memset(shots, 0, sizeof(shots));
        audioPlay(AUDIO_SFX_WAVE_CLEAR);
    }
}
startTempest() · source line 252
void startTempest(uint32_t now_ms)

Calls into this documentation:

  1. Executerunning = true;
  2. ExecutegameState = STATE_READY;
  3. ExecutelastUpdate = now_ms;
  4. ExecutelaneCount = MAX_LANES;
  5. ExecuteupdateWeb();
  6. ExecuteESP_LOGI("tempest", "START");
Read original function source
void startTempest(uint32_t now_ms)
{
    running = true;
    gameState = STATE_READY;
    lastUpdate = now_ms;
    laneCount = MAX_LANES;
    updateWeb();
    ESP_LOGI("tempest", "START");
}
stopTempest() · source line 262
void stopTempest(void)

Calls into this documentation:

  1. ExecutecancelHighScoreEntry();
  2. Executerunning = false;
  3. ExecuteESP_LOGI("tempest", "STOP");
Read original function source
void stopTempest(void)
{
    cancelHighScoreEntry();
    running = false;
    ESP_LOGI("tempest", "STOP");
}
tempestAction() · source line 269
void tempestAction(bool start, bool zap, uint32_t now_ms)

Calls into this documentation:

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

    Continue after this decision.

  2. Decisionstart && gameState == STATE_READY
    True
      1. ExecutebeginGame(now_ms);
      2. Exit this functionreturn;
    False

    Continue after this decision.

  3. Decisionzap && zapReady && gameState == STATE_PLAYING
    True
      1. ExecutezapReady = false;
      2. ExecutezapFlash = 250U;
      3. FOR loopunsigned i = 0; i < MAX_ENEMIES; ++i

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

          1. Decisionenemies[i].active
            True
            1. ExecutedestroyEnemy(i);
            False

            Continue after this decision.

      4. ExecuteaudioPlay(AUDIO_SFX_EXPLOSION);
    False

    Continue after this decision.

Read original function source
void tempestAction(bool start, bool zap, uint32_t now_ms)
{
    if (!running) return;
    if (start && gameState == STATE_READY) {
        beginGame(now_ms);
        return;
    }
    if (zap && zapReady && gameState == STATE_PLAYING) {
        zapReady = false;
        zapFlash = 250U;
        for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
            if (enemies[i].active) destroyEnemy(i);
        }
        audioPlay(AUDIO_SFX_EXPLOSION);
    }
}
joystickLane() · source line 286
static uint8_t joystickLane(int x_offset, int y_offset)

Calls into this documentation:

  1. Executeconst float angle = atan2f(-(float)y_offset, (float)x_offset);
  2. Executefloat bestDifference = 4.0f;
  3. Executeuint8_t selected = playerLane;
  4. FOR loopunsigned offset = 0; offset < laneCount; ++offset

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

      1. Executeconst uint8_t lane = (playerLane + offset) % laneCount;
      2. Executeconst uint8_t next = nextLane(lane, 1);
      3. Executeconst float x = (outer[lane].x + outer[next].x) * 0.5f - 108.0f;
      4. Executeconst float y = (outer[lane].y + outer[next].y) * 0.5f - 88.0f;
      5. Executefloat difference = fabsf(atan2f(y, x) - angle);
      6. Decisiondifference > PI
        True
        1. Executedifference = 2.0f * PI - difference;
        False

        Continue after this decision.

      7. Decisiondifference + 0.00001f < bestDifference
        True
          1. ExecutebestDifference = difference;
          2. Executeselected = lane;
        False

        Continue after this decision.

  5. Exit this functionreturn selected;
Read original function source
static uint8_t joystickLane(int x_offset, int y_offset)
{
    const float angle = atan2f(-(float)y_offset, (float)x_offset);
    float bestDifference = 4.0f;
    uint8_t selected = playerLane;
    // Compare actual lane centres so diagonals also match the oval web.
    // Start at the current lane to keep it when two directions tie.
    for (unsigned offset = 0; offset < laneCount; ++offset) {
        const uint8_t lane = (playerLane + offset) % laneCount;
        const uint8_t next = nextLane(lane, 1);
        const float x = (outer[lane].x + outer[next].x) * 0.5f - 108.0f;
        const float y = (outer[lane].y + outer[next].y) * 0.5f - 88.0f;
        float difference = fabsf(atan2f(y, x) - angle);
        if (difference > PI) difference = 2.0f * PI - difference;
        if (difference + 0.00001f < bestDifference) {
            bestDifference = difference;
            selected = lane;
        }
    }
    return selected;
}
updateTempest() · source line 308
void updateTempest(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. DecisiongameState == STATE_FINISHED && now_ms - gameOverStarted >= 1000U
    True
      1. ExecutestartHighScoreEntry(HIGH_SCORE_TEMPEST, score, now_ms);
      2. ExecutegameState = STATE_SCORES;
    False

    Continue after this decision.

  5. 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.

  6. DecisiongameState != STATE_PLAYING && gameState != STATE_NEXT_WAVE
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  7. 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.

  8. Executeconst int direction = (int)right - (int)left;
  9. Executeconst bool stickPosition = !left && !right && valid && controlsReady && (x_offset > 600 || x_offset < -600 || y_offset > 600 || y_offset < -600);
  10. Executeuint8_t targetLane = stickPosition ? joystickLane(x_offset, y_offset) : playerLane;
  11. Executeuint8_t targetLaneCount = laneCount;
  12. Executeaccumulator += elapsed > 100U ? 100U : elapsed;
  13. WHILE loopaccumulator >= STEP_MS && (gameState == STATE_PLAYING || gameState == STATE_NEXT_WAVE)

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

      1. Executeaccumulator -= STEP_MS;
      2. DecisionstickPosition && controlsReady && gameState == STATE_PLAYING
        True
          1. DecisiontargetLaneCount != laneCount
            True
              1. ExecutetargetLane = joystickLane(x_offset, y_offset);
              2. ExecutetargetLaneCount = laneCount;
            False

            Continue after this decision.

          2. ExecuteplayerLane = targetLane;
        False

        Continue after this decision.

      3. ExecutesimulateStep(direction, fire);
Read original function source
void updateTempest(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;
    if (gameState == STATE_FINISHED && now_ms - gameOverStarted >= 1000U) {
        startHighScoreEntry(HIGH_SCORE_TEMPEST, 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_PLAYING && gameState != STATE_NEXT_WAVE) return;
    if (!valid) controlsReady = false;
    else if (x_offset >= -350 && x_offset <= 350 && y_offset >= -350 && y_offset <= 350)
        controlsReady = true;
    const int direction = (int)right - (int)left;
    const bool stickPosition = !left && !right && valid && controlsReady &&
        (x_offset > 600 || x_offset < -600 || y_offset > 600 || y_offset < -600);
    uint8_t targetLane = stickPosition ? joystickLane(x_offset, y_offset) : playerLane;
    uint8_t targetLaneCount = laneCount;
    accumulator += elapsed > 100U ? 100U : elapsed;
    while (accumulator >= STEP_MS && (gameState == STATE_PLAYING || gameState == STATE_NEXT_WAVE)) {
        accumulator -= STEP_MS;
        if (stickPosition && controlsReady && gameState == STATE_PLAYING) {
            // A wave transition can rebuild the web during catch-up steps.
            if (targetLaneCount != laneCount) {
                targetLane = joystickLane(x_offset, y_offset);
                targetLaneCount = laneCount;
            }
            playerLane = targetLane;
        }
        simulateStep(direction, fire);
    }
}
webPoint() · source line 346
static Point webPoint(unsigned edge, float depth)
  1. Exit this functionreturn (Point){inner[edge].x + (outer[edge].x - inner[edge].x) * depth, inner[edge].y + (outer[edge].y - inner[edge].y) * depth};
Read original function source
static Point webPoint(unsigned edge, float depth)
{
    return (Point){inner[edge].x + (outer[edge].x - inner[edge].x) * depth,
                   inner[edge].y + (outer[edge].y - inner[edge].y) * depth};
}
lanePoint() · source line 352
static Point lanePoint(uint8_t lane, float depth)

Calls into this documentation:

  1. Executeconst Point a = webPoint(lane, depth), b = webPoint(nextLane(lane, 1), depth);
  2. Exit this functionreturn (Point){(a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f};
Read original function source
static Point lanePoint(uint8_t lane, float depth)
{
    const Point a = webPoint(lane, depth), b = webPoint(nextLane(lane, 1), depth);
    return (Point){(a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f};
}
line() · source line 358
static void line(uint16_t *frame, Point a, Point b, uint16_t color)

Calls into this documentation:

  1. Executedraw_line(frame, (int)lroundf(a.x), (int)lroundf(a.y), (int)lroundf(b.x), (int)lroundf(b.y), color);
Read original function source
static void line(uint16_t *frame, Point a, Point b, uint16_t color)
{
    draw_line(frame, (int)lroundf(a.x), (int)lroundf(a.y), (int)lroundf(b.x), (int)lroundf(b.y), color);
}
drawTempest() · source line 363
void drawTempest(uint16_t *frame)

Calls into this documentation:

  1. Decision!running
    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. Executechar text[48];
  5. DecisiongameState == STATE_READY
    True
      1. Executedraw_text_centred(frame, 160, 15, "TEMPEST", 3, 0x07ff);
      2. Executedraw_text_centred(frame, 160, 43, "POINT STICK TO POSITION SHIP", 1, 0xffff);
      3. Executedraw_text_centred(frame, 160, 57, "D/B ROTATE LEFT/RIGHT", 1, 0xffff);
      4. Executedraw_text_centred(frame, 160, 72, "HOLD A FIRE", 1, 0xffff);
      5. Executedraw_text_centred(frame, 160, 92, "E SUPERZAPPER ONCE PER WAVE", 1, 0xffe0);
      6. Executedraw_text_centred(frame, 160, 153, "BOOT BACK", 1, 0x7bef);
      7. Exit this functionreturn;
    False

    Continue after this decision.

  6. Executestatic const uint16_t colors[] = {0x039f, 0x781f, 0x03ef, COLOR_DARKBLUE};
  7. Executeconst uint16_t webColor = zapFlash ? 0xffff : colors[(wave - 1U) % 4U];
  8. FOR loopunsigned i = 0; i < laneCount; ++i

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

      1. Executeline(frame, inner[i], outer[i], webColor);
      2. FOR loopunsigned ring = 0; ring <= 4; ++ring

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

        1. Executeline(frame, webPoint(i, ring / 4.0f), webPoint(nextLane(i, 1), ring / 4.0f), webColor);
  9. FOR loopunsigned i = 0; i < MAX_ENEMIES; ++i

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

      1. Executeconst Enemy *enemy = &enemies[i];
      2. Decision!enemy->active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      3. Executeconst Point p = lanePoint(enemy->lane, enemy->depth);
      4. Executeconst float radius = 2.0f + enemy->depth * 3.0f;
      5. Executeconst uint16_t color = enemy->rim ? 0xffe0 : enemy->flipper ? 0xf81f : 0xf800;
      6. Executeconst Point a = {p.x, p.y - radius}, b = {p.x + radius, p.y};
      7. Executeconst Point c = {p.x, p.y + radius}, d = {p.x - radius, p.y};
      8. Executeline(frame, a, b, color);
      9. Executeline(frame, b, c, color);
      10. Executeline(frame, c, d, color);
      11. Executeline(frame, d, a, color);
      12. Decisionenemy->flipper
        True
          1. Executeline(frame, a, c, color);
          2. Executeline(frame, b, d, color);
        False

        Continue after this decision.

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

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

      1. Decisionshots[i].active
        True
        1. Executeline(frame, lanePoint(shots[i].lane, shots[i].depth), lanePoint(shots[i].lane, fminf(1.0f, shots[i].depth + 0.08f)), 0xffff);
        False

        Continue after this decision.

  11. DecisiongameState != STATE_FINISHED && (protection == 0 || (protection / 125U) % 2 == 0)
    True
      1. Executeconst Point a = webPoint(playerLane, 1.06f), b = webPoint(nextLane(playerLane, 1), 1.06f);
      2. Executeconst Point nose = lanePoint(playerLane, 0.90f), rear = lanePoint(playerLane, 1.13f);
      3. Executeline(frame, a, nose, 0xffe0);
      4. Executeline(frame, nose, b, 0xffe0);
      5. Executeline(frame, b, rear, 0xffe0);
      6. Executeline(frame, rear, a, 0xffe0);
    False

    Continue after this decision.

  12. Executedraw_text(frame, 218, 8, "TEMPEST", 2, 0x07ff);
  13. Executedraw_text(frame, 218, 35, "SCORE", 1, 0xffff);
  14. Executesnprintf(text, sizeof(text), "%u", (unsigned)score);
  15. Executedraw_text(frame, 218, 47, text, 1, 0xffff);
  16. Executesnprintf(text, sizeof(text), "WAVE %u", (unsigned)wave);
  17. Executedraw_text(frame, 218, 67, text, 1, 0xffff);
  18. Executesnprintf(text, sizeof(text), "LIVES %u", lives);
  19. Executedraw_text(frame, 218, 84, text, 1, 0xffff);
  20. Executedraw_text(frame, 218, 105, zapReady ? "ZAP READY" : "ZAP USED", 1, 0xffe0);
  21. Executedraw_text(frame, 218, 127, "A FIRE E ZAP", 1, 0x7bef);
  22. Executedraw_text(frame, 218, 150, "BOOT BACK", 1, 0x7bef);
  23. DecisiongameState == STATE_NEXT_WAVE || gameState == STATE_FINISHED
    True
      1. Executedraw_text_centred(frame, 111, 90, gameState == STATE_FINISHED ? "GAME OVER" : "WAVE CLEAR", 2, COLOR_RED);
    False

    Continue after this decision.

Read original function source
void drawTempest(uint16_t *frame)
{
    if (!running) return;
    if (gameState == STATE_SCORES) {
        drawHighScoreEntry(frame);
        return;
    }
    clear(frame, 0);
    char text[48];
    if (gameState == STATE_READY) {
        draw_text_centred(frame, 160, 15, "TEMPEST", 3, 0x07ff);
        draw_text_centred(frame, 160, 43, "POINT STICK TO POSITION SHIP", 1, 0xffff);
        draw_text_centred(frame, 160, 57, "D/B ROTATE LEFT/RIGHT", 1, 0xffff);
        draw_text_centred(frame, 160, 72, "HOLD A FIRE", 1, 0xffff);
        draw_text_centred(frame, 160, 92, "E SUPERZAPPER ONCE PER WAVE", 1, 0xffe0);
        draw_text_centred(frame, 160, 153, "BOOT BACK", 1, 0x7bef);
        return;
    }
    static const uint16_t colors[] = {0x039f, 0x781f, 0x03ef, COLOR_DARKBLUE};
    const uint16_t webColor = zapFlash ? 0xffff : colors[(wave - 1U) % 4U];
    for (unsigned i = 0; i < laneCount; ++i) {
        line(frame, inner[i], outer[i], webColor);
        for (unsigned ring = 0; ring <= 4; ++ring)
            line(frame, webPoint(i, ring / 4.0f), webPoint(nextLane(i, 1), ring / 4.0f), webColor);
    }
    for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
        const Enemy *enemy = &enemies[i];
        if (!enemy->active) continue;
        const Point p = lanePoint(enemy->lane, enemy->depth);
        const float radius = 2.0f + enemy->depth * 3.0f;
        const uint16_t color = enemy->rim ? 0xffe0 : enemy->flipper ? 0xf81f : 0xf800;
        const Point a = {p.x, p.y - radius}, b = {p.x + radius, p.y};
        const Point c = {p.x, p.y + radius}, d = {p.x - radius, p.y};
        line(frame, a, b, color); line(frame, b, c, color);
        line(frame, c, d, color); line(frame, d, a, color);
        if (enemy->flipper) { line(frame, a, c, color); line(frame, b, d, color); }
    }
    for (unsigned i = 0; i < MAX_SHOTS; ++i) {
        if (shots[i].active)
            line(frame, lanePoint(shots[i].lane, shots[i].depth),
                 lanePoint(shots[i].lane, fminf(1.0f, shots[i].depth + 0.08f)), 0xffff);
    }
    if (gameState != STATE_FINISHED && (protection == 0 || (protection / 125U) % 2 == 0)) {
        const Point a = webPoint(playerLane, 1.06f), b = webPoint(nextLane(playerLane, 1), 1.06f);
        const Point nose = lanePoint(playerLane, 0.90f), rear = lanePoint(playerLane, 1.13f);
        line(frame, a, nose, 0xffe0); line(frame, nose, b, 0xffe0);
        line(frame, b, rear, 0xffe0); line(frame, rear, a, 0xffe0);
    }
    draw_text(frame, 218, 8, "TEMPEST", 2, 0x07ff);
    draw_text(frame, 218, 35, "SCORE", 1, 0xffff);
    snprintf(text, sizeof(text), "%u", (unsigned)score);
    draw_text(frame, 218, 47, text, 1, 0xffff);
    snprintf(text, sizeof(text), "WAVE %u", (unsigned)wave);
    draw_text(frame, 218, 67, text, 1, 0xffff);
    snprintf(text, sizeof(text), "LIVES %u", lives);
    draw_text(frame, 218, 84, text, 1, 0xffff);
    draw_text(frame, 218, 105, zapReady ? "ZAP READY" : "ZAP USED", 1, 0xffe0);
    draw_text(frame, 218, 127, "A FIRE E ZAP", 1, 0x7bef);
    draw_text(frame, 218, 150, "BOOT BACK", 1, 0x7bef);
    if (gameState == STATE_NEXT_WAVE || gameState == STATE_FINISHED) {
        draw_text_centred(frame, 111, 90,
                           gameState == STATE_FINISHED ? "GAME OVER" : "WAVE CLEAR",
                           2, COLOR_RED);
    }
}
Complete original file, including constants and data
/* Coded By JD(2026) */
#error "Reference listing only — not provided as build-ready source."
#include "tempest.h"
#include "audio.h"
#include "game_graphics.h"
#include "gold.h"
#include "high_scores.h"
#include "PET2001_font.h"
#include "esp_random.h"
#include "esp_log.h"
#include <math.h>
#include <stdio.h>
#include <string.h>

#define MAX_LANES 16
#define MAX_ENEMIES 24
#define MAX_SHOTS 8
#define STEP_MS 10U
#define PI 3.14159265f

typedef enum { STATE_READY, STATE_PLAYING, STATE_NEXT_WAVE, STATE_FINISHED, STATE_SCORES } GameState;
typedef struct {
    float depth;
    uint32_t moveDue;
    uint8_t lane;
    int8_t direction;
    bool active, flipper, rim;
} Enemy;
typedef struct { float depth; uint8_t lane; bool active; } Shot;
typedef struct { float x, y; } Point;

static Enemy enemies[MAX_ENEMIES];
static Shot shots[MAX_SHOTS];
static Point outer[MAX_LANES], inner[MAX_LANES];
static uint8_t laneCount = MAX_LANES;
static GameState gameState;
static uint32_t score, wave, lastUpdate, accumulator, spawnDue, fireDue;
static uint32_t moveDue, protection, transition, zapFlash;
static uint32_t gameOverStarted;
static unsigned remaining;
static uint8_t playerLane, lives;
static int heldDirection;
static bool running, controlsReady, zapReady;

static uint8_t nextLane(uint8_t lane, int direction)
{
    return (uint8_t)((lane + direction + laneCount) % laneCount);
}

static uint32_t decrease(uint32_t value)
{
    return value > STEP_MS ? value - STEP_MS : 0;
}

static unsigned activeEnemies(void)
{
    unsigned count = 0;
    for (unsigned i = 0; i < MAX_ENEMIES; ++i) count += enemies[i].active;
    return count;
}

static void destroyEnemy(unsigned index)
{
    const uint32_t points = enemies[index].flipper ? 150U : 100U;
    score = score > UINT32_MAX - points ? UINT32_MAX : score + points;
    enemies[index].active = false;
}

static void updateWeb(void)
{
    for (unsigned i = 0; i < laneCount; ++i) {
        const float angle = -PI / 2.0f + i * 2.0f * PI / laneCount;
        outer[i] = (Point){108.0f + cosf(angle) * 91.0f, 88.0f + sinf(angle) * 64.0f};
        inner[i] = (Point){108.0f + cosf(angle) * 19.0f, 88.0f + sinf(angle) * 13.0f};
    }
}

static void beginWave(void)
{
    memset(enemies, 0, sizeof(enemies));
    memset(shots, 0, sizeof(shots));
    const uint8_t previousLanes = laneCount;
    if (wave <= 8U) {
        laneCount = 7U + ((wave - 1U) / 2U) * 3U;
    } else if ((wave & 1U) != 0) {
        // Keep each random web for two waves; repeated counts are allowed.
        laneCount = 8U + esp_random() % 9U;
    }
    // Map the old lane centre onto the new web to preserve angular position.
    playerLane = (uint8_t)(((2U * playerLane + 1U) * laneCount) / (2U * previousLanes));
    updateWeb();
    remaining = 8U + (wave < 12U ? wave : 12U) * 2U;
    spawnDue = 600U;
    fireDue = moveDue = heldDirection = 0;
    protection = 1800U;
    zapReady = true;
    gameState = STATE_PLAYING;
    ESP_LOGI("tempest", "WAVE %u; LANES %u", (unsigned)wave, (unsigned)laneCount);
}

static void beginGame(uint32_t now_ms)
{
    score = 0;
    wave = 1;
    lives = 3;
    laneCount = MAX_LANES;
    playerLane = 0;
    accumulator = zapFlash = transition = 0;
    lastUpdate = now_ms;
    controlsReady = false;
    beginWave();
    audioPlay(AUDIO_SFX_GAME_START);
}

static void spawnEnemy(void)
{
    for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
        if (enemies[i].active) continue;
        enemies[i] = (Enemy){
            .depth = 0.0f,
            .moveDue = 1000U + esp_random() % 800U,
            .lane = esp_random() % laneCount,
            .direction = esp_random() % 2 ? 1 : -1,
            .active = true,
            .flipper = wave >= 2U && esp_random() % 3 == 0,
        };
        --remaining;
        spawnDue = 1100U - (wave < 10U ? wave : 10U) * 60U;
        return;
    }
}

static void shoot(void)
{
    if (fireDue) return;
    for (unsigned i = 0; i < MAX_SHOTS; ++i) {
        if (shots[i].active) continue;
        shots[i] = (Shot){1.0f, playerLane, true};
        fireDue = 140U;
        audioPlay(AUDIO_SFX_PLAYER_SHOT);
        return;
    }
}

static void loseLife(void)
{
    --lives; // lives minus 1
    ESP_LOGI("tempest", "LIVES %u; SCORE %u", lives, (unsigned)score);
    memset(enemies, 0, sizeof(enemies));
    memset(shots, 0, sizeof(shots));
    protection = 1800U;
    controlsReady = false;
    heldDirection = 0;
    if (lives == 0) {
        gameState = STATE_FINISHED;
        gameOverStarted = lastUpdate;
        audioPlay(AUDIO_SFX_GAME_OVER);
        // Award whole thousands once, when the game ends.
        const uint32_t gold = score / 1000U;
        if (gold != 0) {
            if (addUserGoldWon(gold)) {
                ESP_LOGI("tempest", "GOLD WON: %u", (unsigned)gold);
            } else {
                ESP_LOGE("tempest", "Failed to save %u Gold", (unsigned)gold);
            }
        }
    } else {
        audioPlay(AUDIO_SFX_LIFE_LOST);
    }
}

static void simulateStep(int direction, bool fire)
{
    zapFlash = decrease(zapFlash);
    if (gameState == STATE_NEXT_WAVE) {
        transition = decrease(transition);
        if (transition == 0) {
            if (wave < UINT32_MAX) ++wave;
            beginWave();
        }
        return;
    }
    protection = decrease(protection);
    spawnDue = decrease(spawnDue);
    fireDue = decrease(fireDue);
    moveDue = decrease(moveDue);
    if (direction == 0) heldDirection = 0;
    else if (heldDirection != direction || moveDue == 0) {
        playerLane = nextLane(playerLane, direction);
        moveDue = heldDirection == direction ? 100U : 180U;
        heldDirection = direction;
    }
    if (remaining && spawnDue == 0) spawnEnemy();
    if (fire) shoot();
    const float speed = 0.075f + (wave < 15U ? wave : 15U) * 0.009f;
    for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
        Enemy *enemy = &enemies[i];
        if (!enemy->active) continue;
        enemy->moveDue = decrease(enemy->moveDue);
        if (enemy->rim) {
            if (enemy->moveDue == 0) {
                enemy->lane = nextLane(enemy->lane, enemy->direction);
                enemy->moveDue = 240U;
            }
        } else {
            enemy->depth += speed * (STEP_MS / 1000.0f);
            if (enemy->flipper && enemy->moveDue == 0) {
                enemy->lane = nextLane(enemy->lane, enemy->direction);
                enemy->moveDue = 900U;
            }
            if (enemy->depth >= 1.0f) {
                enemy->depth = 1.0f;
                enemy->rim = true;
                enemy->moveDue = 240U;
            }
        }
    }
    // A shot hits the first enemy along its travelled segment, including the rim.
    for (unsigned i = 0; i < MAX_SHOTS; ++i) {
        Shot *shot = &shots[i];
        if (!shot->active) continue;
        const float before = shot->depth;
        shot->depth -= 1.8f * (STEP_MS / 1000.0f);
        int target = -1;
        float nearest = -1.0f;
        for (unsigned j = 0; j < MAX_ENEMIES; ++j) {
            const Enemy *enemy = &enemies[j];
            if (!enemy->active || enemy->lane != shot->lane) continue;
            if (enemy->depth <= before + 0.035f && enemy->depth >= shot->depth - 0.035f &&
                enemy->depth > nearest) { target = (int)j; nearest = enemy->depth; }
        }
        if (target >= 0) {
            destroyEnemy((unsigned)target);
            shot->active = false;
            audioPlay(AUDIO_SFX_HIT);
        }
        else if (shot->depth < 0.0f) shot->active = false;
    }
    if (protection == 0) {
        for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
            if (enemies[i].active && enemies[i].rim && enemies[i].lane == playerLane) {
                loseLife();
                return;
            }
        }
    }
    if (remaining == 0 && activeEnemies() == 0) {
        gameState = STATE_NEXT_WAVE;
        transition = 1500U;
        memset(shots, 0, sizeof(shots));
        audioPlay(AUDIO_SFX_WAVE_CLEAR);
    }
}

void startTempest(uint32_t now_ms)
{
    running = true;
    gameState = STATE_READY;
    lastUpdate = now_ms;
    laneCount = MAX_LANES;
    updateWeb();
    ESP_LOGI("tempest", "START");
}

void stopTempest(void)
{
    cancelHighScoreEntry();
    running = false;
    ESP_LOGI("tempest", "STOP");
}

void tempestAction(bool start, bool zap, uint32_t now_ms)
{
    if (!running) return;
    if (start && gameState == STATE_READY) {
        beginGame(now_ms);
        return;
    }
    if (zap && zapReady && gameState == STATE_PLAYING) {
        zapReady = false;
        zapFlash = 250U;
        for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
            if (enemies[i].active) destroyEnemy(i);
        }
        audioPlay(AUDIO_SFX_EXPLOSION);
    }
}

static uint8_t joystickLane(int x_offset, int y_offset)
{
    const float angle = atan2f(-(float)y_offset, (float)x_offset);
    float bestDifference = 4.0f;
    uint8_t selected = playerLane;
    // Compare actual lane centres so diagonals also match the oval web.
    // Start at the current lane to keep it when two directions tie.
    for (unsigned offset = 0; offset < laneCount; ++offset) {
        const uint8_t lane = (playerLane + offset) % laneCount;
        const uint8_t next = nextLane(lane, 1);
        const float x = (outer[lane].x + outer[next].x) * 0.5f - 108.0f;
        const float y = (outer[lane].y + outer[next].y) * 0.5f - 88.0f;
        float difference = fabsf(atan2f(y, x) - angle);
        if (difference > PI) difference = 2.0f * PI - difference;
        if (difference + 0.00001f < bestDifference) {
            bestDifference = difference;
            selected = lane;
        }
    }
    return selected;
}

void updateTempest(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;
    if (gameState == STATE_FINISHED && now_ms - gameOverStarted >= 1000U) {
        startHighScoreEntry(HIGH_SCORE_TEMPEST, 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_PLAYING && gameState != STATE_NEXT_WAVE) return;
    if (!valid) controlsReady = false;
    else if (x_offset >= -350 && x_offset <= 350 && y_offset >= -350 && y_offset <= 350)
        controlsReady = true;
    const int direction = (int)right - (int)left;
    const bool stickPosition = !left && !right && valid && controlsReady &&
        (x_offset > 600 || x_offset < -600 || y_offset > 600 || y_offset < -600);
    uint8_t targetLane = stickPosition ? joystickLane(x_offset, y_offset) : playerLane;
    uint8_t targetLaneCount = laneCount;
    accumulator += elapsed > 100U ? 100U : elapsed;
    while (accumulator >= STEP_MS && (gameState == STATE_PLAYING || gameState == STATE_NEXT_WAVE)) {
        accumulator -= STEP_MS;
        if (stickPosition && controlsReady && gameState == STATE_PLAYING) {
            // A wave transition can rebuild the web during catch-up steps.
            if (targetLaneCount != laneCount) {
                targetLane = joystickLane(x_offset, y_offset);
                targetLaneCount = laneCount;
            }
            playerLane = targetLane;
        }
        simulateStep(direction, fire);
    }
}

static Point webPoint(unsigned edge, float depth)
{
    return (Point){inner[edge].x + (outer[edge].x - inner[edge].x) * depth,
                   inner[edge].y + (outer[edge].y - inner[edge].y) * depth};
}

static Point lanePoint(uint8_t lane, float depth)
{
    const Point a = webPoint(lane, depth), b = webPoint(nextLane(lane, 1), depth);
    return (Point){(a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f};
}

static void line(uint16_t *frame, Point a, Point b, uint16_t color)
{
    draw_line(frame, (int)lroundf(a.x), (int)lroundf(a.y), (int)lroundf(b.x), (int)lroundf(b.y), color);
}

void drawTempest(uint16_t *frame)
{
    if (!running) return;
    if (gameState == STATE_SCORES) {
        drawHighScoreEntry(frame);
        return;
    }
    clear(frame, 0);
    char text[48];
    if (gameState == STATE_READY) {
        drawPet2001TextCentredTransparent(frame, 160, 15, "TEMPEST", 0x07ff, 3);
        drawPet2001TextCentredTransparent(frame, 160, 43,
                                          "POINT STICK TO POSITION SHIP", 0xffff, 1);
        drawPet2001TextCentredTransparent(frame, 160, 57,
                                          "D/B ROTATE LEFT/RIGHT", 0xffff, 1);
        drawPet2001TextCentredTransparent(frame, 160, 72, "HOLD A FIRE", 0xffff, 1);
        drawPet2001TextCentredTransparent(frame, 160, 92,
                                          "E SUPERZAPPER ONCE PER WAVE", 0xffe0, 1);
        drawPet2001TextCentredTransparent(frame, 160, 153, "BOOT BACK", 0x7bef, 1);
        return;
    }
    static const uint16_t colors[] = {0x039f, 0x781f, 0x03ef, COLOR_DARKBLUE};
    const uint16_t webColor = zapFlash ? 0xffff : colors[(wave - 1U) % 4U];
    for (unsigned i = 0; i < laneCount; ++i) {
        line(frame, inner[i], outer[i], webColor);
        for (unsigned ring = 0; ring <= 4; ++ring)
            line(frame, webPoint(i, ring / 4.0f), webPoint(nextLane(i, 1), ring / 4.0f), webColor);
    }
    for (unsigned i = 0; i < MAX_ENEMIES; ++i) {
        const Enemy *enemy = &enemies[i];
        if (!enemy->active) continue;
        const Point p = lanePoint(enemy->lane, enemy->depth);
        const float radius = 2.0f + enemy->depth * 3.0f;
        const uint16_t color = enemy->rim ? 0xffe0 : enemy->flipper ? 0xf81f : 0xf800;
        const Point a = {p.x, p.y - radius}, b = {p.x + radius, p.y};
        const Point c = {p.x, p.y + radius}, d = {p.x - radius, p.y};
        line(frame, a, b, color); line(frame, b, c, color);
        line(frame, c, d, color); line(frame, d, a, color);
        if (enemy->flipper) { line(frame, a, c, color); line(frame, b, d, color); }
    }
    for (unsigned i = 0; i < MAX_SHOTS; ++i) {
        if (shots[i].active)
            line(frame, lanePoint(shots[i].lane, shots[i].depth),
                 lanePoint(shots[i].lane, fminf(1.0f, shots[i].depth + 0.08f)), 0xffff);
    }
    if (gameState != STATE_FINISHED && (protection == 0 || (protection / 125U) % 2 == 0)) {
        const Point a = webPoint(playerLane, 1.06f), b = webPoint(nextLane(playerLane, 1), 1.06f);
        const Point nose = lanePoint(playerLane, 0.90f), rear = lanePoint(playerLane, 1.13f);
        line(frame, a, nose, 0xffe0); line(frame, nose, b, 0xffe0);
        line(frame, b, rear, 0xffe0); line(frame, rear, a, 0xffe0);
    }
    drawPet2001TextTransparent(frame, 208, 8, "TEMPEST", 0x07ff, 2);
    drawPet2001TextTransparent(frame, 218, 35, "SCORE", 0xffff, 1);
    snprintf(text, sizeof(text), "%u", (unsigned)score);
    drawPet2001TextTransparent(frame, 218, 47, text, 0xffff, 1);
    snprintf(text, sizeof(text), "WAVE %u", (unsigned)wave);
    drawPet2001TextTransparent(frame, 218, 67, text, 0xffff, 1);
    snprintf(text, sizeof(text), "LIVES %u", lives);
    drawPet2001TextTransparent(frame, 218, 84, text, 0xffff, 1);
    drawPet2001TextTransparent(frame, 218, 105, zapReady ? "ZAP READY" : "ZAP USED", 0xffe0, 1);
    drawPet2001TextTransparent(frame, 218, 127, "A FIRE E ZAP", 0x7bef, 1);
    drawPet2001TextTransparent(frame, 218, 150, "BOOT BACK", 0x7bef, 1);
    if (gameState == STATE_NEXT_WAVE || gameState == STATE_FINISHED) {
        drawPet2001TextCentredTransparent(frame, 111, 90,
                                          gameState == STATE_FINISHED ? "GAME OVER" : "WAVE CLEAR",
                                          COLOR_RED, 2);
    }
}