Boing Ball

Source: src/boing_ball.c · 206 lines · 7 functions.

Screen captures

Views of this part running on the Mini Arcade.

Boing Ball screen on the Mini Arcade
Boing Ball showing the animated chequered sphere.

Screen / module overview

  1. Enter Boing Ball

    Build cached sphere geometry once; reset position, velocity, rotation and bounce settings.

  2. Handle controls

    D/B changes rotation speed. A/C changes bounce height.

  3. Advance physics

    Limit elapsed time to 50 ms; integrate movement and gravity; advance texture rotation.

  4. Check collisions

    Reverse horizontal movement at side walls. At the floor, apply the selected upward bounce velocity.

  5. Draw and repeat

    Draw the shadow and checkerboard sphere using cached geometry. Show control status briefly after changes.

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

buildSphereMap() · source line 55
static void buildSphereMap(void)
  1. Decisionsphere_ready
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  2. FOR loopint y = 0; y < BALL_DIAMETER; ++y

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

      1. FOR loopint x = 0; x < BALL_DIAMETER; ++x

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

          1. Executeconst int index = y * BALL_DIAMETER + x;
          2. Executeconst float normal_x = (float)(x - BALL_RADIUS) / (float)BALL_RADIUS;
          3. Executeconst float normal_y = (float)(y - BALL_RADIUS) / (float)BALL_RADIUS;
          4. Executeconst float radius_squared = normal_x * normal_x + normal_y * normal_y;
          5. Decisionradius_squared > 1.0f
            True
              1. Executeshade_map[index] = 0U;
              2. Next loop iterationcontinue;
            False

            Continue after this decision.

          6. Executeconst float depth = sqrtf(1.0f - radius_squared);
          7. Executeconst float longitude = (atan2f(normal_x, depth) + BALL_PI) / (2.0f * BALL_PI);
          8. Executeconst float latitude = (asinf(normal_y) + BALL_PI * 0.5f) / BALL_PI;
          9. Executelongitude_map[index] = (uint8_t)(longitude * (float)BALL_LONGITUDE_COUNT);
          10. Executelatitude_map[index] = (uint8_t)(latitude * (float)BALL_LATITUDE_COUNT);
          11. Executeshade_map[index] = depth > 0.78f ? 3U : depth > 0.42f ? 2U : 1U;
  3. Executesphere_ready = true;
Read original function source
static void buildSphereMap(void)
{
    // Geometry is constant for the life of the application, so build it once.
    if (sphere_ready) return;
    for (int y = 0; y < BALL_DIAMETER; ++y) {
        for (int x = 0; x < BALL_DIAMETER; ++x) {
            const int index = y * BALL_DIAMETER + x;
            // Convert square bitmap coordinates to a -1..1 sphere plane.
            const float normal_x = (float)(x - BALL_RADIUS) / (float)BALL_RADIUS;
            const float normal_y = (float)(y - BALL_RADIUS) / (float)BALL_RADIUS;
            const float radius_squared = normal_x * normal_x + normal_y * normal_y;
            if (radius_squared > 1.0f) {
                // Pixels outside the unit circle are outside the visible sphere.
                shade_map[index] = 0U;
                continue;
            }
            // Depth is the visible front hemisphere at this position.
            const float depth = sqrtf(1.0f - radius_squared);
            const float longitude = (atan2f(normal_x, depth) + BALL_PI) / (2.0f * BALL_PI);
            const float latitude = (asinf(normal_y) + BALL_PI * 0.5f) / BALL_PI;
            // Store checkerboard bands and a simple centre-to-rim lighting level.
            longitude_map[index] = (uint8_t)(longitude * (float)BALL_LONGITUDE_COUNT);
            latitude_map[index] = (uint8_t)(latitude * (float)BALL_LATITUDE_COUNT);
            shade_map[index] = depth > 0.78f ? 3U : depth > 0.42f ? 2U : 1U;
        }
    }
    sphere_ready = true;
}
drawShadow() · source line 84
static void drawShadow(uint16_t *frame, int centre_x)

