# GeekFighter — progress journal

Newest entries at the bottom. One entry per work session/iteration: what changed, what
was verified (with `TEST.smoke` result / screenshot), what's next.

---

## ★ SUMMARY (overnight autonomous build — 2026-07-19, 10 iterations) ★

**GeekFighter is a complete, playable, SF2-faithful 1v1 fighter at https://geekfighter.rian.ca.**
Built from two ripped sprite sheets (Ryu, Blanka — placeholders the owner will replace).

**Done & verified in-browser:**
- Sprite pipeline (`tools/extract.py`): bg + shadow keying, gutter-split → 227 Ryu / 175 Blanka
  frames + atlas + contact sheets. `tools/montage.py` for pose ID.
- Data-driven engine (`public/game.js`, chars in `public/data/*.json`): 60fps fixed step,
  state machine, walk/crouch/jump, all 6 normals + crouch normals + sweep, blocking (high/mid/low),
  motion+charge+mash special parsing, projectiles, pushboxes, best-of-3 rounds/timer/KO.
- Specials: Ryu Hadoken (clean fireball) / Shoryuken / Tatsumaki; Blanka Rolling Ball / Up-Ball /
  Electric (+ spark FX). Throws (unblockable grab, player + AI).
- Game feel: hitstop (damage-scaled), dizzy/stun → skeleton-flash, wake-up invulnerability + getup.
- Presentation: arcade CHARACTER SELECT (1P/CPU or 2P), KO slow-mo, PERFECT / TIME OVER / DOUBLE
  K.O., victory pose, rematch → select; sunset parallax stage; height-scaled shadows.
- AI: reaction-gated & beatable (skill 0.7) — anti-air reversals, anti-fireball, whiff-punish,
  okizeme, jump-ins, zoning. Full AI-vs-AI match verified to a winner (2-1, no exceptions).
- Audio: `public/audio.js` — all SFX synthesized in WebAudio (no external files), unlocks on first
  keypress. Infra: nginx `no-store` + `?v=` script cache-bust (fresh code every reload); `TEST`
  harness (pause/neutral/smoke) for deterministic headless testing.

**Bugs fixed along the way:** electric-on-single-press, up-ball preempted by jump, stale-cached JS
masking all game.js edits, test-harness rAF/AI nondeterminism.

