Flappy Kiro: Architecture and Design Decisions
An article describing the architecture and design decisions taken while implementing the Flappy Kiro game as part of an AWS workshop. A technical deep-dive into the architecture of Flappy Kiro — a single-file browser game built with vanilla JavaScript and the Canvas 2D API.
Overview
Flappy Kiro is a Flappy Bird-style game delivered as a single self-contained HTML file. The player controls a ghost character ("Ghosty") that must navigate through gaps between scrolling pipe obstacles. The game features a hand-drawn aesthetic, responsive canvas sizing, persistent high scores, and a comprehensive test suite.
Key constraints that shaped the architecture:
- Zero external dependencies at runtime (single HTML file, no network requests)
- Fully testable game logic (pure functions extractable to a Node.js module)
- Deterministic physics regardless of display refresh rate
- Responsive to any viewport size
Project Structure
1
2
3
4
index.html ← Final deliverable (all CSS + JS inlined)
game-logic.js ← Pure functions module (for testing only)
game-logic.test.js ← 200 tests (Jest + fast-check)
package.json ← Dev dependencies onlyThe dual-file approach is the core architectural decision:
game-logic.js exports the same pure functions that are inlined in index.html. This gives us the testability of modules with the deployability of a single file.Architecture Layers
The codebase is organized into clear vertical layers, each with a single responsibility:
1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────────┐
│ Input Layer (event listeners) │
├─────────────────────────────────────────────────┤
│ Game Loop (fixed-timestep accumulator) │
├─────────────────────────────────────────────────┤
│ State Machine (idle / playing / gameover) │
├─────────────────────────────────────────────────┤
│ Pure Logic Functions (physics, pipes, collision, scoring) │
├─────────────────────────────────────────────────┤
│ Render Layer (Canvas 2D drawing) │
├─────────────────────────────────────────────────┤
│ Persistence Layer (localStorage wrapper) │
└─────────────────────────────────────────────────┘Design Decision 1: Reference Coordinate System
All game logic operates in a fixed 800×600 reference space. Scale factors (
scaleX, scaleY) are computed once on resize and applied only at render time.1
2
3
4
5
6
// Physics always uses reference coords
const ghost = { x: 100, y: 300, width: 39, height: 39 };
// Scaling happens exclusively in render functions
const screenX = ghost.x * scaleX;
const screenY = ghost.y * scaleY;Why: This decouples gameplay from display resolution. Collision detection, pipe placement, and scoring all work in a consistent coordinate space regardless of whether the player is on a 320px phone or a 4K monitor. It also makes testing trivial — no need to mock viewport dimensions.
Design Decision 2: Fixed-Timestep Game Loop
The game uses an accumulator-based fixed-timestep pattern rather than delta-time scaling:
1
2
3
4
5
while (accumulator >= CONFIG.TICK_MS) {
update(state);
accumulator -= CONFIG.TICK_MS;
}
render(state, canvas, ctx);Why: Delta-time physics introduces floating-point drift and makes collision detection unreliable. Fixed timestep guarantees identical physics regardless of frame rate — a 30fps device and a 144fps display produce the same gameplay. The accumulator is capped at
5 * TICK_MS to prevent a "spiral of death" when the tab is backgrounded.Design Decision 3: Pure Functions for Game Logic
All physics, collision, scoring, and pipe management functions are pure — they take inputs and return new values without side effects:
1
2
3
4
5
6
7
function applyGravity(ghost) {
return { ...ghost, vy: Math.min(ghost.vy + CONFIG.GRAVITY, CONFIG.TERMINAL_VEL) };
}
function applyFlap(ghost) {
return { ...ghost, vy: CONFIG.FLAP_VEL };
}Why: Pure functions are trivially testable in Node.js without any browser environment. They enable property-based testing with fast-check, where hundreds of random inputs verify invariants like "gravity never exceeds terminal velocity" or "collision detection is symmetric."
The state machine functions (
updateIdle, updatePlaying, updateGameOver) mutate the shared state object for performance in the hot game loop — but they compose pure functions internally.Design Decision 4: String-Literal State Machine
Game modes use a string literal (
'idle' | 'playing' | 'gameover') rather than boolean flags:1
2
3
4
5
function update(state) {
if (state.mode === 'idle') { updateIdle(state); return; }
if (state.mode === 'playing') { updatePlaying(state); return; }
if (state.mode === 'gameover') { updateGameOver(state); return; }
}Why: Boolean flags (
isPlaying, isGameOver) create a combinatorial explosion of impossible states. A single mode string makes transitions explicit and prevents bugs like simultaneously being "playing" and "game over."Design Decision 5: One Input Per Frame
Input events set a flag; the flag is consumed exactly once at the start of each physics tick:
1
2
3
4
5
6
7
// Event listener (multiple possible per frame)
canvas.addEventListener('click', () => { state.inputThisFrame = true; });
// Update (consumes once)
const flap = state.inputThisFrame;
state.inputThisFrame = false;
if (flap) { /* apply flap */ }Why: This prevents multiple flaps from rapid clicks within a single frame, ensures deterministic replay, and decouples input timing from physics timing. The game always processes at most one flap per tick.
Design Decision 6: AABB Collision with Shared Geometry
Collision uses axis-aligned bounding boxes (AABB). The
getPipeRects() function returns four rectangles per pipe (top body, top cap, bottom cap, bottom body) and is shared between collision detection and rendering:1
2
3
4
5
6
7
8
function getPipeRects(pipe, canvasH, scoreBarH) {
return [
{ x, y: 0, w: PIPE_WIDTH, h: gapTop - CAP_HEIGHT }, // top body
{ x: capX, y: gapTop - CAP_HEIGHT, w: capW, h: CAP_HEIGHT }, // top cap
{ x: capX, y: gapBottom, w: capW, h: CAP_HEIGHT }, // bottom cap
{ x, y: gapBottom + CAP_HEIGHT, w: PIPE_WIDTH, h: ... }, // bottom body
];
}Why: Sharing geometry between collision and rendering guarantees visual/physical consistency — what you see is what collides. AABB is sufficient for rectangular obstacles and is O(1) per rectangle pair.
Design Decision 7: Render-Time-Only Visual Effects
The idle bob animation and velocity-based tilt are applied at render time, never modifying the game state:
1
2
3
4
5
6
// IDLE: bob offset applied only during drawing
const renderY = state.ghost.y + BOB_AMPLITUDE * Math.sin(state.bobPhase);
drawGhost(ctx, { ...state.ghost, y: renderY }, scaleX, scaleY);
// PLAYING: tilt applied only during drawing
drawGhostWithTilt(ctx, state.ghost, scaleX, scaleY);Why: Keeping visual effects out of the physics state prevents bugs where bob/tilt would affect collision detection or scoring. The state remains clean for logic; rendering adds cosmetic flourishes on top.
Design Decision 8: Defensive Persistence
The localStorage wrapper uses try/catch with silent failure and validates all retrieved values:
1
2
3
4
5
6
7
8
function readHighScore(storage) {
try {
const raw = storage.getItem(CONFIG.LS_KEY);
if (raw === null) return 0;
const parsed = parseInt(raw, 10);
return isNaN(parsed) || parsed < 0 ? 0 : parsed;
} catch { return 0; }
}Why: localStorage can throw in private browsing, be disabled, have quota exceeded, or contain corrupted data. A game should never crash because of storage issues. The injected
storage parameter also enables clean testing without mocking globals.Design Decision 9: Offscreen Canvas for Static Textures
The hand-drawn background texture is generated once into an
OffscreenCanvas and stored as a CanvasPattern:1
2
3
4
5
function createSketchTexture(mainCtx) {
const oc = new OffscreenCanvas(800, 600);
// Draw 400 random strokes once...
return mainCtx.createPattern(oc, 'repeat');
}Why: Generating 400 random strokes every frame at 60fps would be expensive. Pre-rendering to a pattern makes the per-frame cost a single
fillRect call with the cached pattern — essentially free.Design Decision 10: URL Parameter Configuration
All numeric CONFIG values can be overridden via URL query parameters:
1
index.html?GRAVITY=0.3&GAP_HEIGHT=150&PIPE_INTERVAL=60The
parseFloat + !isNaN guard naturally protects string-valued keys like LS_KEY:1
2
3
4
5
6
for (const key of Object.keys(CONFIG)) {
if (params.has(key)) {
const val = parseFloat(params.get(key));
if (!isNaN(val)) CONFIG[key] = val;
}
}Why: This enables difficulty tuning, playtesting, and debugging without editing source code. A designer can share a URL with custom difficulty settings.
Rendering Architecture
The render pass follows a strict 7-layer draw order:
1
2
3
4
5
6
7
1. Sky fill → clears the frame (no ctx.clearRect needed)
2. Sketch texture → pattern overlay at 60% opacity
3. Clouds → semi-transparent, parallax scrolling
4. Pipes → 3-tone body with highlight/shadow strips
5. Ghost → path-drawn character with bob/tilt
6. Score bar → fixed bottom UI strip
7. Overlay → semi-transparent prompt/game-over screenEach layer draws on top of the previous. The sky fill doubles as the frame clear, saving one canvas operation per frame.
Pipes use flat
fillRect calls with color strips rather than createLinearGradient — gradients must be recreated on resize and are expensive to construct per-frame.Testing Strategy
The test suite (200 tests) covers:
| Category | Approach | Count |
|---|---|---|
| Physics | Unit tests + property invariants | ~30 |
| Pipe management | Unit tests for spawn/scroll/cull | ~20 |
| Collision | Boundary + pipe overlap scenarios | ~27 |
| Scoring | Increment logic + idempotency | ~17 |
| Persistence | Storage edge cases + error handling | ~24 |
| Clouds | Initialization ranges + wrapping | ~22 |
| State machine | Transitions + preservation | ~48 |
| Configuration | URL override parsing | ~16 |
Key testing patterns:
- Dependency injection:
spawnPipeaccepts anrngparameter;readHighScoreaccepts astorageobject - Immutability verification: Tests confirm pure functions don't mutate inputs
- Boundary cases: Edge-touching (no collision) vs. 1-pixel overlap (collision)
- Property-based: fast-check generates random inputs to verify invariants hold universally
Performance Considerations
- No per-frame allocations in hot paths: The render loop avoids creating new objects, arrays, or strings
- Pipe culling: Off-screen pipes are removed to keep the array bounded
- Cloud wrapping: Clouds recycle by repositioning rather than destroy/create
- Pattern reuse: The sketch texture is a cached CanvasPattern, not redrawn each frame
- Minimal ctx state changes:
save()/restore()used sparingly, only where needed (globalAlpha, rotation) - Accumulator cap: Prevents runaway catch-up ticks after tab backgrounding
Summary
Flappy Kiro demonstrates that a well-structured vanilla JavaScript game can be simultaneously simple (single file, zero dependencies), testable (200 tests, property-based coverage), and performant (60fps with responsive scaling). The key insight is that separating pure logic from side effects (rendering, storage, input) pays dividends in every dimension: testability, debuggability, and maintainability.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article