DEV Community

Prateek Mohan
Prateek Mohan

Posted on

I Turned Flashcards Into a Zombie FPS, a Runner Game, and Tetris — Here's How

Cross-posted from my dev.to series on building PatternMaster, an AI-powered DSA learning platform.


Most "gamified" learning apps use games as a reward. Finish your lesson → get to play a little minigame for 30 seconds. It's basically digital candy. The studying and the game are completely separate things.

I wanted to flip that. What if the game was the studying? What if every zombie you shoot is carrying one of your flashcard questions, and the body part you aim for determines which answer you pick? What if the wrong lane in a runner game costs you health? What if your Tetris pieces are literally labeled with multiple-choice answers?

That's what I built. Three full games — a 3D zombie FPS, a Subway Surfers-style runner, and Tetris — all powered by the same flashcard content you're already studying. The same cards you're reviewing with spaced repetition show up as game questions. Study hard = better at the games. It's a loop that actually closes.

Here's how it works under the hood.


The Core Insight: Your Flashcards ARE the Game Content

Before I get into any specific game, let me explain the architecture decision that makes all of this possible, because it's the thing that took me the longest to figure out.

Early in development, I had two separate systems: a review card system and a game question system. The game questions were AI-generated specifically for gameplay, and they were completely separate from your spaced repetition cards. This meant:

  • Generating questions twice (once for review, once for games)
  • Managing two databases
  • Questions getting out of sync
  • Extra AI API calls burning through my quota

The fix was obvious in retrospect: review cards ARE game questions. The conversion is trivial:

// client/src/games/questions/provider.ts

/** Convert a review card to a game question — no AI needed */
export function reviewCardToQuestion(card: ReviewCard): ZombieQuestion | null {
  if (card.type !== 'concept') return null;

  const options = card.options ?? [card.back];
  const correctIndex = card.correctIndex ?? 0;

  return {
    id: `rc:${card.id}`,
    source: card.source === 'seed' ? 'review_card' : 'ai_card',
    originCardId: card.id,
    originCardLabel: card.tags?.[0] ?? card.unitId,
    originCardType: 'concept',
    questionText: card.front,   // front of card → question prompt
    answer: card.back,          // back of card → correct answer
    options,
    correctIndex,
    explanation: card.back,
    xpValue: 10,
  };
}
Enter fullscreen mode Exit fullscreen mode

The comment in the file says it best: "Phase 2 simplification: review cards ARE the game questions." No separate generation. Your card front becomes the question. Your card back becomes the answer. Any distractor options the card already has get used; if it doesn't have enough, we pull from other cards' backs to generate them on the fly.

The question pool for all three games is computed reactively via a shared hook:

// client/src/games/shared/useSharedQuestionSetup.ts

export function useSharedQuestionSetup(reviewCards: ReviewCard[]): SharedQuestionSetup {
  const selectedCardIds = useStore((s) => s.activeCardIds);

  // Compute question pool reactively from review cards — no AI, instant
  const questionPool = useMemo(() => {
    const conceptCards = reviewCards.filter(c => c.type === 'concept');
    const raw = buildQuestionPool(conceptCards);
    return ensureDistractors(raw, reviewCards);
  }, [reviewCards]);

  // Filter to only questions matching selected cards
  const filteredPool = useMemo(() => {
    if (selectedCardIds.length === 0 && questionPool.length > 0) return questionPool;
    const selectedSet = new Set(selectedCardIds);
    const matched = questionPool.filter((q) => q.originCardId && selectedSet.has(q.originCardId));
    return matched.length > 0 ? matched : questionPool;
  }, [questionPool, selectedCardIds]);

  return { questionPool, filteredPool, selectedCardIds, ... };
}
Enter fullscreen mode Exit fullscreen mode

Every game calls this same hook. Change your review card selection, and all three games update instantly. No regeneration, no loading spinner, no extra API cost.

Play Hub showing all three games


The Hyper Zombie FPS

This is the main event. A full 3D first-person zombie shooter built in vanilla Three.js (not React Three Fiber — the engine needed fine-grained control that a declarative wrapper would have made painful).

