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:
- Floating-point world space. Everything uses
floatpositions and velocities. The window is 800×600. Wrap at the edges (classic Asteroids screen-wrap, not walls). - Separate vectors per entity type.
std::vector<Asteroid>,std::vector<Bullet>, oneShip. No generic "entity" base class — the types are different enough that a shared base buys nothing here. - Split update and render.
update(float dt)advances all physics;render(SDL_Renderer*)draws everything. The main loop feeds delta-time between these two calls. - No textures. Draw everything as SDL wire-frame shapes — line-drawn ship, polygons for asteroids, small squares for bullets. Keeps setup zero.
- Waves. Clear all asteroids and bullets between waves; spawn the next wave's asteroids away from the player's position.
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
The finished game is around 400 lines across five files. Notable things the architecture decisions bought us:
- The separate vectors per entity type kept the update and render code flat and readable — no casting, no virtual dispatch, no polymorphism needed because each type just does its own thing.
- The delta-time loop meant the game played consistently at any frame rate from the start — the frame-rate-independent drag fix was the only physics correction needed.
- The explicit wave-spawning function (with safe-distance checking) made Stage 3 easy — the AI just needed the right parameters, not a redesign.
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
- Asteroids required architecture-first planning before any prompting — decide entity types, data ownership, and physics model upfront.
- The five stages (architecture, core systems, content, messy middle, finishing) are a reusable template for any medium-complexity vibe-coding project.
- Three bugs required human reasoning to catch: frame-rate-dependent drag, bullet-index safety in nested loops, and wrong spawn-distance reference.
- The messy middle is inevitable — tackle it with targeted, single-symptom debugging prompts rather than "fix all the bugs."
- The architecture decision to use separate vectors per entity type (rather than a polymorphic entity base class) kept the code flat and readable throughout.