Calls into this documentation:

  1. Executeconst uint16_t shadow = RGB565(0, 0, 80);
  2. Executefill_rect(frame, centre_x - 16, (int)BALL_FLOOR_Y, 33, 1, shadow);
  3. Executefill_rect(frame, centre_x - 23, (int)BALL_FLOOR_Y + 1, 47, 2, shadow);
  4. Executefill_rect(frame, centre_x - 18, (int)BALL_FLOOR_Y + 3, 37, 1, shadow);
Read original function source
static void drawShadow(uint16_t *frame, int centre_x)
{
    // A flattened dark-blue oval visually anchors the otherwise floating sphere.
    const uint16_t shadow = RGB565(0, 0, 80);
    fill_rect(frame, centre_x - 16, (int)BALL_FLOOR_Y, 33, 1, shadow);
    fill_rect(frame, centre_x - 23, (int)BALL_FLOOR_Y + 1, 47, 2, shadow);
    fill_rect(frame, centre_x - 18, (int)BALL_FLOOR_Y + 3, 37, 1, shadow);
}
startBoingBall() · source line 93
void startBoingBall(uint32_t now_ms)

Calls into this documentation:

  1. ExecutebuildSphereMap();
  2. Executeball_x = 82.0f;
  3. Executeball_y = 52.0f;
  4. Executeball_velocity_x = BALL_HORIZONTAL_VELOCITY;
  5. Executeball_velocity_y = 0.0f;
  6. Executerotation_phase = 0.0f;
  7. Executerotation_speed_index = 2U;
  8. Executebounce_height_index = 2U;
  9. Executelast_update_ms = now_ms;
  10. Executecontrols_changed_at = now_ms;
  11. Executerunning = true;
Read original function source
void startBoingBall(uint32_t now_ms)
{
    // Prepare the cached geometry and reset the familiar left-to-right demo.
    buildSphereMap();
    ball_x = 82.0f;
    ball_y = 52.0f;
    ball_velocity_x = BALL_HORIZONTAL_VELOCITY;
    ball_velocity_y = 0.0f;
    rotation_phase = 0.0f;
    rotation_speed_index = 2U;
    bounce_height_index = 2U;
    last_update_ms = now_ms;
    controls_changed_at = now_ms;
    running = true;
}
stopBoingBall() · source line 109
void stopBoingBall(void)
  1. Executerunning = false;
Read original function source
void stopBoingBall(void)
{
    // This module owns no allocated resources; stopping disables update/draw only.
    running = false;
}
boingBallAction() · source line 115
void boingBallAction(bool rotation_slower, bool rotation_faster, bool bounce_higher,
                     bool bounce_lower, uint32_t now_ms)
  1. Decision!running
    True
    1. Exit this functionreturn;
    False

    Continue after this decision.

  2. Executebool changed = false;
  3. Decisionrotation_slower && rotation_speed_index > 0U
    True
      1. Execute--rotation_speed_index;
      2. Executechanged = true;
    False

    Continue after this decision.

  4. Decisionrotation_faster && rotation_speed_index + 1U < ROTATION_SPEED_COUNT
    True
      1. Execute++rotation_speed_index;
      2. Executechanged = true;
    False

    Continue after this decision.

  5. Decisionbounce_higher && bounce_height_index + 1U < BOUNCE_HEIGHT_COUNT
    True
      1. Execute++bounce_height_index;
      2. Executechanged = true;
    False

    Continue after this decision.

  6. Decisionbounce_lower && bounce_height_index > 0U
    True
      1. Execute--bounce_height_index;
      2. Executechanged = true;
    False

    Continue after this decision.

  7. Decisionchanged
    True
    1. Executecontrols_changed_at = now_ms;
    False

    Continue after this decision.