The pitch: zombies walk toward you. Each zombie has a question. Its five body parts correspond to the five answer options. You shoot the correct body part. Wrong answers cost health. Right answers kill the zombie and give you XP that feeds back into your spaced repetition system.

Hyper Zombie FPS setup screen

The 5-Zone Body System

This is the mechanic that makes the whole game idea work. Each zombie's body is divided into five hitboxes:

// client/src/games/hyper/types.ts

export type ZoneKey = "head" | "chest" | "leftArm" | "rightArm" | "legs";

export const ZONE_TO_ANSWER_INDEX: Record<ZoneKey, number> = {
  head: 0,      // Answer A
  chest: 1,     // Answer B
  leftArm: 2,   // Answer C
  rightArm: 4,  // Answer E
  legs: 3,      // Answer D
};

export const ZONE_LABEL: Record<ZoneKey, string> = {
  head: "A",
  chest: "B",
  leftArm: "C",
  rightArm: "E",
  legs: "D",
};
Enter fullscreen mode Exit fullscreen mode

Each zone has a 3D hitbox in the zombie model's local space, with a slight forgiveness multiplier so shots that barely clip the edge still register:

// client/src/games/hyper/engine/constants.ts

export const HITBOX_FORGIVENESS_SCALE = 1.3;
export const ZONE_HITBOXES: Record<ZoneKey, { offset: [number,number,number]; size: [number,number,number] }> = {
  head:     { offset: [0,    0.90, 0],    size: [0.18, 0.18, 0.18] },
  chest:    { offset: [0,    0.65, 0],    size: [0.30, 0.25, 0.20] },
  leftArm:  { offset: [-0.22, 0.62, 0],  size: [0.12, 0.30, 0.12] },
  rightArm: { offset: [ 0.22, 0.62, 0],  size: [0.12, 0.30, 0.12] },
  legs:     { offset: [0,    0.25, 0],    size: [0.25, 0.35, 0.18] },
};

// Each zone gets a color for the floating answer label
export const ZONE_COLOR_MAP: Record<ZoneKey, string> = {
  head: '#ef4444',      // red — Head = A
  chest: '#3b82f6',     // blue — Chest = B
  leftArm: '#22c55e',   // green — Left Arm = C
  rightArm: '#f59e0b',  // amber — Right Arm = E
  legs: '#8b5cf6',      // violet — Legs = D
};
Enter fullscreen mode Exit fullscreen mode

The engine supports two rigging formats. Kenney-style models (the low-poly pack) use node-name-based detection, walking up the parent chain until you hit a recognized name. Mixamo/FBX-style models use bone prefix matching. Both fallback to the same zone system:

// client/src/games/hyper/engine/types.ts

/** Walk up the parent chain of a mesh to find a named body-part node (Kenney rigs) */
export function getZoneFromNodeParentChain(object: THREE.Object3D): ZoneKey | null {
  let current: THREE.Object3D | null = object;
  while (current) {
    const zone = NODE_NAME_TO_ZONE[current.name];
    if (zone) return zone;
    current = current.parent;
  }
  return null;
}

// Bone name prefix → ZoneKey for mixamo rigs
export const BONE_ZONE_PREFIXES: [string, ZoneKey][] = [
  ["mixamorigHead", "head"], ["mixamorigNeck", "head"],
  ["mixamorigSpine", "chest"], ["mixamorigHips", "chest"],
  ["mixamorigLeftArm", "leftArm"], ["mixamorigLeftForeArm", "leftArm"],
  ["mixamorigRightArm", "rightArm"], ["mixamorigRightForeArm", "rightArm"],
  ["mixamorigLeftUpLeg", "legs"], ["mixamorigLeftLeg", "legs"],
  // ...
];
Enter fullscreen mode Exit fullscreen mode

Slow-Motion Focus: The "Matrix" System

When you aim your crosshair at a zombie that has a question loaded, time slows to 12% speed. Enough to read the question floating above the zombie, see which answer maps to which body part, and take your shot. It's a mechanic I'm genuinely proud of — it makes the game feel like a power fantasy AND it actually gives you time to think about the answer.

// client/src/games/hyper/HyperGameEngine.ts (simplified)

