-- NORMAL -- ~/projects/typomaniac.md project 01 / 06

typomaniac

Two keyboards facing a tower: every letter is timestamped on the player’s side, but it’s the server that decides.

Stack
TypeScript · Elysia · Bun · React 19
Year
2026
Repository
github.com/Popoch39/typomaniac ↗
A

Anatomy

7 parts · only one counts
Fig. 1 Duel bench with a referee tower
bun · elysias literature loiterature longe
  1. Serve the whole game

    A single Bun server holds the queue, the duels in memory and the whole API.

    DuelQueue

    99const duelQueue = new DuelQueue({

    100 clock: config.clock,

    101 store: duelStore,

  2. 02BATCH_MSL792–823 ↗

    Timestamp each keystroke

    Each letter is timestamped, shown at once, then sent in 50 ms batches.

    pressed()

    794const pressed = (store: DuelStore, key: Key, now: number): Pick<DuelStore, "state" | "cues"> => {

    795 const { state } = store;

    797 if (state.phase !== "running") {

  3. 03/api/duelL17–52 ↗

    Connect the two players

    One WebSocket per tab: you send your keystrokes and receive your opponent’s.

    duelModule()

    20export const duelModule = ({ auth, trustProxy, queue, friendsLive }: DuelModuleConfig) =>

    21 new Elysia({ name: "duel", seed: queue }).use(authentication(auth, { trustProxy })).ws("/duel", {

    22 auth: true,

  4. 04RunningDuelL423–522 ↗

    Referee the duel

    The server keeps the duel in memory, judges the round at 30.4 s and restarts 7 s later.

    endRound()

    428endRound(judgedAt: number, draw: () => number): RoundEnd {

    429 const endedAt = this.#round.startsAt + this.#durationMs;

    430 const ended = this.#endRound(endedAt);

  5. 05acceptance.tsL21–68 ↗

    Check keystroke timestamps

    Rejects a keystroke stamped before the start, after it arrived, out of order or too late.

    rejectReason()

    22const rejectReason = (

    23 acceptedRun: AcceptedRun,

    24 { at }: Keystroke,

  6. 06isDuelDecided()L21–67 ↗

    Name the winner

    The higher score takes the round, then accuracy; two rounds won take the duel.

    roundOutcome()

    22export const roundOutcome = (first: RoundSide, second: RoundSide): Outcome =>

    23 outcomeOf(first.score - second.score || first.result.accuracy - second.result.accuracy);

    27export const roundsWon = (rounds: readonly RoundSides[]): [number, number] => {

  7. 07applyTp()L164–206 ↗

    Climb the rankings

    A win is worth 8 to 35 rank points, 20 between equals; 100 points earn a promotion.

    tpDelta()

    167export const tpDelta = (

    168 standing: Standing,

    169 mmr: number,

round 2/3 · 18.6 s · A 1–0 B
Note view

Click keyboard A or type a letter: it races to the tower.Tap keyboard A: each press sends a letter to the tower.

Fig. 1 — Each keystroke leaves its keyboard timestamped, in 50 ms batches. The tower rejects it if its timestamp comes before the start or after its arrival, goes backwards, or runs past the round’s 30 s (with 400 ms of tolerance). It judges each round on score, then on accuracy, and the first to win two rounds moves the TP; the MMR stays hidden.

Parts list

  1. 01 Base Bun + Elysia: a single process holds the queue, the in-memory duels and every API route. Code: apps/api/src/app.ts, lines 97–182 ↗
  2. 02 Keyboards A and B React 19 clients: each keystroke is timestamped, shown on screen straight away, then batched. Code: apps/frontend/src/stores/duel-store.ts, lines 792–823 ↗
  3. 03 Cables The /api/duel WebSocket, one per tab: keystroke batches on the way out, the opponent’s keystrokes and resyncs on the way back. Code: apps/api/src/modules/duel/index.ts, lines 17–52 ↗
  4. 04 Referee tower The live duel, in memory: takes in every batch, forfeits an inhuman typing rate, judges the round at 30.4 s and opens the next one 7 s later. Code: apps/api/src/modules/duel/running-duel.ts, lines 423–522 ↗
  5. 05 Checkpoint The four timestamp checks on a keystroke: before the start, after its arrival, out of order, after the end. Code: packages/typing-engine/src/acceptance.ts, lines 21–68 ↗
  6. 06 Round lamps The higher score wins the round, then accuracy; two rounds won or three played decide the duel. Code: packages/typing-engine/src/outcome.ts, lines 21–67 ↗
  7. 07 Mast The duel’s TP, kept between 8 and 35; promotion at 100 TP with a shield, demotion to 75 TP; the MMR stays hidden. Code: packages/ranked/src/ranked.ts, lines 164–206 ↗