Asteroids

Source: src/asteroids.c · 438 lines · 19 functions.

Open the existing readable game overview →

Screen captures

Views of this part running on the Mini Arcade.

Asteroids game screen on the Mini Arcade
Asteroids running on the Mini Arcade display.

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

randomUnit() · source line 47
static float randomUnit(void)
  1. Exit this functionreturn (float)(esp_random() & 0xffffU) / 65536.0f;
Read original function source
static float randomUnit(void)
{
    return (float)(esp_random() & 0xffffU) / 65536.0f;
}
wrap() · source line 52
static float wrap(float value, float extent)
  1. WHILE loopvalue < 0.0f

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

    1. Executevalue += extent;
  2. WHILE loopvalue >= extent

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

    1. Executevalue -= extent;
  3. Exit this functionreturn value;
Read original function source
static float wrap(float value, float extent)
{
    while (value < 0.0f) value += extent;
    while (value >= extent) value -= extent;
    return value;
}
distanceSquared() · source line 59
static float distanceSquared(const Body *a, const Body *b)
  1. Executefloat dx = fabsf(a->x - b->x), dy = fabsf(a->y - b->y);
  2. Decisiondx > LCD_WIDTH / 2.0f
    True
    1. Executedx = LCD_WIDTH - dx;
    False

    Continue after this decision.

  3. Decisiondy > LCD_HEIGHT / 2.0f
    True
    1. Executedy = LCD_HEIGHT - dy;
    False

    Continue after this decision.

  4. Exit this functionreturn dx * dx + dy * dy;
Read original function source
static float distanceSquared(const Body *a, const Body *b)
{
    float dx = fabsf(a->x - b->x), dy = fabsf(a->y - b->y);
    if (dx > LCD_WIDTH / 2.0f) dx = LCD_WIDTH - dx;
    if (dy > LCD_HEIGHT / 2.0f) dy = LCD_HEIGHT - dy;
    return dx * dx + dy * dy;
}
rockRadius() · source line 67
static float rockRadius(uint8_t size)
  1. Exit this functionreturn size == 3 ? 16.0f : size == 2 ? 10.0f : 5.0f;
Read original function source
static float rockRadius(uint8_t size)
{
    return size == 3 ? 16.0f : size == 2 ? 10.0f : 5.0f;
}
moveBody() · source line 72
static void moveBody(Body *body, float seconds)

Calls into this documentation:

  1. Executebody->x = wrap(body->x + body->vx * seconds, LCD_WIDTH);
  2. Executebody->y = wrap(body->y + body->vy * seconds, LCD_HEIGHT);
Read original function source
static void moveBody(Body *body, float seconds)
{
    body->x = wrap(body->x + body->vx * seconds, LCD_WIDTH);
    body->y = wrap(body->y + body->vy * seconds, LCD_HEIGHT);
}
addRock() · source line 78
static bool addRock(float x, float y, uint8_t size, float direction)

Calls into this documentation:

  1. FOR loopunsigned i = 0; i < MAX_ROCKS; ++i

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

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

        Continue after this decision.

      2. Executeconst float speed = 16.0f + (3 - size) * 12.0f + (wave < 12 ? wave : 12) * 2.0f + randomUnit() * 10.0f;
      3. Executerocks[i] = (Rock){ .body = {x, y, cosf(direction) * speed, sinf(direction) * speed}, .angle = randomUnit() * 2.0f * PI, .spin = (randomUnit() - 0.5f) * 1.5f, .size = size, .active = true, };
      4. Exit this functionreturn true;
  2. Exit this functionreturn false;
Read original function source
static bool addRock(float x, float y, uint8_t size, float direction)
{
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        if (rocks[i].active) continue;
        const float speed = 16.0f + (3 - size) * 12.0f +
                            (wave < 12 ? wave : 12) * 2.0f + randomUnit() * 10.0f;
        rocks[i] = (Rock){
            .body = {x, y, cosf(direction) * speed, sinf(direction) * speed},
            .angle = randomUnit() * 2.0f * PI,
            .spin = (randomUnit() - 0.5f) * 1.5f,
            .size = size,
            .active = true,
        };
        return true;
    }
    return false;
}
resetShip() · source line 96
static void resetShip(void)

Calls into this documentation:

  1. Executeship = (Body){LCD_WIDTH / 2.0f, LCD_HEIGHT / 2.0f, 0, 0};
  2. ExecuteshipAngle = -PI / 2.0f;
  3. Executeprotection = PROTECTION_MS;
  4. Executethrusting = false;
  5. ExecuteaudioSetEngineThrust(false);
  6. ExecutecontrolsReady = false;
Read original function source
static void resetShip(void)
{
    ship = (Body){LCD_WIDTH / 2.0f, LCD_HEIGHT / 2.0f, 0, 0};
    shipAngle = -PI / 2.0f;
    protection = PROTECTION_MS;
    thrusting = false;
    audioSetEngineThrust(false);
    controlsReady = false;
}
beginWave() · source line 106
static void beginWave(void)

Calls into this documentation:

  1. Executememset(rocks, 0, sizeof(rocks));
  2. Executememset(bullets, 0, sizeof(bullets));
  3. Executeconst unsigned count = wave < 6 ? wave + 2 : 8;
  4. FOR loopunsigned i = 0; i < count; ++i

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

      1. Executeconst float x = wrap(ship.x + LCD_WIDTH / 2.0f, LCD_WIDTH);
      2. Executeconst float y = randomUnit() * LCD_HEIGHT;
      3. ExecuteaddRock(x, y, 3, randomUnit() * 2.0f * PI);
  5. ExecuteESP_LOGI("asteroids", "WAVE %u", (unsigned)wave);
Read original function source
static void beginWave(void)
{
    memset(rocks, 0, sizeof(rocks));
    memset(bullets, 0, sizeof(bullets));
    const unsigned count = wave < 6 ? wave + 2 : 8;
    for (unsigned i = 0; i < count; ++i) {
        // Spawn on a vertical edge, on the side opposite the ship.
        const float x = wrap(ship.x + LCD_WIDTH / 2.0f, LCD_WIDTH);
        const float y = randomUnit() * LCD_HEIGHT;
        addRock(x, y, 3, randomUnit() * 2.0f * PI);
    }
    ESP_LOGI("asteroids", "WAVE %u", (unsigned)wave);
}
beginGame() · source line 120
static void beginGame(uint32_t now_ms)

Calls into this documentation:

  1. Executescore = 0;
  2. Executewave = 1;
  3. Executelives = 3;
  4. ExecutefireDelay = accumulator = 0;
  5. ExecutelastUpdate = now_ms;
  6. ExecutegameState = STATE_PLAYING;
  7. ExecuteresetShip();
  8. ExecutebeginWave();
  9. ExecuteaudioPlay(AUDIO_SFX_GAME_START);