Read original function source
void boingBallAction(bool rotation_slower, bool rotation_faster, bool bounce_higher,
                     bool bounce_lower, uint32_t now_ms)
{
    if (!running) return;
    bool changed = false;
    // D/B adjust texture rotation; A/C adjust the selected bounce launch velocity.
    if (rotation_slower && rotation_speed_index > 0U) {
        --rotation_speed_index;
        changed = true;
    }
    if (rotation_faster && rotation_speed_index + 1U < ROTATION_SPEED_COUNT) {
        ++rotation_speed_index;
        changed = true;
    }
    if (bounce_higher && bounce_height_index + 1U < BOUNCE_HEIGHT_COUNT) {
        ++bounce_height_index;
        changed = true;
    }
    if (bounce_lower && bounce_height_index > 0U) {
        --bounce_height_index;
        changed = true;
    }
    if (changed) controls_changed_at = now_ms;
}
updateBoingBall() · source line 140
void updateBoingBall(uint32_t now_ms)

Calls into this documentation:

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

    Continue after this decision.

  2. Executeuint32_t elapsed_ms = now_ms - last_update_ms;
  3. Decisionelapsed_ms > 50U
    True
    1. Executeelapsed_ms = 50U;
    False

    Continue after this decision.

  4. Executelast_update_ms = now_ms;
  5. Executeconst float elapsed = (float)elapsed_ms / 1000.0f;
  6. Executeball_x += ball_velocity_x * elapsed;
  7. Executeball_y += ball_velocity_y * elapsed;
  8. Executeball_velocity_y += BALL_GRAVITY * elapsed;
  9. Executerotation_phase += rotation_speeds[rotation_speed_index] * elapsed;
  10. Decisionrotation_phase >= (float)BALL_LONGITUDE_COUNT
    True
    1. Executerotation_phase -= (float)BALL_LONGITUDE_COUNT;
    False

    Continue after this decision.

  11. Decisionball_x < (float)BALL_RADIUS
    True
      1. Executeball_x = (float)BALL_RADIUS;
      2. Executeball_velocity_x = BALL_HORIZONTAL_VELOCITY;
      3. ExecuteaudioPlay(AUDIO_SFX_BOING_IMPACT);
    False
    1. Decisionball_x > (float)(LCD_WIDTH - 1 - BALL_RADIUS)
      True
        1. Executeball_x = (float)(LCD_WIDTH - 1 - BALL_RADIUS);
        2. Executeball_velocity_x = -BALL_HORIZONTAL_VELOCITY;
        3. ExecuteaudioPlay(AUDIO_SFX_BOING_IMPACT);
      False

      Continue after this decision.

  12. Decisionball_y > BALL_FLOOR_Y - (float)BALL_RADIUS
    True
      1. Executeball_y = BALL_FLOOR_Y - (float)BALL_RADIUS;
      2. Executeball_velocity_y = bounce_velocities[bounce_height_index];
      3. ExecuteaudioPlay(AUDIO_SFX_BOING_IMPACT);
    False

    Continue after this decision.

Read original function source
void updateBoingBall(uint32_t now_ms)
{
    if (!running) return;
    // Cap long stalls so physics never jumps by a large visible distance.
    uint32_t elapsed_ms = now_ms - last_update_ms;
    if (elapsed_ms > 50U) elapsed_ms = 50U;
    last_update_ms = now_ms;
    const float elapsed = (float)elapsed_ms / 1000.0f;
    // Integrate horizontal velocity and gravity-driven vertical velocity.
    ball_x += ball_velocity_x * elapsed;
    ball_y += ball_velocity_y * elapsed;
    ball_velocity_y += BALL_GRAVITY * elapsed;
    // Advance and wrap the texture once per complete longitude revolution.
    rotation_phase += rotation_speeds[rotation_speed_index] * elapsed;
    if (rotation_phase >= (float)BALL_LONGITUDE_COUNT) rotation_phase -= (float)BALL_LONGITUDE_COUNT;
    if (ball_x < (float)BALL_RADIUS) {
        // Keep the entire 81-pixel ball on screen and bounce at both walls.
        ball_x = (float)BALL_RADIUS;
        ball_velocity_x = BALL_HORIZONTAL_VELOCITY;
        audioPlay(AUDIO_SFX_BOING_IMPACT);
    } else if (ball_x > (float)(LCD_WIDTH - 1 - BALL_RADIUS)) {
        ball_x = (float)(LCD_WIDTH - 1 - BALL_RADIUS);
        ball_velocity_x = -BALL_HORIZONTAL_VELOCITY;
        audioPlay(AUDIO_SFX_BOING_IMPACT);
    }
    if (ball_y > BALL_FLOOR_Y - (float)BALL_RADIUS) {
        // Land on the floor, then launch with the currently selected bounce height.
        ball_y = BALL_FLOOR_Y - (float)BALL_RADIUS;
        ball_velocity_y = bounce_velocities[bounce_height_index];
        audioPlay(AUDIO_SFX_BOING_IMPACT);
    }
}
drawBoingBall() · source line 173
void drawBoingBall(uint16_t *frame)