**Optional / not done (owner can restart `/loop` to continue):** per-frame anchor offsets (#3 —
minor limb-shift on wide attack frames), mobile/touch controls (#12), win-quote text screen,
dedicated grab-toss animation frames, AI-difficulty menu. Roster is fixed at 2 until more sprite
sheets are added.

**Loop stopped** after Iter 10 — the core goal ("genuinely playable and faithful") is met and
end-to-end verified; only optional polish remains.

---

## 2026-07-19 — Foundation (initial build)
- Built sprite extractor `tools/extract.py`; extracted 227 Ryu + 175 Blanka frames + atlas.
- Built data-driven engine (`game.js`), character data (`data/*.json`), page shell, testkit.
- Deployed; verified in browser via injected drives: no exceptions over 560 + hadoken tests;
  AI closes distance and deals damage; timer/rounds/HUD render; Hadoken projectile connects.
- Known issues to fix next (see PLAN.md TODO): Blanka idle uses a hunched frame; some frames
  carry baked-in sheet text labels (e.g. Ryu "Hadouken (General)"); frame lists are best-guess
  and need contact-sheet-accurate correction; movesets are partial vs docs/sf2-moves.md.
- Next: TODO #1 (frame-mapping accuracy) and #2 (strip text-label frames).

## 2026-07-19 — Iter 1: core locomotion frame-mapping (TODO #1 partial, #2 partial)
- Built `tools/montage.py` (labeled large-cell montage + text-label flag heuristic) for pose ID.
- Identified correct frames from contact sheets and fixed `data/*.json` anims:
  - Ryu: idle 4-5-6, walk 14-19, crouch **9** (was 30 = a KICK frame!), jump 24/27, block 13,
    crouch_block 10, getup 118/13, victory 44-45.
  - Blanka: idle **4-5** (was 4-5-**6**, where 6 = "Crouch/Jump Start" text frame → the hunched
    look), walk 14-15, crouch **7** (was 15 = standing), block 45, crouch_block 46, hit 22,
    crouch_hit 48, knockdown 29-30-42, ko 50, dizzy/victory 24.
- Verified: `TEST.smoke(1500)` err=null, both deal damage, round 2 reached. Staged idle + crouch
  with AI off + screenshots: Ryu and Blanka idle stances clean & correct; both crouches correct.
- Still open: Blanka jump frame 40 is a poor "banana" pose w/ faint baked-in text — needs a
  better tuck frame; walk cycles not yet visually confirmed as reading like walking; attack &
  special frames still need contact-sheet mapping; the text-label detector missed faint Blanka
  labels (frames 3,6,32,35,36,38,39) — needs improvement before a full re-extract.
- Next (Iter 2): finish Ryu+Blanka normals (LP/MP/HP, LK/MK/HK, crouch variants, sweep) with
  correct pose frames + verify each connects; then Blanka jump frame + walk-cycle visual check.

## 2026-07-19 — Iter 2: Ryu normals + specials frame-mapping (TODO #1 Ryu done, #2 progress)
- Mapped Ryu normals from contact sheets: LP=49 (jab), MP=56 (hook), HP=39 (fierce straight),
  LK=54 (front kick), MK=67 (side kick), HK=50 (high roundhouse), cr_lp=65, cr_lk=64,
  cr_hk sweep=60/61, j_attack=52.
- Cleaned Ryu specials: Hadoken anim → clean thrust [127,130,131] (was [128,129,129]; 129 had
  baked-in "Hadouken (General)" text); fireball projectile → clean travel frames [136,137,138,139]
  (was birth-splash 134/135); Shoryuken → uppercut [47].
- `text_flag` now catches frame 129 in the 126-146 range (detector works on clearer labels).
- Verified: `TEST.smoke(1500)` err=null, both deal damage; HP punch renders as a proper fierce
  straight (screenshot); Hadoken thrust is text-free with an authentic blue fireball mid-flight
  (screenshot). "Hadouken (General)" text bug resolved.
- Next (Iter 3): Blanka normals (LP/MP/HP, LK/MK/HK, crouch, sweep) + specials (rolling ball,
  up-ball, electric) frame-mapping; better Blanka jump frame; walk-cycle visual check for both.

## 2026-07-19 — Iter 3: Blanka normals/specials + 2 critical infra fixes
- Mapped Blanka normals: LP=72(claw) MP=68(lunge) HP=61(reach) LK=73(bite) MK=71 HK=58(slide),
  cr_lp=52 cr_lk=57 cr_hk sweep=64. Specials: rolling ball=[53,54,55] (charge back→fwd+P),
  vertical roll=[55,54], electric=[71,72] (was frame 85 = the *electrocuted* sprite, not attacking).
- Added electric spark FX (drawElectric) rendered while in the electric special.
- FIXED gameplay bug: `mash` special fired on a SINGLE punch (Blanka could never throw a normal
  punch). Now punch presses are tracked every frame in `trackMash()`; electric needs ≥4 presses
  in 30f and can cancel a normal — SF2-faithful. Verified: single punch=normal(61), mash=electric
  @4 presses, ball=rolling_attack, smoke err=null.
- FIXED (critical infra) STALE-CODE: the browser was running a cached game.js, so NONE of the
  game.js edits this iteration were actually loading. Added `Cache-Control: no-store` in nginx.conf
  (container restarted) AND an index.html inline loader that appends `?v=Date.now()` to game.js/
  testkit.js. Reloads now always run fresh code. (This had been silently masking edits.)
- Added test-harness reliability: `TEST.pause/resume` (live rAF loop paused during manual stepping,
  was interleaving and corrupting test state) and `TEST.neutral(x1,x2)` (clean grounded-idle staging,
  AI off — the AI had been leaving fighters mid-jump during single-input tests).
- Next (Iter 4): visual pass on remaining Ryu/Blanka moves (kicks, Shoryuken, Tatsumaki, ball travel,
  up-ball) + walk-cycle check; then improve `text_flag` to catch faint labels and re-extract clean;
  then move to hitstop/knockdown polish and character-select.

## 2026-07-19 — Iter 4: verified all remaining moves + fixed up-ball; TODO #1 done
- Visual/functional pass on every remaining move (all err-free, all render correct poses):
  Ryu HK roundhouse(50), Shoryuken(47 rising, y<ground), Tatsumaki(88-91 spin);
  Blanka slide-kick HK(58), rolling ball(53-55), up-ball(55). Walk cycle verified: forward
  motion +28px/24f, anim cycles frames 14-17.
- FIXED bug: Blanka up-ball (charge-down + up+K) could never fire — the jump check ran before
  the attack check, so up+K always jumped. Reordered handleActions: attack buttons are checked
  first so up+button becomes a charge special; up ALONE still jumps (verified both).
- TODO #1 (frame-mapping accuracy) COMPLETE for both characters. TODO #2 (text labels): the
  frames actually USED are all clean now; improving the detector + full re-extract is now optional.
- Next (Iter 5): pick from remaining TODO — strongest value: hitstop + knockdown/wakeup polish (#6),
  then character-select (#8), throws (#5), and smarter anti-air AI (#9).

## 2026-07-19 — Iter 5: game-feel — hitstop, wakeup invuln, getup, dizzy (TODO #6)
- Hitstop: on a clean hit, `G.hitstop` freezes the whole sim briefly (2f on block, up to 10f on
  heavy hits, scaled by damage) for satisfying impact. Verified hitstop reached 9 on an HP hit.
- Wakeup invulnerability + real getup state: knockdown → `getup` (16f, invuln=20, can't act/be hit)
  → idle. Verified a hit during getup does NOT land.
- Dizzy/stun: hits add to a `stun` meter (decays 0.4/f); ≥48 in a window → `dizzy` state (150f,
  helpless). Renders the iconic SF2 skeleton-flash (Ryu f112) / dazed pose (Blanka f24) — verified
  visually, looks great. Knockdown resets stun.
- Verified: `TEST.smoke(1500)` err=null; hitstop/dizzy/invuln all confirmed via injection + screenshot.
- Next (Iter 6): character-select screen (#8) OR throws (#5). Leaning character-select so the game
  is a complete pick→fight→win loop; then throws, then smarter anti-air AI (#9).

## 2026-07-19 — Iter 6: character-select screen (TODO #8)
- Added a `select` screen (G.screen state): arcade-style "SELECT YOUR FIGHTER" with both fighters'
  idle-portrait boxes, 1P cursor (A/D move, F confirm), optional 2P cursor (←/→, Num4) via the [2]
  1P/2P toggle, CPU auto-confirms alongside P1. Confirm → setupMatch with chosen chars → match.
  Match-over now returns to the select screen (Enter).
- Wired step()/render() to branch on G.screen; setupMatch sets screen='match' (so TEST.reset and
  smoke bypass select cleanly).
- Verified: initial screen=select; smoke err=null; P1 right-move selects Blanka; F confirm starts
  match with P1=Blanka; screenshot shows a clean select screen.
- Next (Iter 7): throws (#5, close 4/6+HP → grab/damage/knockdown), then smarter anti-air AI (#9),
  then round/KO polish (#7) and a nicer stage (#11).

## 2026-07-19 — Iter 7: throws (TODO #5)
- Added data-driven throws: `"throw":{frame,damage,range}` per char (Ryu 40 / Blanka 73).
  Point-blank (dx<46) + toward/away (4 or 6) + MP/HP → unblockable grab: 18 dmg, knockdown,
  opponent flung (vx 4.6, vy up). At range the same input is a normal punch (verified both).
- AI now throws at close range (~28% when dist<44) via a 'throw' decision (toward+HP).
- Verified: point-blank forward+HP → p2 knockdown +18 dmg + flung; ranged forward+HP → normal
  punch, no knockdown; `TEST.smoke(1200)` err=null (AI using throws); screenshot shows the toss.
- Note: throw uses a lunging-punch pose as the grab approximation (no dedicated grab-toss frames
  identified yet) — a candidate for later visual polish.
- Next (Iter 8): smarter/beatable AI (#9 — anti-air with dp/up-ball, react to fireballs, whiff
  punish, wake pressure), then round/KO presentation (#7) and a nicer stage (#11).

## 2026-07-19 — Iter 8: smarter, beatable AI (TODO #9)
- Rewrote updateAI as a reaction-gated, state-aware AI (skill=0.7 default, tunable):
  anti-air (reversal shoryuken/vertical_roll, else crouch-HP) on descending jump-ins;
  anti-projectile (jump over at range / block up close); whiff-punish on opponent's attack
  recovery; okizeme (close in + strike/throw on wake); jump-in attacks while airborne;
  fireball zoning at range (Ryu); spacing/poke/approach/retreat/throw otherwise.
  `aiSpecial()` lets the CPU trigger specials directly (no motion sim). Reaction cadence
  scales with (1.5 - skill) so lower skill = slower.
- Verified: `TEST.smoke(1200)` err=null with round transitions (lively AI-vs-AI, damage/throws);
  anti-air path 16/16 at skill 1.0, ~2/12 at 0.7 (beatable); mid-fight screenshot shows both
  trading at close range with health depleting.
- Next (Iter 9): round/KO presentation polish (#7 — KO freeze/slow-mo, perfect/time-over,
  win-quote beat) and a nicer parallax-ish stage (#11).

## 2026-07-19 — Iter 9: KO/round presentation (#7) + nicer stage (#11)
- KO polish: PERFECT (no-damage round win), TIME OVER (clock→0, higher HP wins), DOUBLE K.O.
  (draw), plus KO slow-motion (46 frames at ~half speed via a step-skip). Verified all messages
  + slowmo=46 on KO; smoke err=null (matches complete through slow-mo).
- Stage: replaced the flat gradient with a sunset arena — sky gradient + sun, parallax mountain
  silhouettes + a building skyline with lit windows, a plank floor with perspective lines +
  horizon highlight, and fighter shadows that shrink with jump height. Subtle parallax from the
  fighters' midpoint. Screenshot confirms a clean, characterful stage.
- Nearly all TODO done. Remaining: per-frame anchors (#3, polish), audio (#10, must be
  self-synthesized WebAudio — no external files), mobile/touch (#12, stretch).
- Next (Iter 10): self-synthesized WebAudio SFX (punch/kick/hit/block/jump/fireball/KO), then
  per-frame anchor offsets, then a wind-down summary.

## 2026-07-19 — Iter 10: sound effects (TODO #10, WebAudio-synthesized)
- Added `public/audio.js`: SFX synthesized entirely in WebAudio (oscillators + filtered noise),
  NO external files. Sounds: whiff (per strength), hit thud, block clink, jump, fireball, special,
  KO stinger, dizzy, round chime. Stays silent + cheap until the AudioContext is unlocked by a real
  keydown/pointer gesture, so it never fires or errors during headless tests.
- Hooked into game events: attack whiff, special, throw/hit, block, dizzy, projectile spawn, jump,
  KO, and the FIGHT! cue. All guarded with `window.SFX&&`.
- Verified: SFX loaded; every SFX fn callable without error; `TEST.smoke(1200)` err=null (SFX fired
  hundreds of times, silent, no exceptions). Audibility is best confirmed by playing in a real tab.