// Heavy slow-mo (0.12) when a zombie question is focused — gives time to read & answer
// Both PC and mobile get this effect
const hasQuestion = this.focusedZombieIndex !== -1;
const matrixTarget = hasQuestion && !this.gameState.isShooting
  ? 0.12  // 88% slow — lots of time to read question and pick answer
  : 1;

const blendSpeed =
  matrixTarget < this.simTimeScale
    ? ENGINE_CONFIG.MATRIX.ENTER_SPEED   // snap into slow-mo fast
    : ENGINE_CONFIG.MATRIX.EXIT_SPEED;   // ease out slower

this.simTimeScale = THREE.MathUtils.lerp(
  this.simTimeScale,
  matrixTarget,
  Math.min(1, rawDelta * blendSpeed),
);
delta = rawDelta * this.simTimeScale;
Enter fullscreen mode Exit fullscreen mode

The sim time scale affects everything in the game loop: bullet speed, zombie movement, animation mixers, particle systems. The sky phase manager deliberately uses real time, not sim time — you don't want day/night transitions stuttering during slow-mo.

18 Weapons, 9 Enemy Types

Each of the 18 weapons (blaster-a through blaster-r) has completely differentiated stats — damage, ammo capacity, fire rate, reload time, bullet spread, sound, and even visual properties like bullet tracer color and muzzle flash color:

// client/src/games/hyper/types.ts (excerpt)

export const WEAPON_MODELS: Record<WeaponType, WeaponModelConfig> = {
  "blaster-a": {
    name: "Pulse Pistol",
    damage: 10, maxAmmo: 12, shootCooldown: 0.20, reloadTime: 1.5,
    description: "Reliable starter sidearm",
    bulletColor: "#00ffcc", muzzleColor: "#00ffcc",
    bulletSize: 1.0, recoilKick: 0.15, spread: 1,
    fireSound: "pew"
  },
  "blaster-b": {
    name: "Ion Rifle",
    damage: 7, maxAmmo: 30, shootCooldown: 0.10, reloadTime: 2.2,
    description: "Full-auto assault rifle",
    bulletColor: "#3b82f6", muzzleColor: "#60a5fa",
    bulletSize: 0.8, recoilKick: 0.08, spread: 2,
    fireSound: "burst"
  },
  // ...16 more
};
Enter fullscreen mode Exit fullscreen mode

The enemy types range from easy Zombies to tanky Orcs and Keepers. Each has its own model, scale, speed/health/damage multipliers, and animation clips. The Vampire sprints at 1.5x base speed. The Orc has 2x health but moves at 0.8x speed. The Ghost has half health but moves at 1.8x — fragile but fast.

orc: {
  name: "Orc", scale: 4.6, speed: 0.8, health: 2.0, damage: 2.0,
  difficulty: "hard",
  walkAnim: "walk", hitAnim: "hit-react",
},
vampire: {
  name: "Vampire", scale: 4.0, speed: 1.5, health: 1.2, damage: 1.5,
  walkAnim: "sprint",  // vampires have a sprint animation instead of walk
},
Enter fullscreen mode Exit fullscreen mode

Procedural Worlds with Seeded RNG

Every map is procedurally generated from a seed number. Same seed = same world, every time. The WorldGenerator picks props based on the active theme (graveyard, urban, wasteland, fortress, or random), places them using a weighted probability system, and registers their bounding boxes with the collision system:

// client/src/games/hyper/WorldGenerator.ts

export type WorldTheme = 'graveyard' | 'urban' | 'wasteland' | 'fortress' | 'random';

const GRAVEYARD_PROPS: PropDef[] = [
  { path: 'cross.glb',        weight: 5, baseScale: 3.0 },
  { path: 'coffin.glb',       weight: 2, baseScale: 3.0 },
  { path: 'crypt.glb',        weight: 1, baseScale: 4.5, large: true },
  { path: 'altar-stone.glb',  weight: 2, baseScale: 3.0 },
  { path: 'fence.glb',        weight: 4, baseScale: 3.0 },
  { path: 'fire-basket.glb',  weight: 3, baseScale: 3.0 },
];

const FORTRESS_PROPS: PropDef[] = [
  { path: 'column-large.glb',    weight: 3, baseScale: 4.5, large: true },
  { path: 'fence-fortified.glb', weight: 4, baseScale: 3.5 },
  { path: 'brick-wall.glb',      weight: 3, baseScale: 3.5 },
];
Enter fullscreen mode Exit fullscreen mode

