Chapter 25  ·  Project

Vibe Asteroids

Asteroids is a step up from Snake in every dimension. The physics are continuous (floating-point positions and velocities, rotation, drag), the collision detection is more involved, and there's enough content — ship, bullets, asteroids of three sizes, explosions, waves — that you really need an architecture plan before any prompting starts. This is the architecture-first approach to vibe-coding.

Architecture First

With Snake, the design was simple enough to emerge prompt-by-prompt. With Asteroids, diving straight into code would produce a tangled mess that's harder to extend than it is to rewrite. The right move is to decide the core structure before opening the AI chat.

Our architecture decisions:

With these decisions made, we split the work into five stages.

Stage 1 — Architecture and the Ship

Prompt:

I am writing C++17 with SDL 3. No SDL 2 API. RAII for SDL resources. No external libraries. Build the skeleton for Asteroids across these files: main.cpp, Game.h/.cpp, Ship.h/.cpp. Window 800×600. The ship is a triangle drawn with SDL_RenderLine. It has float position (centre), float angle (radians), float velocity (vx, vy). Thrust (W or Up) accelerates in the facing direction. Left/Right arrows rotate. Drag of 0.98 applied to velocity each frame. Screen-wrap. Delta-time loop using SDL_GetTicks(). No bullets or asteroids yet.

Review — spot the drag bug:

The AI produces:

vx *= 0.98f;
vy *= 0.98f;

This looks right but it's frame-rate dependent. Multiplying by 0.98 per frame at 60 fps is very different drag from multiplying by 0.98 per frame at 30 fps. The correct formula is frame-rate independent:

float dragFactor = std::pow(0.98f, dt * 60.0f);
vx *= dragFactor;
vy *= dragFactor;

std::pow(0.98f, dt * 60.0f) applies "what 0.98 per frame at 60 fps would feel like" regardless of the actual frame rate. This is a physics correctness issue — AI assistants almost never get this right unprompted because the naive version works at a fixed frame rate and seems fine in testing.

Push back: "Your drag formula is frame-rate dependent. Fix it using pow(dragPerFrame, dt * targetFps)."

Stage 2 — Bullets and Asteroids

Prompt:

Add bullets (Space key fires, max 5 on screen, disappear after 1.5 seconds) and asteroids (three sizes: large/medium/small). Large asteroids split into two medium when shot; medium split into two small; small are destroyed. Store asteroids in a vector. Draw them as polygons with SDL_RenderLine (use a circle approximation with 8 points). Collision is circle-vs-circle using the asteroid's radius.

What the AI produces (collision, key part):

bool circleHit(float ax, float ay, float ar,
               float bx, float by, float br)
{
    float dx = ax - bx, dy = ay - by;
    return dx*dx + dy*dy <= (ar + br)*(ar + br);
}

// In Game::update, bullet vs asteroid:
for (int bi = (int)bullets_.size()-1; bi >= 0; --bi)
{
    for (int ai = (int)asteroids_.size()-1; ai >= 0; --ai)
    {
        if (circleHit(bullets_[bi].x, bullets_[bi].y, BULLET_RADIUS,
                      asteroids_[ai].x, asteroids_[ai].y, asteroids_[ai].radius))
        {
            splitAsteroid(ai);
            bullets_.erase(bullets_.begin() + bi);
            break;
        }
    }
}

Review — spot the index bug:

Iterating backwards and using erase is a deliberate choice — when you erase element ai, all higher indices shift down by one, so iterating forwards would skip an element. Backwards iteration avoids this. The AI got this right.

However, notice the break after erasing the bullet. This exits the inner loop but the outer loop continues — meaning a single bullet can only destroy one asteroid per frame, which is correct behaviour. Without the break, the outer loop would try to access an already-erased bullet index on the next inner iteration. This is a genuine correctness issue that you have to reason about — if you missed the break, the game would crash randomly when a bullet happened to be at index 0 and the inner loop tried to keep running.

Stage 3 — Scoring, Lives, and Wave Spawning

Prompt:

Add score (large=20, medium=50, small=100), lives (start with 3, lose one on ship-asteroid collision, brief invincibility period after respawn), and wave management (clear everything between waves, spawn next wave's asteroids at screen edges, never within 150px of the player's spawn point at the centre).

Review — wave spawn bug:

The AI's initial wave-spawn code places asteroids at random screen positions and checks distance from the centre. But the player respawns at the screen centre — so checking distance from (400, 300) is correct for wave 1, but waves 2+ start with the player still alive at an unknown position. The spawn check needs to be against the player's current position, not a hardcoded centre.

Push back: "The spawn-safe-distance check uses hardcoded screen centre. Pass the ship's current position to spawnWave and check distance from that instead."

Stage 4 — The Messy Middle

This is where vibe-coding projects typically accumulate small inconsistencies — a render that misses the invincibility flash, a score that doesn't reset on new game, a lives display that shows one more than the correct value. The messy middle is normal; it's the cost of generating code in rounds rather than planning every detail up front.

The key skill here is targeted debugging. Don't ask the AI to "fix all the bugs" — it'll change too much. Instead, identify one specific symptom, narrow it to a specific variable or code path, and ask for a focused fix.

Example: the invincibility flash isn't showing. Trace: the AI added an invincible_ bool but the render code doesn't check it. Fix prompt: "In Ship::render, if invincible_ is true, alternate drawing and not drawing each second using SDL_GetTicks() % 200 < 100."

Stage 5 — Finishing

Prompt:

Final polish: game-over screen when lives reach zero (show final score, press R to restart), a high score that persists during the session, show wave number in the HUD, and make the ship explosion a ring of debris particles (8 small squares that drift outward and fade over 0.8 seconds).

The AI generates a Particle struct with position, velocity, and a float lifetime. The main loop updates and renders them the same way as bullets. Fading is implemented by scaling the draw colour's alpha by life / maxLife — a clean approach that requires SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND) for alpha blending to work.

The Finished Game

Vibe Asteroids — gameplay screenshot with ship, asteroids, and bullets
Vibe Asteroids — wire-frame ship firing at a cluster of incoming asteroids.

The finished game is around 400 lines across five files. Notable things the architecture decisions bought us:

Comparing the Two Projects

Snake was pure vibe-coding: prompt → generate → review → run, one feature at a time, no upfront plan needed. Asteroids needed the architecture-first approach because the physics, multiple entity types, and wave system would have tangled badly if we'd prompted blindly.

The rule of thumb: if you can describe the whole game's structure in two sentences, prompt-first is fine. If the architecture requires real decisions about entity organisation, data ownership, or physics models, make those decisions yourself before starting.

Both projects demonstrated the same underlying principle: the AI generates code, but you supply the judgment. The three bugs we caught in this chapter — frame-rate-dependent drag, the break-after-erase correctness issue, the wrong spawn-distance reference — all required understanding the code well enough to reason about it, not just read it. That understanding came from everything in Acts 1 and 2.

Summary