Calls into this documentation:

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

    Continue after this decision.

  2. Executeclear(frame, RGB565(0, 32, 175));
  3. Executeconst int centre_x = (int)lroundf(ball_x);
  4. Executeconst int centre_y = (int)lroundf(ball_y);
  5. Executeconst uint8_t rotation = (uint8_t)rotation_phase;
  6. ExecutedrawShadow(frame, centre_x);
  7. FOR loopint y = 0; y < BALL_DIAMETER; ++y

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

      1. Executeconst int screen_y = centre_y - BALL_RADIUS + y;
      2. Decisionscreen_y < 0 || screen_y >= LCD_HEIGHT
        True
        1. Next loop iterationcontinue;
        False

        Continue after this decision.

      3. FOR loopint x = 0; x < BALL_DIAMETER; ++x

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

          1. Executeconst int index = y * BALL_DIAMETER + x;
          2. Executeconst uint8_t shade = shade_map[index];
          3. Decisionshade == 0U
            True
            1. Next loop iterationcontinue;
            False

            Continue after this decision.

          4. Executeconst int screen_x = centre_x - BALL_RADIUS + x;
          5. Decisionscreen_x < 0 || screen_x >= LCD_WIDTH
            True
            1. Next loop iterationcontinue;
            False

            Continue after this decision.

          6. Executeconst uint8_t longitude = (uint8_t)((longitude_map[index] + rotation) % BALL_LONGITUDE_COUNT);
          7. Executeconst bool red = (((longitude / 2U) + latitude_map[index]) & 1U) != 0U;
          8. Executeframe[screen_y * LCD_WIDTH + screen_x] = red ? red_shades[shade - 1U] : white_shades[shade - 1U];
  8. Decisionlast_update_ms - controls_changed_at < CONTROL_STATUS_MS
    True
      1. Executechar text[36];
      2. Executesnprintf(text, sizeof(text), "D/B ROT %u A/C BOUNCE %u", (unsigned)rotation_speed_index + 1U, (unsigned)bounce_height_index + 1U);
      3. Executedraw_text_centred(frame, LCD_WIDTH / 2, 158, text, 1, COLOR_WHITE);
    False

    Continue after this decision.