The seeded RNG (mulberry32) ensures reproducibility. If you share a seed with a friend, you both get the exact same map layout. This matters for the multiplayer mode.

Dynamic Day/Night: SkyPhaseManager

The world has five lighting phases — dawn, day, sunset, dusk, night — each with completely different sky gradients, fog density, ambient and directional light colors, and tone mapping exposure. They transition smoothly over time:

// client/src/games/hyper/engine/SkyPhaseManager.ts

export const SKY_PHASES: Record<SkyPhase, SkyPhaseConfig> = {
  dawn: {
    skyTop:      new THREE.Color(0x1a0a2e),  // deep purple
    skyHorizon:  new THREE.Color(0xff7744),  // orange glow
    fogDensity: 0.007,
    ambientColor: new THREE.Color(0xffaa88),
    ambientIntensity: 0.5,
    exposure: 2.2,
  },
  night: {
    skyTop:      new THREE.Color(0x020210),  // near black
    skyHorizon:  new THREE.Color(0x0a0a30),  // dark blue
    fogDensity: 0.010,                       // denser fog at night
    ambientColor: new THREE.Color(0x4466aa),
    ambientIntensity: 0.25,
    exposure: 1.8,
  },
  // dawn, day, sunset, dusk in between
};
Enter fullscreen mode Exit fullscreen mode

Night mode is genuinely spooky. The fog density cranks up and the ambient light drops to barely-visible, which creates this oppressive atmospheric pressure. Perfect for a zombie game.

Games Lab showing Hyper Zombie


P2P Multiplayer via Trystero — Zero Server Cost

The multiplayer system uses Trystero, which creates WebRTC connections routed through free public Nostr relays. No dedicated game server. No monthly bill. Just peer-to-peer.

// client/src/games/hyper/MultiplayerClient.ts

/**
 * MultiplayerClient — P2P multiplayer for Hyper Zombie via Trystero (WebRTC).
 * No server required. Players connect directly via free Nostr relays.
 *
 * - Send player position at ~20Hz
 * - Receive + interpolate other players' positions
 * - Render remote players as colored capsules with nametag sprites
 */

import { joinRoom, type Room } from 'trystero/nostr';

const PLAYER_COLORS = [0x3b82f6, 0x22c55e, 0xf59e0b, 0xef4444, 0x8b5cf6, 0x06b6d4];
Enter fullscreen mode Exit fullscreen mode

Each remote player is rendered as a colored capsule mesh with a canvas-generated nametag sprite. Position updates go out at 20Hz, zombie state sync from host to guests at 10Hz. The host owns the zombie simulation; guests just shoot and their hit events get sent back to host for validation.

Is it going to work great with 200ms latency? No. But for 2-4 players on decent connections, it's actually pretty playable. And it cost literally nothing to host.

Lab Multiplayer view


The Runner Game

Think Subway Surfers but every gate you hit has a multiple-choice question. Run through the correct lane. Three lanes = three answer options. Wrong lane = health damage. Right lane = XP.

Runner game setup

The entire game is a pure functional state reducer. No classes, no mutation. Every frame is (state, action) → { state, events }:

// client/src/games/runner/module/engine/reducer.ts

export const engineReducer = (
  state: EngineState,
  action: EngineAction,
  questions: Question[] = []
): { state: EngineState, events: RunnerEvent[] } => {
  const events: RunnerEvent[] = [];
  let newState: EngineState = {
    ...state,
    player: { ...state.player },
    segments: [...state.segments],
    activeEffects: [...state.activeEffects],
  };

  if (action.type === 'START') {
    const seed = action.seed ?? Math.floor(Math.random() * 1000000);
    newState = getInitialState(seed, difficulty);
    newState.status = 'PLAYING';
    events.push({ type: 'GAME_STARTED', payload: { gameId: 'runner' } });
  }
  // ...
};
Enter fullscreen mode Exit fullscreen mode

The slowdown mechanic is the satisfying part. When a question gate is coming up, the game triggers a slow-motion effect:

// client/src/games/runner/module/engine/constants.ts

