Code — how this page is built

Under the hood

The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.


TypingShooterGameview on GitHub ↗

rAF loop falls words at a rising speed; keystrokes lock onto a target word and extend a matched prefix (highlighted); completing it scores, a missed word costs a life.

'use client';

import * as React from 'react';

import { Win98Button } from '@/components/ui/win-98-button';

const POOL =
  'paper ink noise small soft curious quiet drift static field glow ember stone river hum cipher orbit pixel ghost lunar amber relic vapor'.split(
    ' ',
  );
const KEY = 'kaspirius:typing-shooter:best';
const H = 380;

interface W {
  id: number;
  text: string;
  x: number;
  y: number;
}

export function TypingShooterGame() {
  const [status, setStatus] = React.useState<'ready' | 'playing' | 'over'>('ready');
  const [, force] = React.useReducer((n) => n + 1, 0);
  const [score, setScore] = React.useState(0);
  const [lives, setLives] = React.useState(3);
  const [best, setBest] = React.useState(0);

  const r = React.useRef({
    words: [] as W[],
    nextId: 1,
    speed: 0.35,
    lives: 3,
    buffer: '',
    target: null as number | null,
  });

  React.useEffect(() => setBest(Number(localStorage.getItem(KEY)) || 0), []);

  const start = () => {
    r.current = { words: [], nextId: 1, speed: 0.35, lives: 3, buffer: '', target: null };
    setScore(0);
    setLives(3);
    setStatus('playing');
  };

  React.useEffect(() => {
    if (status !== 'playing') return;
    let raf = 0;
    let last = performance.now();
    let acc = 0;
    let died = false;
    const loop = (t: number) => {
      const g = r.current;
      const dt = t - last;
      last = t;
      acc += dt;
      if (acc > 1100) {
        acc = 0;
        g.words.push({ id: g.nextId++, text: POOL[Math.floor(Math.random() * POOL.length)], x: 8 + Math.random() * 78, y: -4 });
        g.speed += 0.012;
      }
      let lost = 0;
      g.words.forEach((w) => (w.y += (g.speed * dt) / 16));
      g.words = g.words.filter((w) => {
        if (w.y > 100) {
          lost++;
          if (w.id === g.target) {
            g.target = null;
            g.buffer = '';
          }
          return false;
        }
        return true;
      });
      if (lost) {
        g.lives -= lost;
        setLives(g.lives);
        if (g.lives <= 0) {
          died = true;
          setScore((s) => {
            setBest((b) => {
              const n = Math.max(b, s);
              localStorage.setItem(KEY, String(n));
              return n;
            });
            return s;
          });
          setStatus('over');
        }
      }
      force();
      if (!died) raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [status]);

  React.useEffect(() => {
    if (status !== 'playing') return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key.length !== 1 || !/[a-z]/i.test(e.key)) return;
      const ch = e.key.toLowerCase();
      const g = r.current;
      if (g.target == null) {
        const cand = g.words.find((w) => w.text[0] === ch);
        if (cand) {
          g.target = cand.id;
          g.buffer = ch;
        }
      } else {
        const w = g.words.find((x) => x.id === g.target);
        if (!w) {
          g.target = null;
          g.buffer = '';
        } else if (w.text[g.buffer.length] === ch) {
          g.buffer += ch;
          if (g.buffer === w.text) {
            g.words = g.words.filter((x) => x.id !== g.target);
            g.target = null;
            g.buffer = '';
            setScore((s) => s + 1);
          }
        }
      }
      force();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [status]);

  const g = r.current;

  return (
    <div className="flex flex-col items-center gap-4">
      <div className="flex gap-8 font-mono text-sm">
        <span>score {score}</span>
        <span>lives {lives}</span>
        <span className="opacity-60">best {best}</span>
      </div>
      <div
        className="relative overflow-hidden rounded-md border border-foreground/20 bg-[#14110b]"
        style={{ width: 'min(92vw, 460px)', height: H }}
      >
        {g.words.map((w) => {
          const isTarget = w.id === g.target;
          return (
            <span
              key={w.id}
              className="absolute -translate-x-1/2 whitespace-nowrap font-mono text-base"
              style={{ left: `${w.x}%`, top: `${w.y}%` }}
            >
              {isTarget ? (
                <>
                  <span className="text-[var(--accent-olive)]">{w.text.slice(0, g.buffer.length)}</span>
                  <span className="text-white">{w.text.slice(g.buffer.length)}</span>
                </>
              ) : (
                <span className="text-white/80">{w.text}</span>
              )}
            </span>
          );
        })}
        {status !== 'playing' ? (
          <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[var(--paper)]/85">
            <div className="text-xl font-bold">{status === 'over' ? 'Game over' : 'Typing Shooter'}</div>
            <Win98Button onClick={start} className="h-10 px-6 text-sm">
              {status === 'ready' ? 'Start' : 'Play again'}
            </Win98Button>
          </div>
        ) : null}
      </div>
      <span className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-45">
        type a word to shoot it down before it lands
      </span>
    </div>
  );
}
↖ kaspirius