Read original function source
static void beginGame(uint32_t now_ms)
{
    score = 0;
    wave = 1;
    lives = 3;
    fireDelay = accumulator = 0;
    lastUpdate = now_ms;
    gameState = STATE_PLAYING;
    resetShip();
    beginWave();
    audioPlay(AUDIO_SFX_GAME_START);
}
hitRock() · source line 133
static void hitRock(unsigned index)

Calls into this documentation:

  1. Executeconst Rock rock = rocks[index];
  2. Executerocks[index].active = false;
  3. Executeconst uint32_t points = rock.size == 3 ? 20U : rock.size == 2 ? 50U : 100U;
  4. Executescore = score > UINT32_MAX - points ? UINT32_MAX : score + points;
  5. Decisionrock.size > 1
    True
      1. Executeconst float direction = atan2f(rock.body.vy, rock.body.vx);
      2. ExecuteaddRock(rock.body.x, rock.body.y, rock.size - 1, direction - 0.65f);
      3. ExecuteaddRock(rock.body.x, rock.body.y, rock.size - 1, direction + 0.65f);
    False

    Continue after this decision.

  6. ExecuteaudioPlay(AUDIO_SFX_HIT);
Read original function source
static void hitRock(unsigned index)
{
    const Rock rock = rocks[index];
    rocks[index].active = false;
    const uint32_t points = rock.size == 3 ? 20U : rock.size == 2 ? 50U : 100U;
    score = score > UINT32_MAX - points ? UINT32_MAX : score + points;
    if (rock.size > 1) {
        const float direction = atan2f(rock.body.vy, rock.body.vx);
        addRock(rock.body.x, rock.body.y, rock.size - 1, direction - 0.65f);
        addRock(rock.body.x, rock.body.y, rock.size - 1, direction + 0.65f);
    }
    audioPlay(AUDIO_SFX_HIT);
}
fireBullet() · source line 147
static void fireBullet(void)

Calls into this documentation:

  1. DecisionfireDelay != 0
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

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

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

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

        Continue after this decision.

      2. Executeconst float dx = cosf(shipAngle), dy = sinf(shipAngle);
      3. Executebullets[i] = (Bullet){ .body = {wrap(ship.x + dx * 9.0f, LCD_WIDTH), wrap(ship.y + dy * 9.0f, LCD_HEIGHT), ship.vx + dx * 180.0f, ship.vy + dy * 180.0f}, .remaining = 900U, };
      4. ExecutefireDelay = FIRE_DELAY_MS;
      5. ExecuteaudioPlay(AUDIO_SFX_PLAYER_SHOT);
      6. Exit this functionreturn;
Read original function source
static void fireBullet(void)
{
    if (fireDelay != 0) return;
    for (unsigned i = 0; i < MAX_BULLETS; ++i) {
        if (bullets[i].remaining != 0) continue;
        const float dx = cosf(shipAngle), dy = sinf(shipAngle);
        bullets[i] = (Bullet){
            .body = {wrap(ship.x + dx * 9.0f, LCD_WIDTH),
                     wrap(ship.y + dy * 9.0f, LCD_HEIGHT),
                     ship.vx + dx * 180.0f, ship.vy + dy * 180.0f},
            .remaining = 900U,
        };
        fireDelay = FIRE_DELAY_MS;
        audioPlay(AUDIO_SFX_PLAYER_SHOT);
        return;
    }
}
autoControls() · source line 165
static void autoControls(int *rotation, bool *thrust, bool *fire)

Calls into this documentation:

  1. Executeconst Rock *target = NULL;
  2. Executefloat nearestDistanceSquared = 0.0f;
  3. FOR loopunsigned i = 0; i < MAX_ROCKS; ++i

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

      1. Decision!rocks[i].active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      2. Executeconst float distanceSquaredToRock = distanceSquared(&ship, &rocks[i].body);
      3. Decisiontarget == NULL || distanceSquaredToRock < nearestDistanceSquared
        True
          1. Executetarget = &rocks[i];
          2. ExecutenearestDistanceSquared = distanceSquaredToRock;
        False

        Continue after this decision.

  4. Decisiontarget == NULL
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  5. Executefloat dx = target->body.x - ship.x;
  6. Executefloat dy = target->body.y - ship.y;
  7. Decisiondx > LCD_WIDTH / 2.0f
    True
    1. Executedx -= LCD_WIDTH;
    False

    Continue after this decision.

  8. Decisiondx < -LCD_WIDTH / 2.0f
    True
    1. Executedx += LCD_WIDTH;
    False

    Continue after this decision.

  9. Decisiondy > LCD_HEIGHT / 2.0f
    True
    1. Executedy -= LCD_HEIGHT;
    False

    Continue after this decision.

  10. Decisiondy < -LCD_HEIGHT / 2.0f
    True
    1. Executedy += LCD_HEIGHT;
    False

    Continue after this decision.

  11. Executeconst float dangerDistance = rockRadius(target->size) + 34.0f;
  12. Executeconst bool danger = nearestDistanceSquared < dangerDistance * dangerDistance;
  13. Executeconst float forwardX = cosf(shipAngle);
  14. Executeconst float forwardY = sinf(shipAngle);
  15. Executeconst float cross = forwardX * dy - forwardY * dx;
  16. Executeconst float dot = forwardX * dx + forwardY * dy;
  17. Executeconst float alignmentLimitSquared = nearestDistanceSquared * 0.0676f;
  18. Decisiondanger
    True
      1. Execute*rotation = cross > 0.0f ? -1 : cross < 0.0f ? 1 : 1;
      2. Execute*fire = false;
      3. Execute*thrust = true;
      4. Exit this functionreturn;
    False

    Continue after this decision.

  19. Decisioncross * cross <= alignmentLimitSquared && dot > 0.0f
    True
    1. Execute*rotation = 0;
    False
    1. Decisioncross > 0.0f
      True
      1. Execute*rotation = 1;
      False
      1. Decisioncross < 0.0f
        True
        1. Execute*rotation = -1;
        False
        1. Execute*rotation = 1;
  20. Execute*fire = dot > 0.0f && cross * cross <= alignmentLimitSquared;
  21. Execute*thrust = nearestDistanceSquared > 75.0f * 75.0f && *rotation == 0;
