While rebuilding ianpiggott.com as a static Astro site, I wanted a small arcade game on the contact page — Taito Crazy Balloon vibes: steer up and down, drift through gaps, don't pop on the spikes.
I described that to Grok (xAI) in Cursor. First playable build: under ten minutes. One Astro component, one canvas, zero game-engine dependencies. What follows is how it works, what broke, how we tuned it, and what AI-assisted dev actually looks like in 2026.
The brief
Genre
Vertical steer-only balloon through scrolling wall gaps. Crazy Balloon-style — not a Flappy Bird clone.
Stack
Embed in existing Astro layout. Match site CSS variables and typography.
Input
Keyboard (arrows / W-S), click-to-start, touch ↑/↓ hold buttons on phones.
Output
Static HTML — game logic in an inline client script. No server runtime.
A static site generator changes what "shipping a game" means: you're not deploying a WebGL build — you're dropping a component next to the contact form.
What shipped in the first pass
Four layers, one file
Grok scaffolded SliceBalloonGame.astro:
- Markup — HUD, canvas, start overlay, touch bar
- Script — TypeScript game loop in
<script>(bundled by Vite) - Styles — scoped CSS using
--color-header,--color-accent, etc. - Integration — import on
contact.astro, boot onastro:page-load
Core architecture
// Fixed player, scrolling world
const BALLOON_X = 72; // screen X never changes
let worldX = 0; // increases each frame
const sx = wall.x - worldX; // wall screen position
function loop() {
update();
render();
requestAnimationFrame(loop);
}
The balloon stays at a fixed X. Obstacles live in world space and slide left. Simple collision: the player is a fixed probe; the world moves past them.
Play it
Arrow keys or W/S to steer up and down. On phone, hold the up/down buttons.
Under the hood
Render pipeline
Pure Canvas 2D — no Pixi, Phaser, or Three.js. Each frame:
drawSky()— gradientdrawClouds()— parallaxdrawEdges()— ceiling/floor spikesdrawWall()— brick columns + gap spikesdrawBalloon()— body, string, basket
Obstacle model
type Wall = {
x: number;
gapY: number;
gapH: number;
};
Spawn horizon ahead of camera; cull walls off the left edge. Gaps randomised in a safe band — every column is passable.
Collision
Softened AABB hitbox — slightly narrower than the drawn ellipse for fair near-misses.
Three deaths: ceiling, floor, or wall outside the gap. Three lives, brief invincibility, small rewind after a pop.
Input & mobile
Desktop: Set<string> key polling in rAF. Touch: ↑/↓ hold buttons via @media (hover: none) — continuous thrust, not tap.
data-bg-ready guard + astro:page-load prevents double-binding on client nav.
Wall spawn logic (correct version)
function ensureWalls() {
walls = walls.filter((wall) => wall.x - worldX > -WALL_W * 2);
const horizon = worldX + W + SPAWN_AHEAD;
while (nextWallX < horizon) spawnWall();
}
The bug that broke v1
Symptom
Endless sky, score ticking up, no deaths. Looked fine in a screenshot; felt broken in play.
Cause — inverted wall culling
// WRONG: deletes walls still approaching from the right
walls = walls.filter((wall) => wall.x - worldX < W + 80);
That kept walls with low screen X and deleted obstacles still off the right edge — garbage-collected before they reached the balloon.
Fix
walls = walls.filter((wall) => wall.x - worldX > -WALL_W * 2);
AI generated the game fast. I still had to play it to catch this. The vibe doesn't replace the playtest.
Tuning after the ten minutes
Human-in-the-loop polish
Playable in ten minutes; finished over a few conversation turns:
Score-based difficulty
function difficultyRamp() {
return Math.min(score / DIFFICULTY_RAMP_SCORE, 1);
}
function currentScroll() {
const ramp = difficultyRamp();
return SCROLL_BASE + (SCROLL_MAX - SCROLL_BASE) * ramp;
}
function nextWallGap() {
const ramp = difficultyRamp();
const loose = WALL_GAP_MAX - 100 + Math.random() * 100;
const tight = WALL_GAP_MIN - 20 + Math.random() * 55;
return loose + (tight - loose) * ramp;
}
At high scores: faster scroll and two gap columns on screen — emergent pressure without a wave system.
Why Astro
Islands by default
Contact page is static HTML. Game hydrates only where imported.
One file
Markup, logic, CSS colocated — easy to reason about in chat with an AI assistant.
Static deploy
npm run build → game ships in dist/contact/index.html like everything else.
No server
100% client-side canvas. AI authored source; nothing to operate at runtime.
What AI accelerated
| Task | Traditional | Grok in Cursor |
|---|---|---|
| Canvas boilerplate (loop, input, overlay) | 1–2 hours | ~2 minutes |
| Wall spawn + collision + draw | 2–3 hours | ~5 minutes |
| Astro embed + site styling | 30–60 minutes | ~3 minutes |
| Playtesting + feel tuning | Still human | Still human |
AI didn't replace judgment on pacing, fairness, or the culling bug. It collapsed the distance between "idea" and "something running in the real layout."
File map
src/components/SliceBalloonGame.astro— entire gamesrc/pages/contact.astro— embed + credit asidesrc/components/PostContent.astro—data-embed-game="slice-balloon"inline demo marker
Takeaways
- Constrain the AI — fixed stack, fixed scope, fixed reference (Crazy Balloon). Narrow prompts beat "make me a game."
- World-space scrolling with fixed player X — simple collision, easy to explain in chat.
- Ship inside the site — no separate game repo, no brand drift.
- Playtest immediately — coordinate bugs only show up when you play.
- Under ten minutes is real for a first playable. Polish is a conversation.
If you're rebuilding a personal site in 2026, this is now rational to add — not because games are free, but because scaffolding cost dropped by an order of magnitude. The spike walls are still deadly. Writing them is a lot faster than it used to be.