export const CONSTANTS = {
  BASE_SPEED: 12,
  MAX_SPEED: 22,
  QUESTION_SLOWMO_MS: 2000,
  QUESTION_SLOWMO_SCALE: 0.05,  // 95% slowdown
  GATE_VISIBLE_DIST: 104,        // you can see it coming from 104 units away
  QUESTION_MIN_Z_GAP: 140,       // questions every 140-245 units
  QUESTION_MAX_Z_GAP: 245,
  WRONG_ANSWER_DAMAGE: 5,
  // Power-ups:
  POWERUP_SPAWN_CHANCE: 0.3,
};
Enter fullscreen mode Exit fullscreen mode

Power-ups drop randomly: magnet (pulls coins), shield (one free wrong answer), score multiplier (2x), jetpack (lifts you over obstacles), and superjump. Each has a duration timer. Multiple power-ups can be active simultaneously.

The map system is also interesting. The runner can either procedurally generate segments, or play hand-authored maps defined in a RunnerMapOverlay format with explicit obstacle placements, spawn points, and pickup spots. Both modes feed into the same segment spawner and reducer.

Lab Runner


Tetris

Tetris is the most conceptually elegant implementation of the three games. Each tetromino is labeled with an answer option. Multiple-choice question appears at the top. The correct answer piece has special behavior — when it locks, it triggers a bomb explosion that clears surrounding rows.

Wrong answer pieces pile up normally. The pressure is intrinsic: if you keep answering wrong, your board fills up.

// client/src/games/tetris/module/engine/engine.ts

export function createInitialState(seed: number = Date.now()): GameState {
  let s = seed;
  // 7-bag randomizer for fair piece distribution
  const bag = shuffleBag(['I', 'J', 'L', 'O', 'S', 'T', 'Z'], s);

  return {
    phase: 'idle',
    board: Array.from({ length: 20 }, () => Array(10).fill(null)),
    activePiece: null,
    nextPieces,
    bag,
    score: 0,
    level: 1,
    currentQuestionId: null,
    currentQuestionPrompt: null,
    questionChoices: null,
    selectedChoiceId: null,
    lastAnswerCorrect: null,
    // ...
  };
}
Enter fullscreen mode Exit fullscreen mode

The piece assignment is deterministic. Given a question and choice, the piece shape is always the same (via a string hash), so you get predictable "I = correct answer A" associations over time. This might actually help memory formation — the muscle memory of the piece shape reinforces the answer.

function deterministicPiece(choice, questionId, index): string {
  if (choice.pieceId && TETROMINOES[choice.pieceId]) {
    return choice.pieceId;
  }
  const keys = Object.keys(TETROMINOES);
  return keys[hashString(`${questionId}:${choice.id}:${index}`) % keys.length] ?? 'T';
}
Enter fullscreen mode Exit fullscreen mode

The question cycling is worth mentioning. Questions cycle through in shuffled order, reshuffle when exhausted, and track how many full cycles you've done. So if you have 8 review cards loaded, you'll see all 8 before repeating any — the same shuffled-deck principle as Anki.

Lab Tetris


The Shared Infrastructure

All three games use the same GameSetup component for card selection, the same GameResultsOverlay for post-game stats, and the same event tracking system:

// client/src/games/events/types.ts — events emitted by all games

type GameEvent =
  | { type: 'GAME_STARTED';    payload: { gameId: string } }
  | { type: 'QUESTION_HIT';    payload: { correct: boolean; cardId: string; xp: number } }
  | { type: 'GAME_OVER';       payload: { score: number; xp: number; timeMs: number } }
  | { type: 'POWERUP_COLLECT'; payload: { type: PowerUpType } }
Enter fullscreen mode Exit fullscreen mode

Game results feed directly back to the SM-2 spaced repetition state. Answer a question correctly in the zombie game → that card's efactor improves, interval extends, due date pushed further out. Answer wrong → interval resets, card comes back sooner. The games aren't separate from the review system; they're another input to it.

There's also Cosmo, the mascot. He has 12 animated states (idle, celebrate, confused, thinking, hint, wave, etc.), and shows up in the game setup screens reacting to what you're doing. GameCosmo.tsx maps game phases to animation states. It's a small touch but it makes the whole experience feel more coherent.