Read original function source
void drawBoingBall(uint16_t *frame)
{
    if (!running || frame == NULL) return;
    // Draw into the shared framebuffer over the Amiga-blue backdrop.
    clear(frame, RGB565(0, 32, 175));
    const int centre_x = (int)lroundf(ball_x);
    const int centre_y = (int)lroundf(ball_y);
    const uint8_t rotation = (uint8_t)rotation_phase;
    drawShadow(frame, centre_x);
    // Render only the cached sphere pixels that lie inside the screen bounds.
    for (int y = 0; y < BALL_DIAMETER; ++y) {
        const int screen_y = centre_y - BALL_RADIUS + y;
        if (screen_y < 0 || screen_y >= LCD_HEIGHT) continue;
        for (int x = 0; x < BALL_DIAMETER; ++x) {
            const int index = y * BALL_DIAMETER + x;
            const uint8_t shade = shade_map[index];
            if (shade == 0U) continue;
            const int screen_x = centre_x - BALL_RADIUS + x;
            if (screen_x < 0 || screen_x >= LCD_WIDTH) continue;
            // Offset longitude to rotate the checkerboard without recomputing geometry.
            const uint8_t longitude = (uint8_t)((longitude_map[index] + rotation) % BALL_LONGITUDE_COUNT);
            const bool red = (((longitude / 2U) + latitude_map[index]) & 1U) != 0U;
            frame[screen_y * LCD_WIDTH + screen_x] = red ? red_shades[shade - 1U] : white_shades[shade - 1U];
        }
    }
    if (last_update_ms - controls_changed_at < CONTROL_STATUS_MS) {
        // The current tuning is shown briefly after the user changes a control.
        char text[36];
        snprintf(text, sizeof(text), "D/B ROT %u  A/C BOUNCE %u",
                 (unsigned)rotation_speed_index + 1U, (unsigned)bounce_height_index + 1U);
        draw_text_centred(frame, LCD_WIDTH / 2, 158, text, 1, COLOR_WHITE);
    }
}
Complete original file, including constants and data
/* Coded By JD(2026) */
#error "Reference listing only — not provided as build-ready source."
#include "boing_ball.h"

#include "audio.h"
#include "game_graphics.h"
#include "PET2001_font.h"

#include <math.h>
#include <stdbool.h>
#include <stdio.h>

// The ball is a procedural 81x81 sphere with rotating checkerboard bands.
#define BALL_RADIUS 40
#define BALL_DIAMETER (BALL_RADIUS * 2 + 1)
#define BALL_PIXEL_COUNT (BALL_DIAMETER * BALL_DIAMETER)
#define BALL_LONGITUDE_COUNT 16U // Checkerboard columns around the sphere.
#define BALL_LATITUDE_COUNT 8U   // Checkerboard rows from top to bottom.
#define BALL_FLOOR_Y 146.0f      // Shadow and bounce floor baseline.
#define BALL_GRAVITY 380.0f      // Downward acceleration in pixels/second^2.
#define BALL_BOUNCE_VELOCITY -215.0f // Negative Y launches the ball upward.
#define BALL_HORIZONTAL_VELOCITY 82.0f
#define BALL_PI 3.14159265f
#define ROTATION_SPEED_COUNT 4U // D/B select one of four rotation speeds.
#define BOUNCE_HEIGHT_COUNT 4U  // A/C select one of four bounce heights.
#define CONTROL_STATUS_MS 1500U // Brief duration of the control reminder.

// Cached sphere geometry avoids trigonometry during every rendered frame.
// shade_map is zero outside the disc, otherwise a one-to-three lighting level.
static uint8_t longitude_map[BALL_PIXEL_COUNT];
static uint8_t latitude_map[BALL_PIXEL_COUNT];
static uint8_t shade_map[BALL_PIXEL_COUNT];
// Ball position is its centre; velocities use pixels per second.
static float ball_x;
static float ball_y;
static float ball_velocity_x;
static float ball_velocity_y;
// Texture offset measured in longitude bands, rather than degrees.
static float rotation_phase;
static uint32_t last_update_ms;
static uint32_t controls_changed_at;
// Indexes into the four tuning tables below.
static uint8_t rotation_speed_index;
static uint8_t bounce_height_index;
static bool running;
static bool sphere_ready;

// Three-level directional lighting for red and white checkerboard squares.
static const uint16_t red_shades[] = {
    RGB565(105, 10, 10), RGB565(190, 22, 20), RGB565(250, 70, 60)
};
static const uint16_t white_shades[] = {
    RGB565(105, 115, 135), RGB565(210, 220, 235), RGB565(255, 255, 255)
};
static const float rotation_speeds[ROTATION_SPEED_COUNT] = {4.0f, 8.0f, 12.0f, 16.0f};
static const float bounce_velocities[BOUNCE_HEIGHT_COUNT] = {-160.0f, -190.0f, BALL_BOUNCE_VELOCITY, -250.0f};