Read original function source
static void autoControls(int *rotation, bool *thrust, bool *fire)
{
    const Rock *target = NULL;
    float nearestDistanceSquared = 0.0f;
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        if (!rocks[i].active) continue;
        const float distanceSquaredToRock = distanceSquared(&ship, &rocks[i].body);
        if (target == NULL || distanceSquaredToRock < nearestDistanceSquared) {
            target = &rocks[i];
            nearestDistanceSquared = distanceSquaredToRock;
        }
    }
    if (target == NULL) return;

    float dx = target->body.x - ship.x;
    float dy = target->body.y - ship.y;
    if (dx > LCD_WIDTH / 2.0f) dx -= LCD_WIDTH;
    if (dx < -LCD_WIDTH / 2.0f) dx += LCD_WIDTH;
    if (dy > LCD_HEIGHT / 2.0f) dy -= LCD_HEIGHT;
    if (dy < -LCD_HEIGHT / 2.0f) dy += LCD_HEIGHT;

    const float dangerDistance = rockRadius(target->size) + 34.0f;
    const bool danger = nearestDistanceSquared < dangerDistance * dangerDistance;
    const float forwardX = cosf(shipAngle);
    const float forwardY = sinf(shipAngle);
    const float cross = forwardX * dy - forwardY * dx;
    const float dot = forwardX * dx + forwardY * dy;
    const float alignmentLimitSquared = nearestDistanceSquared * 0.0676f;

    // The cross product picks the shorter turn without converting position to an angle.
    if (danger) {
        *rotation = cross > 0.0f ? -1 : cross < 0.0f ? 1 : 1;
        *fire = false;
        *thrust = true;
        return;
    }
    if (cross * cross <= alignmentLimitSquared && dot > 0.0f) *rotation = 0;
    else if (cross > 0.0f) *rotation = 1;
    else if (cross < 0.0f) *rotation = -1;
    else *rotation = 1;
    *fire = dot > 0.0f && cross * cross <= alignmentLimitSquared;
    *thrust = nearestDistanceSquared > 75.0f * 75.0f && *rotation == 0;
}
simulateStep() · source line 209
static void simulateStep(int rotation, bool thrust, bool fire)

Calls into this documentation:

  1. Executeconst float seconds = STEP_MS / 1000.0f;
  2. Decisionprotection > 0
    True
    1. Executeprotection = protection > STEP_MS ? protection - STEP_MS : 0;
    False

    Continue after this decision.

  3. DecisionfireDelay > 0
    True
    1. ExecutefireDelay = fireDelay > STEP_MS ? fireDelay - STEP_MS : 0;
    False

    Continue after this decision.

  4. ExecuteshipAngle = wrap(shipAngle + rotation * 3.4f * seconds, 2.0f * PI);
  5. Decisionthrusting != thrust
    True
      1. Executethrusting = thrust;
      2. ExecuteaudioSetEngineThrust(thrusting);
    False

    Continue after this decision.

  6. Decisionthrust
    True
      1. Executeship.vx += cosf(shipAngle) * 95.0f * seconds;
      2. Executeship.vy += sinf(shipAngle) * 95.0f * seconds;
      3. Executeconst float speed = sqrtf(ship.vx * ship.vx + ship.vy * ship.vy);
      4. Decisionspeed > 100.0f
        True
          1. Executeship.vx *= 100.0f / speed;
          2. Executeship.vy *= 100.0f / speed;
        False

        Continue after this decision.

    False

    Continue after this decision.

  7. ExecutemoveBody(&ship, seconds);
  8. Decisionfire
    True
    1. ExecutefireBullet();
    False

    Continue after this decision.

  9. FOR loopunsigned i = 0; i < MAX_ROCKS; ++i

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

      1. Decision!rocks[i].active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      2. ExecutemoveBody(&rocks[i].body, seconds);
      3. Executerocks[i].angle = wrap(rocks[i].angle + rocks[i].spin * seconds, 2.0f * PI);
  10. FOR loopunsigned i = 0; i < MAX_BULLETS; ++i

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

      1. ExecuteBullet *bullet = &bullets[i];
      2. Decisionbullet->remaining == 0
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      3. ExecutemoveBody(&bullet->body, seconds);
      4. Executebullet->remaining = bullet->remaining > STEP_MS ? bullet->remaining - STEP_MS : 0;
      5. FOR loopunsigned j = 0; j < MAX_ROCKS; ++j

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

          1. Decision!rocks[j].active
            True
            1. Next loop iterationcontinue;
            False

            Continue after this decision.

          2. Executeconst float radius = rockRadius(rocks[j].size) + 1.0f;
          3. DecisiondistanceSquared(&bullet->body, &rocks[j].body) <= radius * radius
            True
              1. Executebullet->remaining = 0;
              2. ExecutehitRock(j);
              3. Exit enclosing loop or switchbreak;
            False

            Continue after this decision.

  11. Decisionprotection == 0
    True
      1. FOR loopunsigned i = 0; i < MAX_ROCKS; ++i

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

          1. Decision!rocks[i].active
            True
            1. Next loop iterationcontinue;
            False

            Continue after this decision.

          2. Executeconst float radius = rockRadius(rocks[i].size) + SHIP_RADIUS;
          3. DecisiondistanceSquared(&ship, &rocks[i].body) > radius * radius
            True
            1. Next loop iterationcontinue;
            False

            Continue after this decision.

          4. Execute--lives;
          5. ExecuteESP_LOGI("asteroids", "LIVES %u; SCORE %u", lives, (unsigned)score);
          6. ExecuteaudioPlay(AUDIO_SFX_PLAYER_EXPLOSION);
          7. Decisionlives == 0
            True
              1. ExecutegameState = STATE_FINISHED;
              2. ExecutegameOverStarted = lastUpdate;
              3. Executethrusting = false;
              4. ExecuteaudioSetEngineThrust(false);
              5. Executeconst uint32_t gold = score / 1000U;
              6. Decisiongold != 0
                True
                  1. Decision(autoMode ? addAIGold(gold) : addUserGoldWon(gold))
                    True
                      1. ExecuteESP_LOGI("asteroids", "GOLD WON: %u", (unsigned)gold);
                    False
                      1. ExecuteESP_LOGE("asteroids", "Failed to save %u Gold", (unsigned)gold);
                False

                Continue after this decision.

            False
              1. ExecuteresetShip();
              2. Executememset(bullets, 0, sizeof(bullets));
          8. Exit this functionreturn;
    False

    Continue after this decision.

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

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

      1. Decisionrocks[i].active
        True
        1. Exit this functionreturn;
        False

        Continue after this decision.

  13. ExecuteaudioPlay(AUDIO_SFX_WAVE_CLEAR);
  14. Decisionwave < UINT32_MAX
    True
    1. Execute++wave;
    False

    Continue after this decision.

  15. ExecutebeginWave();