Performance on Mobile

All three games have to run on mobile. That's non-negotiable for a learning app — people do their review sessions on the bus, between classes, anywhere. For the 3D game, this meant a lot of work.

The engine detects low-end devices via navigator APIs:

// client/src/games/hyper/engine/constants.ts

RENDERER: {
  PIXEL_RATIO_MAX: 1.5,          // cap pixel ratio even on high-DPI screens
  LOW_DEVICE_MEMORY_GB: 4,       // 4GB or less = low quality mode
  LOW_DEVICE_THREADS: 4,         // 4 hardware threads or less = low quality mode
},
Enter fullscreen mode Exit fullscreen mode

In low quality mode: shadow maps disabled, LOD culling more aggressive, post-processing (bloom, vignette, chromatic aberration) stripped. The same SCALE constant (0.55) shrinks the entire world proportionally — spawn distances, bullet ranges, arena size — so mobile doesn't have to render as far:

const SCALE = 0.55;

export const ENGINE_CONFIG = {
  CAMERA: { FAR: 800 * SCALE },         // only render 440 units instead of 800
  ZOMBIE: { SPAWN_DISTANCE: 20 * SCALE }, // zombies spawn 11 units away, not 20
  BULLET: { MAX_DISTANCE: 200 * SCALE }, // bullets despawn at 110, not 200
};
Enter fullscreen mode Exit fullscreen mode

World LOD culling happens every frame: props beyond a certain distance get visibility toggled off. The PerfLogger module tracks frame time against a budget and emits warnings when we're dropping below 30fps.

Mobile also gets a completely different control scheme — a virtual joystick for movement (left thumb), virtual look input (right thumb), and on-screen shoot/reload/ability buttons. The MobileManager handles touch events separately from InputManager's keyboard/mouse handling.


What I Learned

Browser 3D is genuinely capable now. When I started this project I wasn't sure if a real-time 3D FPS with procedural worlds was feasible in a browser. It is. Three.js with proper instancing, LOD, and texture atlasing gets you pretty far. The renderer caps at 1.5x pixel ratio and it looks fine.

Pure reducer architecture scales. The runner game's state machine started small and grew to handle 10+ action types, multiple effect systems, and complex collision logic. Because it's a pure function — same input always produces same output — it was trivially testable and easy to reason about. I wish I'd applied the same pattern to the Tetris engine from the start.

Seeded RNG solves a lot of problems. Every game uses deterministic seeded random number generation. This means replays are possible, multiplayer maps are synchronized from a single seed, and bugs are reproducible. I used mulberry32 — a simple 32-bit pseudo-RNG that's fast and gives good distribution.

P2P multiplayer is surprisingly accessible. Trystero abstracts away the WebRTC complexity. You call joinRoom(), you get action/state channels, you broadcast. The hard part isn't the networking — it's deciding who owns what state. In my case, the host owns zombie simulation and guests own their own player positions. Conflict resolution is "host wins." Simple.

The games are better than I expected. I thought the zone-based shooting mechanic would feel clunky in practice. It doesn't. Having a visual mapping between body part → answer letter gives your brain a spatial anchor, and the slow-motion focus system gives you time to actually read and think. I've caught myself remembering "B = Chest = Binary Search time complexity is O(log n)" from a session I played three days ago. That's the whole point.


What's Next

The three games are working. The next thing I want to tackle is a proper leaderboard and session history — right now you see your post-game score but there's no persistent record of "you got this question wrong 4 times in the zombie game." Connecting game-specific mistakes back to the review system more explicitly would close the loop even tighter.

I'm also thinking about adding a fourth game: a Space Invaders variant where columns of alien ships are labeled A-E and you shoot the column matching the correct answer. The codebase already has a skeleton implementation (/play/invaders) but it's not hooked into the shared question pool yet.

If you want to poke around the code yourself, the project is on GitHub. The games lab at /games-lab runs in no-auth mode with static demo questions, so you can see the games without needing an account.


Part 3 will cover the AI tutor, the spaced repetition algorithm, and the lesson generation system — the backend of the learning loop that makes the games worth playing.

Part 1: How I Built an AI-Powered DSA Learning Platform

Top comments (0)