static void buildSphereMap(void)
{
    // Geometry is constant for the life of the application, so build it once.
    if (sphere_ready) return;
    for (int y = 0; y < BALL_DIAMETER; ++y) {
        for (int x = 0; x < BALL_DIAMETER; ++x) {
            const int index = y * BALL_DIAMETER + x;
            // Convert square bitmap coordinates to a -1..1 sphere plane.
            const float normal_x = (float)(x - BALL_RADIUS) / (float)BALL_RADIUS;
            const float normal_y = (float)(y - BALL_RADIUS) / (float)BALL_RADIUS;
            const float radius_squared = normal_x * normal_x + normal_y * normal_y;
            if (radius_squared > 1.0f) {
                // Pixels outside the unit circle are outside the visible sphere.
                shade_map[index] = 0U;
                continue;
            }
            // Depth is the visible front hemisphere at this position.
            const float depth = sqrtf(1.0f - radius_squared);
            const float longitude = (atan2f(normal_x, depth) + BALL_PI) / (2.0f * BALL_PI);
            const float latitude = (asinf(normal_y) + BALL_PI * 0.5f) / BALL_PI;
            // Store checkerboard bands and a simple centre-to-rim lighting level.
            longitude_map[index] = (uint8_t)(longitude * (float)BALL_LONGITUDE_COUNT);
            latitude_map[index] = (uint8_t)(latitude * (float)BALL_LATITUDE_COUNT);
            shade_map[index] = depth > 0.78f ? 3U : depth > 0.42f ? 2U : 1U;
        }
    }
    sphere_ready = true;
}

static void drawShadow(uint16_t *frame, int centre_x)
{
    // A flattened dark-blue oval visually anchors the otherwise floating sphere.
    const uint16_t shadow = RGB565(0, 0, 80);
    fill_rect(frame, centre_x - 16, (int)BALL_FLOOR_Y, 33, 1, shadow);
    fill_rect(frame, centre_x - 23, (int)BALL_FLOOR_Y + 1, 47, 2, shadow);
    fill_rect(frame, centre_x - 18, (int)BALL_FLOOR_Y + 3, 37, 1, shadow);
}

void startBoingBall(uint32_t now_ms)
{
    // Prepare the cached geometry and reset the familiar left-to-right demo.
    buildSphereMap();
    ball_x = 82.0f;
    ball_y = 52.0f;
    ball_velocity_x = BALL_HORIZONTAL_VELOCITY;
    ball_velocity_y = 0.0f;
    rotation_phase = 0.0f;
    rotation_speed_index = 2U;
    bounce_height_index = 2U;
    last_update_ms = now_ms;
    controls_changed_at = now_ms;
    running = true;
}

void stopBoingBall(void)
{
    // This module owns no allocated resources; stopping disables update/draw only.
    running = false;
}

void boingBallAction(bool rotation_slower, bool rotation_faster, bool bounce_higher,
                     bool bounce_lower, uint32_t now_ms)
{
    if (!running) return;
    bool changed = false;
    // D/B adjust texture rotation; A/C adjust the selected bounce launch velocity.
    if (rotation_slower && rotation_speed_index > 0U) {
        --rotation_speed_index;
        changed = true;
    }
    if (rotation_faster && rotation_speed_index + 1U < ROTATION_SPEED_COUNT) {
        ++rotation_speed_index;
        changed = true;
    }
    if (bounce_higher && bounce_height_index + 1U < BOUNCE_HEIGHT_COUNT) {
        ++bounce_height_index;
        changed = true;
    }
    if (bounce_lower && bounce_height_index > 0U) {
        --bounce_height_index;
        changed = true;
    }
    if (changed) controls_changed_at = now_ms;
}