Read original function source
static void simulateStep(int rotation, bool thrust, bool fire)
{
    const float seconds = STEP_MS / 1000.0f;
    if (protection > 0) protection = protection > STEP_MS ? protection - STEP_MS : 0;
    if (fireDelay > 0) fireDelay = fireDelay > STEP_MS ? fireDelay - STEP_MS : 0;
    shipAngle = wrap(shipAngle + rotation * 3.4f * seconds, 2.0f * PI);
    if (thrusting != thrust) {
        thrusting = thrust;
        audioSetEngineThrust(thrusting);
    }
    if (thrust) {
        ship.vx += cosf(shipAngle) * 95.0f * seconds;
        ship.vy += sinf(shipAngle) * 95.0f * seconds;
        const float speed = sqrtf(ship.vx * ship.vx + ship.vy * ship.vy);
        if (speed > 100.0f) { ship.vx *= 100.0f / speed; ship.vy *= 100.0f / speed; }
    }
    moveBody(&ship, seconds);
    if (fire) fireBullet();
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        if (!rocks[i].active) continue;
        moveBody(&rocks[i].body, seconds);
        rocks[i].angle = wrap(rocks[i].angle + rocks[i].spin * seconds, 2.0f * PI);
    }
    for (unsigned i = 0; i < MAX_BULLETS; ++i) {
        Bullet *bullet = &bullets[i];
        if (bullet->remaining == 0) continue;
        moveBody(&bullet->body, seconds);
        bullet->remaining = bullet->remaining > STEP_MS ? bullet->remaining - STEP_MS : 0;
        for (unsigned j = 0; j < MAX_ROCKS; ++j) {
            if (!rocks[j].active) continue;
            const float radius = rockRadius(rocks[j].size) + 1.0f;
            if (distanceSquared(&bullet->body, &rocks[j].body) <= radius * radius) {
                bullet->remaining = 0;
                hitRock(j);
                break;
            }
        }
    }
    if (protection == 0) {
        for (unsigned i = 0; i < MAX_ROCKS; ++i) {
            if (!rocks[i].active) continue;
            const float radius = rockRadius(rocks[i].size) + SHIP_RADIUS;
            if (distanceSquared(&ship, &rocks[i].body) > radius * radius) continue;
            --lives;
            ESP_LOGI("asteroids", "LIVES %u; SCORE %u", lives, (unsigned)score);
            audioPlay(AUDIO_SFX_PLAYER_EXPLOSION);
            if (lives == 0) {
                gameState = STATE_FINISHED;
                gameOverStarted = lastUpdate;
                thrusting = false;
                audioSetEngineThrust(false);
                // 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("asteroids", "GOLD WON: %u", (unsigned)gold);
                    } else {
                        ESP_LOGE("asteroids", "Failed to save %u Gold", (unsigned)gold);
                    }
                }
            } else {
                resetShip();
                memset(bullets, 0, sizeof(bullets));
            }
            return;
        }
    }
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        if (rocks[i].active) return;
    }
    audioPlay(AUDIO_SFX_WAVE_CLEAR);
    if (wave < UINT32_MAX) ++wave;
    beginWave();
}
startAsteroids() · source line 284
void startAsteroids(uint32_t now_ms)

Calls into this documentation:

  1. Executerunning = true;
  2. ExecutegameState = STATE_READY;
  3. ExecutelastUpdate = now_ms;
  4. Executeaccumulator = score = 0;
  5. Executethrusting = false;
  6. ExecuteaudioSetEngineThrust(false);
  7. ExecuteautoMode = false;
  8. ExecutemodeSelectionReset(&modeInput);
  9. ExecuteESP_LOGI("asteroids", "START");
Read original function source
void startAsteroids(uint32_t now_ms)
{
    running = true;
    gameState = STATE_READY;
    lastUpdate = now_ms;
    accumulator = score = 0;
    thrusting = false;
    audioSetEngineThrust(false);
    autoMode = false;
    modeSelectionReset(&modeInput);
    ESP_LOGI("asteroids", "START");
}
stopAsteroids() · source line 297
void stopAsteroids(void)

Calls into this documentation:

  1. ExecutecancelHighScoreEntry();
  2. ExecuteaudioSetEngineThrust(false);
  3. Executerunning = false;
  4. ExecuteESP_LOGI("asteroids", "STOP");
Read original function source
void stopAsteroids(void)
{
    cancelHighScoreEntry();
    audioSetEngineThrust(false);
    running = false;
    ESP_LOGI("asteroids", "STOP");
}
asteroidsAction() · source line 305
void asteroidsAction(bool start, bool stop_auto, uint32_t now_ms)

