AWS Builder Center

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 only
The 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=60
The 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 screen
Each 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:
CategoryApproachCount
PhysicsUnit tests + property invariants~30
Pipe managementUnit tests for spawn/scroll/cull~20
CollisionBoundary + pipe overlap scenarios~27
ScoringIncrement logic + idempotency~17
PersistenceStorage edge cases + error handling~24
CloudsInitialization ranges + wrapping~22
State machineTransitions + preservation~48
ConfigurationURL override parsing~16
Key testing patterns:
  • Dependency injection: spawnPipe accepts an rng parameter; readHighScore accepts a storage object
  • 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.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article