void updateBoingBall(uint32_t now_ms)
{
    if (!running) return;
    // Cap long stalls so physics never jumps by a large visible distance.
    uint32_t elapsed_ms = now_ms - last_update_ms;
    if (elapsed_ms > 50U) elapsed_ms = 50U;
    last_update_ms = now_ms;
    const float elapsed = (float)elapsed_ms / 1000.0f;
    // Integrate horizontal velocity and gravity-driven vertical velocity.
    ball_x += ball_velocity_x * elapsed;
    ball_y += ball_velocity_y * elapsed;
    ball_velocity_y += BALL_GRAVITY * elapsed;
    // Advance and wrap the texture once per complete longitude revolution.
    rotation_phase += rotation_speeds[rotation_speed_index] * elapsed;
    if (rotation_phase >= (float)BALL_LONGITUDE_COUNT) rotation_phase -= (float)BALL_LONGITUDE_COUNT;
    if (ball_x < (float)BALL_RADIUS) {
        // Keep the entire 81-pixel ball on screen and bounce at both walls.
        ball_x = (float)BALL_RADIUS;
        ball_velocity_x = BALL_HORIZONTAL_VELOCITY;
        audioPlay(AUDIO_SFX_BOING_IMPACT);
    } else if (ball_x > (float)(LCD_WIDTH - 1 - BALL_RADIUS)) {
        ball_x = (float)(LCD_WIDTH - 1 - BALL_RADIUS);
        ball_velocity_x = -BALL_HORIZONTAL_VELOCITY;
        audioPlay(AUDIO_SFX_BOING_IMPACT);
    }
    if (ball_y > BALL_FLOOR_Y - (float)BALL_RADIUS) {
        // Land on the floor, then launch with the currently selected bounce height.
        ball_y = BALL_FLOOR_Y - (float)BALL_RADIUS;
        ball_velocity_y = bounce_velocities[bounce_height_index];
        audioPlay(AUDIO_SFX_BOING_IMPACT);
    }
}

void drawBoingBall(uint16_t *frame)
{
    if (!running || frame == NULL) return;
    // Draw into the shared framebuffer over the Amiga-blue backdrop.
    clear(frame, RGB565(0, 32, 175));
    const int centre_x = (int)lroundf(ball_x);
    const int centre_y = (int)lroundf(ball_y);
    const uint8_t rotation = (uint8_t)rotation_phase;
    drawShadow(frame, centre_x);
    // Render only the cached sphere pixels that lie inside the screen bounds.
    for (int y = 0; y < BALL_DIAMETER; ++y) {
        const int screen_y = centre_y - BALL_RADIUS + y;
        if (screen_y < 0 || screen_y >= LCD_HEIGHT) continue;
        for (int x = 0; x < BALL_DIAMETER; ++x) {
            const int index = y * BALL_DIAMETER + x;
            const uint8_t shade = shade_map[index];
            if (shade == 0U) continue;
            const int screen_x = centre_x - BALL_RADIUS + x;
            if (screen_x < 0 || screen_x >= LCD_WIDTH) continue;
            // Offset longitude to rotate the checkerboard without recomputing geometry.
            const uint8_t longitude = (uint8_t)((longitude_map[index] + rotation) % BALL_LONGITUDE_COUNT);
            const bool red = (((longitude / 2U) + latitude_map[index]) & 1U) != 0U;
            frame[screen_y * LCD_WIDTH + screen_x] = red ? red_shades[shade - 1U] : white_shades[shade - 1U];
        }
    }
    if (last_update_ms - controls_changed_at < CONTROL_STATUS_MS) {
        // The current tuning is shown briefly after the user changes a control.
        char text[36];
        snprintf(text, sizeof(text), "D/B ROT %u  A/C BOUNCE %u",
                 (unsigned)rotation_speed_index + 1U, (unsigned)bounce_height_index + 1U);
        drawPet2001TextCentredTransparent(frame, LCD_WIDTH / 2, 158, text, COLOR_WHITE, 1);
    }
}