Calls into this documentation:

  1. Decisionrunning && start && gameState == STATE_READY
    True
      1. ExecuteESP_LOGI("asteroids", "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. Executethrusting = false;
        4. ExecuteaudioSetEngineThrust(false);
        5. ExecutegameState = STATE_READY;
      False

      Continue after this decision.

Read original function source
void asteroidsAction(bool start, bool stop_auto, uint32_t now_ms)
{
    if (running && start && gameState == STATE_READY) {
        ESP_LOGI("asteroids", "START: %s", autoMode ? "AUTO" : "PLAY");
        beginGame(now_ms);
    }
    else if (running && stop_auto && autoMode && gameState == STATE_PLAYING) {
        autoMode = false;
        modeSelectionReset(&modeInput);
        thrusting = false;
        audioSetEngineThrust(false);
        gameState = STATE_READY;
    }
}
updateAsteroids() · source line 320
void updateAsteroids(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. DecisionautoMode
        True
          1. ExecutebeginGame(now_ms);
        False
          1. ExecutestartHighScoreEntry(HIGH_SCORE_ASTEROIDS, 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_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.

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

    Continue after this decision.

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

  9. Executeint rotation = left || right ? (int)right - (int)left : valid && controlsReady ? (x_offset > 600 ? 1 : x_offset < -600 ? -1 : 0) : 0;
  10. Executebool thrust = valid && controlsReady && y_offset > 600;
  11. Executebool shouldFire = fire;
  12. DecisionautoMode
    True
    1. ExecuteautoControls(&rotation, &thrust, &shouldFire);
    False

    Continue after this decision.

  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 ? rotation : (left || right || controlsReady) ? rotation : 0, autoMode ? thrust : controlsReady && thrust, shouldFire);
Read original function source
void updateAsteroids(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) {
        if (autoMode) {
            beginGame(now_ms);
        } else {
            startHighScoreEntry(HIGH_SCORE_ASTEROIDS, 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;
    int rotation = left || right ? (int)right - (int)left
        : valid && controlsReady ? (x_offset > 600 ? 1 : x_offset < -600 ? -1 : 0) : 0;
    bool thrust = valid && controlsReady && y_offset > 600;
    bool shouldFire = fire;
    if (autoMode) autoControls(&rotation, &thrust, &shouldFire);
    // Bound catch-up after a stall; collision checks still run every 10 ms.
    accumulator += elapsed > 100U ? 100U : elapsed;
    while (accumulator >= STEP_MS && gameState == STATE_PLAYING) {
        accumulator -= STEP_MS;
        simulateStep(autoMode ? rotation : (left || right || controlsReady) ? rotation : 0,
                     autoMode ? thrust : controlsReady && thrust, shouldFire);
    }
}
drawWrappedLine() · source line 362
static void drawWrappedLine(uint16_t *frame, float x0, float y0, float x1, float y1)

Calls into this documentation:

  1. FOR loopint dy = -LCD_HEIGHT; dy <= LCD_HEIGHT; dy += LCD_HEIGHT

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

      1. FOR loopint dx = -LCD_WIDTH; dx <= LCD_WIDTH; dx += LCD_WIDTH

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

          1. Executeconst int ax = (int)lroundf(x0) + dx, ay = (int)lroundf(y0) + dy;
          2. Executeconst int bx = (int)lroundf(x1) + dx, by = (int)lroundf(y1) + dy;
          3. Decision(ax < 0 && bx < 0) || (ax >= LCD_WIDTH && bx >= LCD_WIDTH) || (ay < 0 && by < 0) || (ay >= LCD_HEIGHT && by >= LCD_HEIGHT)
            True
            1. Next loop iterationcontinue;
            False

            Continue after this decision.

          4. Executedraw_line(frame, ax, ay, bx, by, 0xffff);
Read original function source
static void drawWrappedLine(uint16_t *frame, float x0, float y0, float x1, float y1)
{
    for (int dy = -LCD_HEIGHT; dy <= LCD_HEIGHT; dy += LCD_HEIGHT) {
        for (int dx = -LCD_WIDTH; dx <= LCD_WIDTH; dx += LCD_WIDTH) {
            const int ax = (int)lroundf(x0) + dx, ay = (int)lroundf(y0) + dy;
            const int bx = (int)lroundf(x1) + dx, by = (int)lroundf(y1) + dy;
            if ((ax < 0 && bx < 0) || (ax >= LCD_WIDTH && bx >= LCD_WIDTH) ||
                (ay < 0 && by < 0) || (ay >= LCD_HEIGHT && by >= LCD_HEIGHT)) continue;
            draw_line(frame, ax, ay, bx, by, 0xffff);
        }
    }
}
drawAsteroids() · source line 375
void drawAsteroids(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 line[64];
  5. DecisiongameState == STATE_READY
    True
      1. Executedraw_text_centred(frame, 160, 16, "ASTEROIDS", 3, 0xffff);
      2. Executedraw_text_centred(frame, 83, 51, "PLAY", 2, autoMode ? COLOR_LIGHTGRAY : COLOR_YELLOW);
      3. Executedraw_text_centred(frame, 237, 51, "AUTO", 2, autoMode ? COLOR_YELLOW : COLOR_LIGHTGRAY);
      4. Executedraw_text_centred(frame, 160, 88, "D/B/STICK CHANGE A/K START", 1, 0xffff);
      5. Executedraw_text_centred(frame, 160, 106, "D/B/STICK LEFT/RIGHT ROTATE", 1, 0xffff);
      6. Executedraw_text_centred(frame, 160, 124, "STICK UP THRUST HOLD A FIRE", 1, 0xffff);
      7. Executedraw_text_centred(frame, 160, 153, "BOOT BACK", 1, 0x7bef);
      8. Exit this functionreturn;
    False

    Continue after this decision.

  6. FOR loopunsigned i = 0; i < MAX_ROCKS; ++i

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

      1. Executeconst Rock *rock = &rocks[i];
      2. Decision!rock->active
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      3. Executestatic const float shape[8] = {1.0f, 0.78f, 0.95f, 0.72f, 1.0f, 0.83f, 0.92f, 0.75f};
      4. FOR loopunsigned j = 0; j < 8; ++j

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

          1. Executeconst unsigned next = (j + 1) % 8;
          2. Executeconst float a = rock->angle + j * PI / 4.0f, b = rock->angle + next * PI / 4.0f;
          3. Executeconst float radius = rockRadius(rock->size);
          4. ExecutedrawWrappedLine(frame, rock->body.x + cosf(a) * radius * shape[j], rock->body.y + sinf(a) * radius * shape[j], rock->body.x + cosf(b) * radius * shape[next], rock->body.y + sinf(b) * radius * shape[next]);
  7. FOR loopunsigned i = 0; i < MAX_BULLETS; ++i

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

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

        Continue after this decision.

      2. Executeconst Body *b = &bullets[i].body;
      3. ExecutedrawWrappedLine(frame, b->x, b->y, b->x - b->vx * 0.012f, b->y - b->vy * 0.012f);
  8. DecisiongameState == STATE_PLAYING && (protection == 0 || (protection / 125U) % 2 == 0)
    True
      1. Executeconst float dx = cosf(shipAngle), dy = sinf(shipAngle);
      2. Executeconst float noseX = ship.x + dx * 8, noseY = ship.y + dy * 8;
      3. Executeconst float leftX = ship.x - dx * 5 - dy * 5, leftY = ship.y - dy * 5 + dx * 5;
      4. Executeconst float rightX = ship.x - dx * 5 + dy * 5, rightY = ship.y - dy * 5 - dx * 5;
      5. ExecutedrawWrappedLine(frame, noseX, noseY, leftX, leftY);
      6. ExecutedrawWrappedLine(frame, leftX, leftY, rightX, rightY);
      7. ExecutedrawWrappedLine(frame, rightX, rightY, noseX, noseY);
      8. Decisionthrusting
        True
          1. ExecutedrawWrappedLine(frame, leftX, leftY, ship.x - dx * 12, ship.y - dy * 12);
          2. ExecutedrawWrappedLine(frame, rightX, rightY, ship.x - dx * 12, ship.y - dy * 12);
        False

        Continue after this decision.

    False

    Continue after this decision.

  9. Executefill_rect(frame, 0, 0, LCD_WIDTH, 12, 0);
  10. Executesnprintf(line, sizeof(line), "SCORE %u", (unsigned)score);
  11. Executedraw_text(frame, 3, 2, line, 1, 0xffff);
  12. Executesnprintf(line, sizeof(line), "WAVE %u", (unsigned)wave);
  13. Executedraw_text(frame, 166, 2, line, 1, 0xffff);
  14. Executesnprintf(line, sizeof(line), "LIVES %u", lives);
  15. Executedraw_text(frame, 272, 2, line, 1, 0xffff);
  16. DecisionautoMode
    True
    1. Executedraw_text_centred(frame, 160, 14, "AUTO", 1, COLOR_YELLOW);
    False

    Continue after this decision.

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

    Continue after this decision.

Read original function source
void drawAsteroids(uint16_t *frame)
{
    if (!running) return;
    if (gameState == STATE_SCORES) {
        drawHighScoreEntry(frame);
        return;
    }
    clear(frame, 0);
    char line[64];
    if (gameState == STATE_READY) {
        draw_text_centred(frame, 160, 16, "ASTEROIDS", 3, 0xffff);
        draw_text_centred(frame, 83, 51, "PLAY", 2, autoMode ? COLOR_LIGHTGRAY : COLOR_YELLOW);
        draw_text_centred(frame, 237, 51, "AUTO", 2, autoMode ? COLOR_YELLOW : COLOR_LIGHTGRAY);
        draw_text_centred(frame, 160, 88, "D/B/STICK CHANGE  A/K START", 1, 0xffff);
        draw_text_centred(frame, 160, 106, "D/B/STICK LEFT/RIGHT ROTATE", 1, 0xffff);
        draw_text_centred(frame, 160, 124, "STICK UP THRUST  HOLD A FIRE", 1, 0xffff);
        draw_text_centred(frame, 160, 153, "BOOT BACK", 1, 0x7bef);
        return;
    }
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        const Rock *rock = &rocks[i];
        if (!rock->active) continue;
        static const float shape[8] = {1.0f, 0.78f, 0.95f, 0.72f, 1.0f, 0.83f, 0.92f, 0.75f};
        for (unsigned j = 0; j < 8; ++j) {
            const unsigned next = (j + 1) % 8;
            const float a = rock->angle + j * PI / 4.0f, b = rock->angle + next * PI / 4.0f;
            const float radius = rockRadius(rock->size);
            drawWrappedLine(frame, rock->body.x + cosf(a) * radius * shape[j],
                            rock->body.y + sinf(a) * radius * shape[j],
                            rock->body.x + cosf(b) * radius * shape[next],
                            rock->body.y + sinf(b) * radius * shape[next]);
        }
    }
    for (unsigned i = 0; i < MAX_BULLETS; ++i) {
        if (bullets[i].remaining == 0) continue;
        const Body *b = &bullets[i].body;
        drawWrappedLine(frame, b->x, b->y, b->x - b->vx * 0.012f, b->y - b->vy * 0.012f);
    }
    if (gameState == STATE_PLAYING && (protection == 0 || (protection / 125U) % 2 == 0)) {
        const float dx = cosf(shipAngle), dy = sinf(shipAngle);
        const float noseX = ship.x + dx * 8, noseY = ship.y + dy * 8;
        const float leftX = ship.x - dx * 5 - dy * 5, leftY = ship.y - dy * 5 + dx * 5;
        const float rightX = ship.x - dx * 5 + dy * 5, rightY = ship.y - dy * 5 - dx * 5;
        drawWrappedLine(frame, noseX, noseY, leftX, leftY);
        drawWrappedLine(frame, leftX, leftY, rightX, rightY);
        drawWrappedLine(frame, rightX, rightY, noseX, noseY);
        if (thrusting) {
            drawWrappedLine(frame, leftX, leftY, ship.x - dx * 12, ship.y - dy * 12);
            drawWrappedLine(frame, rightX, rightY, ship.x - dx * 12, ship.y - dy * 12);
        }
    }
    fill_rect(frame, 0, 0, LCD_WIDTH, 12, 0);
    snprintf(line, sizeof(line), "SCORE %u", (unsigned)score);
    draw_text(frame, 3, 2, line, 1, 0xffff);
    snprintf(line, sizeof(line), "WAVE %u", (unsigned)wave);
    draw_text(frame, 166, 2, line, 1, 0xffff);
    snprintf(line, sizeof(line), "LIVES %u", lives);
    draw_text(frame, 272, 2, line, 1, 0xffff);
    if (autoMode) draw_text_centred(frame, 160, 14, "AUTO", 1, COLOR_YELLOW);
    if (gameState == STATE_FINISHED) {
        draw_text_centred(frame, 160, 78, "GAME OVER", 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 "asteroids.h"
#include "audio.h"
#include "game_graphics.h"
#include "PET2001_font.h"
#include "gold.h"
#include "high_scores.h"
#include "mode_selection.h"
#include "esp_random.h"
#include "esp_log.h"
#include <math.h>
#include <stdio.h>
#include <string.h>

#define PI 3.14159265f
#define MAX_ROCKS 32
#define MAX_BULLETS 8
#define STEP_MS 10U
#define FIRE_DELAY_MS 180U
#define PROTECTION_MS 2500U
#define SHIP_RADIUS 5.0f

typedef enum { STATE_READY, STATE_PLAYING, STATE_FINISHED, STATE_SCORES } GameState;
typedef struct { float x, y, vx, vy; } Body;
typedef struct {
    Body body;
    float angle, spin;
    uint8_t size;
    bool active;
} Rock;
typedef struct {
    Body body;
    uint32_t remaining;
} Bullet;

// Eight large rocks can split into at most 32 small rocks.
// All objects reuse fixed storage and the caller's display buffer.
static Rock rocks[MAX_ROCKS];
static Bullet bullets[MAX_BULLETS];
static Body ship;
static GameState gameState;
static float shipAngle;
static uint32_t score, wave, lastUpdate, accumulator, protection, fireDelay;
static uint32_t gameOverStarted;
static uint8_t lives;
static bool running, thrusting, controlsReady, autoMode;
static mode_selection_input_t modeInput;

static float randomUnit(void)
{
    return (float)(esp_random() & 0xffffU) / 65536.0f;
}

static float wrap(float value, float extent)
{
    while (value < 0.0f) value += extent;
    while (value >= extent) value -= extent;
    return value;
}

static float distanceSquared(const Body *a, const Body *b)
{
    float dx = fabsf(a->x - b->x), dy = fabsf(a->y - b->y);
    if (dx > LCD_WIDTH / 2.0f) dx = LCD_WIDTH - dx;
    if (dy > LCD_HEIGHT / 2.0f) dy = LCD_HEIGHT - dy;
    return dx * dx + dy * dy;
}

static float rockRadius(uint8_t size)
{
    return size == 3 ? 16.0f : size == 2 ? 10.0f : 5.0f;
}

static void moveBody(Body *body, float seconds)
{
    body->x = wrap(body->x + body->vx * seconds, LCD_WIDTH);
    body->y = wrap(body->y + body->vy * seconds, LCD_HEIGHT);
}

static bool addRock(float x, float y, uint8_t size, float direction)
{
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        if (rocks[i].active) continue;
        const float speed = 16.0f + (3 - size) * 12.0f +
                            (wave < 12 ? wave : 12) * 2.0f + randomUnit() * 10.0f;
        rocks[i] = (Rock){
            .body = {x, y, cosf(direction) * speed, sinf(direction) * speed},
            .angle = randomUnit() * 2.0f * PI,
            .spin = (randomUnit() - 0.5f) * 1.5f,
            .size = size,
            .active = true,
        };
        return true;
    }
    return false;
}

static void resetShip(void)
{
    ship = (Body){LCD_WIDTH / 2.0f, LCD_HEIGHT / 2.0f, 0, 0};
    shipAngle = -PI / 2.0f;
    protection = PROTECTION_MS;
    thrusting = false;
    audioSetEngineThrust(false);
    controlsReady = false;
}

static void beginWave(void)
{
    memset(rocks, 0, sizeof(rocks));
    memset(bullets, 0, sizeof(bullets));
    const unsigned count = wave < 6 ? wave + 2 : 8;
    for (unsigned i = 0; i < count; ++i) {
        // Spawn on a vertical edge, on the side opposite the ship.
        const float x = wrap(ship.x + LCD_WIDTH / 2.0f, LCD_WIDTH);
        const float y = randomUnit() * LCD_HEIGHT;
        addRock(x, y, 3, randomUnit() * 2.0f * PI);
    }
    ESP_LOGI("asteroids", "WAVE %u", (unsigned)wave);
}

static void beginGame(uint32_t now_ms)
{
    score = 0;
    wave = 1;
    lives = 3;
    fireDelay = accumulator = 0;
    lastUpdate = now_ms;
    gameState = STATE_PLAYING;
    resetShip();
    beginWave();
    audioPlay(AUDIO_SFX_GAME_START);
}

static void hitRock(unsigned index)
{
    const Rock rock = rocks[index];
    rocks[index].active = false;
    const uint32_t points = rock.size == 3 ? 20U : rock.size == 2 ? 50U : 100U;
    score = score > UINT32_MAX - points ? UINT32_MAX : score + points;
    if (rock.size > 1) {
        const float direction = atan2f(rock.body.vy, rock.body.vx);
        addRock(rock.body.x, rock.body.y, rock.size - 1, direction - 0.65f);
        addRock(rock.body.x, rock.body.y, rock.size - 1, direction + 0.65f);
    }
    audioPlay(AUDIO_SFX_HIT);
}

static void fireBullet(void)
{
    if (fireDelay != 0) return;
    for (unsigned i = 0; i < MAX_BULLETS; ++i) {
        if (bullets[i].remaining != 0) continue;
        const float dx = cosf(shipAngle), dy = sinf(shipAngle);
        bullets[i] = (Bullet){
            .body = {wrap(ship.x + dx * 9.0f, LCD_WIDTH),
                     wrap(ship.y + dy * 9.0f, LCD_HEIGHT),
                     ship.vx + dx * 180.0f, ship.vy + dy * 180.0f},
            .remaining = 900U,
        };
        fireDelay = FIRE_DELAY_MS;
        audioPlay(AUDIO_SFX_PLAYER_SHOT);
        return;
    }
}

static void autoControls(int *rotation, bool *thrust, bool *fire)
{
    const Rock *target = NULL;
    float nearestDistanceSquared = 0.0f;
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        if (!rocks[i].active) continue;
        const float distanceSquaredToRock = distanceSquared(&ship, &rocks[i].body);
        if (target == NULL || distanceSquaredToRock < nearestDistanceSquared) {
            target = &rocks[i];
            nearestDistanceSquared = distanceSquaredToRock;
        }
    }
    if (target == NULL) return;

    float dx = target->body.x - ship.x;
    float dy = target->body.y - ship.y;
    if (dx > LCD_WIDTH / 2.0f) dx -= LCD_WIDTH;
    if (dx < -LCD_WIDTH / 2.0f) dx += LCD_WIDTH;
    if (dy > LCD_HEIGHT / 2.0f) dy -= LCD_HEIGHT;
    if (dy < -LCD_HEIGHT / 2.0f) dy += LCD_HEIGHT;

    const float dangerDistance = rockRadius(target->size) + 34.0f;
    const bool danger = nearestDistanceSquared < dangerDistance * dangerDistance;
    const float forwardX = cosf(shipAngle);
    const float forwardY = sinf(shipAngle);
    const float cross = forwardX * dy - forwardY * dx;
    const float dot = forwardX * dx + forwardY * dy;
    const float alignmentLimitSquared = nearestDistanceSquared * 0.0676f;

    // The cross product picks the shorter turn without converting position to an angle.
    if (danger) {
        *rotation = cross > 0.0f ? -1 : cross < 0.0f ? 1 : 1;
        *fire = false;
        *thrust = true;
        return;
    }
    if (cross * cross <= alignmentLimitSquared && dot > 0.0f) *rotation = 0;
    else if (cross > 0.0f) *rotation = 1;
    else if (cross < 0.0f) *rotation = -1;
    else *rotation = 1;
    *fire = dot > 0.0f && cross * cross <= alignmentLimitSquared;
    *thrust = nearestDistanceSquared > 75.0f * 75.0f && *rotation == 0;
}

static void simulateStep(int rotation, bool thrust, bool fire)
{
    const float seconds = STEP_MS / 1000.0f;
    if (protection > 0) protection = protection > STEP_MS ? protection - STEP_MS : 0;
    if (fireDelay > 0) fireDelay = fireDelay > STEP_MS ? fireDelay - STEP_MS : 0;
    shipAngle = wrap(shipAngle + rotation * 3.4f * seconds, 2.0f * PI);
    if (thrusting != thrust) {
        thrusting = thrust;
        audioSetEngineThrust(thrusting);
    }
    if (thrust) {
        ship.vx += cosf(shipAngle) * 95.0f * seconds;
        ship.vy += sinf(shipAngle) * 95.0f * seconds;
        const float speed = sqrtf(ship.vx * ship.vx + ship.vy * ship.vy);
        if (speed > 100.0f) { ship.vx *= 100.0f / speed; ship.vy *= 100.0f / speed; }
    }
    moveBody(&ship, seconds);
    if (fire) fireBullet();
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        if (!rocks[i].active) continue;
        moveBody(&rocks[i].body, seconds);
        rocks[i].angle = wrap(rocks[i].angle + rocks[i].spin * seconds, 2.0f * PI);
    }
    for (unsigned i = 0; i < MAX_BULLETS; ++i) {
        Bullet *bullet = &bullets[i];
        if (bullet->remaining == 0) continue;
        moveBody(&bullet->body, seconds);
        bullet->remaining = bullet->remaining > STEP_MS ? bullet->remaining - STEP_MS : 0;
        for (unsigned j = 0; j < MAX_ROCKS; ++j) {
            if (!rocks[j].active) continue;
            const float radius = rockRadius(rocks[j].size) + 1.0f;
            if (distanceSquared(&bullet->body, &rocks[j].body) <= radius * radius) {
                bullet->remaining = 0;
                hitRock(j);
                break;
            }
        }
    }
    if (protection == 0) {
        for (unsigned i = 0; i < MAX_ROCKS; ++i) {
            if (!rocks[i].active) continue;
            const float radius = rockRadius(rocks[i].size) + SHIP_RADIUS;
            if (distanceSquared(&ship, &rocks[i].body) > radius * radius) continue;
            --lives;
            ESP_LOGI("asteroids", "LIVES %u; SCORE %u", lives, (unsigned)score);
            audioPlay(AUDIO_SFX_PLAYER_EXPLOSION);
            if (lives == 0) {
                gameState = STATE_FINISHED;
                gameOverStarted = lastUpdate;
                thrusting = false;
                audioSetEngineThrust(false);
                // 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("asteroids", "GOLD WON: %u", (unsigned)gold);
                    } else {
                        ESP_LOGE("asteroids", "Failed to save %u Gold", (unsigned)gold);
                    }
                }
            } else {
                resetShip();
                memset(bullets, 0, sizeof(bullets));
            }
            return;
        }
    }
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        if (rocks[i].active) return;
    }
    audioPlay(AUDIO_SFX_WAVE_CLEAR);
    if (wave < UINT32_MAX) ++wave;
    beginWave();
}

void startAsteroids(uint32_t now_ms)
{
    running = true;
    gameState = STATE_READY;
    lastUpdate = now_ms;
    accumulator = score = 0;
    thrusting = false;
    audioSetEngineThrust(false);
    autoMode = false;
    modeSelectionReset(&modeInput);
    ESP_LOGI("asteroids", "START");
}

void stopAsteroids(void)
{
    cancelHighScoreEntry();
    audioSetEngineThrust(false);
    running = false;
    ESP_LOGI("asteroids", "STOP");
}

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

void updateAsteroids(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) {
        if (autoMode) {
            beginGame(now_ms);
        } else {
            startHighScoreEntry(HIGH_SCORE_ASTEROIDS, 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;
    int rotation = left || right ? (int)right - (int)left
        : valid && controlsReady ? (x_offset > 600 ? 1 : x_offset < -600 ? -1 : 0) : 0;
    bool thrust = valid && controlsReady && y_offset > 600;
    bool shouldFire = fire;
    if (autoMode) autoControls(&rotation, &thrust, &shouldFire);
    // Bound catch-up after a stall; collision checks still run every 10 ms.
    accumulator += elapsed > 100U ? 100U : elapsed;
    while (accumulator >= STEP_MS && gameState == STATE_PLAYING) {
        accumulator -= STEP_MS;
        simulateStep(autoMode ? rotation : (left || right || controlsReady) ? rotation : 0,
                     autoMode ? thrust : controlsReady && thrust, shouldFire);
    }
}

static void drawWrappedLine(uint16_t *frame, float x0, float y0, float x1, float y1)
{
    for (int dy = -LCD_HEIGHT; dy <= LCD_HEIGHT; dy += LCD_HEIGHT) {
        for (int dx = -LCD_WIDTH; dx <= LCD_WIDTH; dx += LCD_WIDTH) {
            const int ax = (int)lroundf(x0) + dx, ay = (int)lroundf(y0) + dy;
            const int bx = (int)lroundf(x1) + dx, by = (int)lroundf(y1) + dy;
            if ((ax < 0 && bx < 0) || (ax >= LCD_WIDTH && bx >= LCD_WIDTH) ||
                (ay < 0 && by < 0) || (ay >= LCD_HEIGHT && by >= LCD_HEIGHT)) continue;
            draw_line(frame, ax, ay, bx, by, 0xffff);
        }
    }
}

void drawAsteroids(uint16_t *frame)
{
    if (!running) return;
    if (gameState == STATE_SCORES) {
        drawHighScoreEntry(frame);
        return;
    }
    clear(frame, 0);
    char line[64];
    if (gameState == STATE_READY) {
        drawPet2001TextCentredTransparent(frame, 160, 16, "ASTEROIDS", 0xffff, 3);
        drawPet2001TextCentredTransparent(frame, 83, 51, "PLAY", autoMode ? COLOR_LIGHTGRAY : COLOR_YELLOW, 2);
        drawPet2001TextCentredTransparent(frame, 237, 51, "AUTO", autoMode ? COLOR_YELLOW : COLOR_LIGHTGRAY, 2);
        drawPet2001TextCentredTransparent(frame, 160, 88, "D/B/STICK CHANGE  A/K START", 0xffff, 1);
        drawPet2001TextCentredTransparent(frame, 160, 106, "D/B/STICK LEFT/RIGHT ROTATE", 0xffff, 1);
        drawPet2001TextCentredTransparent(frame, 160, 124, "STICK UP THRUST  HOLD A FIRE", 0xffff, 1);
        drawPet2001TextCentredTransparent(frame, 160, 153, "BOOT BACK", 0x7bef, 1);
        return;
    }
    for (unsigned i = 0; i < MAX_ROCKS; ++i) {
        const Rock *rock = &rocks[i];
        if (!rock->active) continue;
        static const float shape[8] = {1.0f, 0.78f, 0.95f, 0.72f, 1.0f, 0.83f, 0.92f, 0.75f};
        for (unsigned j = 0; j < 8; ++j) {
            const unsigned next = (j + 1) % 8;
            const float a = rock->angle + j * PI / 4.0f, b = rock->angle + next * PI / 4.0f;
            const float radius = rockRadius(rock->size);
            drawWrappedLine(frame, rock->body.x + cosf(a) * radius * shape[j],
                            rock->body.y + sinf(a) * radius * shape[j],
                            rock->body.x + cosf(b) * radius * shape[next],
                            rock->body.y + sinf(b) * radius * shape[next]);
        }
    }
    for (unsigned i = 0; i < MAX_BULLETS; ++i) {
        if (bullets[i].remaining == 0) continue;
        const Body *b = &bullets[i].body;
        drawWrappedLine(frame, b->x, b->y, b->x - b->vx * 0.012f, b->y - b->vy * 0.012f);
    }
    if (gameState == STATE_PLAYING && (protection == 0 || (protection / 125U) % 2 == 0)) {
        const float dx = cosf(shipAngle), dy = sinf(shipAngle);
        const float noseX = ship.x + dx * 8, noseY = ship.y + dy * 8;
        const float leftX = ship.x - dx * 5 - dy * 5, leftY = ship.y - dy * 5 + dx * 5;
        const float rightX = ship.x - dx * 5 + dy * 5, rightY = ship.y - dy * 5 - dx * 5;
        drawWrappedLine(frame, noseX, noseY, leftX, leftY);
        drawWrappedLine(frame, leftX, leftY, rightX, rightY);
        drawWrappedLine(frame, rightX, rightY, noseX, noseY);
        if (thrusting) {
            drawWrappedLine(frame, leftX, leftY, ship.x - dx * 12, ship.y - dy * 12);
            drawWrappedLine(frame, rightX, rightY, ship.x - dx * 12, ship.y - dy * 12);
        }
    }
    fill_rect(frame, 0, 0, LCD_WIDTH, 12, 0);
    snprintf(line, sizeof(line), "SCORE %u", (unsigned)score);
    drawPet2001TextTransparent(frame, 3, 2, line, 0xffff, 1);
    snprintf(line, sizeof(line), "WAVE %u", (unsigned)wave);
    drawPet2001TextTransparent(frame, 166, 2, line, 0xffff, 1);
    snprintf(line, sizeof(line), "LIVES %u", lives);
    drawPet2001TextTransparent(frame, 264, 2, line, 0xffff, 1);
    if (autoMode) drawPet2001TextCentredTransparent(frame, 160, 14, "AUTO", COLOR_YELLOW, 1);
    if (gameState == STATE_FINISHED) {
        drawPet2001TextCentredTransparent(frame, 160, 78, "GAME OVER", COLOR_RED, 2);
    }
}