Jev decided in 1.0s and landed a clean hit. The LLM was still composing its reasoning when the deadline hit — frozen, exposed, -30%.
Two silhouette fighters, one fight. Every beat, Fighter A's move comes from a real Jev typed-decision call. Fighter B's move comes from a real general-purpose LLM (Claude Haiku) prompted for prose reasoning and a parsed final move. Both get the exact same state and the exact same deadline. Whoever doesn't answer in time hesitates — visibly frozen, open to a hit.
That gap, measured live on screen, is the entire point. Nothing here is scripted or pre-baked — every number on the HUD comes from a real network call, every beat. Full spec: DUEL_ARCHITECTURE.md.
Local-only by design. This isn't meant to be deployed publicly — clone it, run it locally with your own API key, and record your own footage. This repo is what gets shared, not a hosted URL.
Not an assertion — measured, from a real recorded session against the real proxies. Your own numbers will vary (that's the point — the HUD measures live), but the shape of the gap is structural, not a fluke:
| 🩵 Jev | 💗 The LLM (Claude Haiku) | |
|---|---|---|
| How it decides | Typed answer — { choice, score, noul } |
Prose reasoning, then a parsed MOVE: line |
| Avg. latency | ~1.0 – 1.2s | ~2.7 – 3.0s |
| Missed deadlines (one session) | 0 | 15 |
| Cost (one 15-min session) | $0.00227 | $0.035 — ~15× more |
| Tokens used (same session) | 61K | 25K — fewer tokens, still costlier |
The last row is the tell: Jev processed more tokens and still cost 15× less, because a typed answer has no output tokens to pay for. Every word the LLM writes — including the reasoning you never see truncated in the HUD — is billed. Structured output isn't a trick to make the LLM look bad; it's just architecturally cheaper and faster for this kind of decision.
flowchart TD
A["Beat clock — ~3.8s cadence"] --> B["Shared combat state"]
B --> C["Jev proxy — /api/jev<br/>(OpenRouter alpha decisions)"]
B --> D["LLM proxy — /api/llm<br/>(OpenRouter chat completions)"]
C -->|"typed answer, ~1.0-1.2s"| E["Deadline gate — 3000ms"]
D -->|"prose + parsed move, ~2.4-3s+"| E
E -->|"on time → apply move"| F["Animation state machine<br/>(pure time-based tween)"]
E -->|"late → hesitate"| F
F --> G["Canvas render + damage popups"]
E --> H["Telemetry: latency, timeouts"]
H --> I["HUD: big latency numbers,<br/>misses counter, wire panels"]
Both proxies are real serverless functions (api/jev.ts, api/llm.ts) —
no mocks, no local fallback decision-maker standing in for either side. If
a key isn't configured, both fighters just hesitate, honestly, every beat.
All 6 implementation phases from the architecture doc are in:
- Fixed-state combat scene — two silhouettes, a pure time-based animation
state machine, no physics (
src/combat/stateMachine.ts,src/scene/renderer.ts). api/jev.ts— real Jev integration via OpenRouter's alpha decisions endpoint.api/llm.ts— real Claude Haiku integration via OpenRouter's normal chat completions endpoint: prose reasoning, a parsedMOVE: <choice>line, no schema, no shortcut — the LLM does what an LLM normally does.- Telemetry HUD — live latency average and timeout count per side, round
tally, a headline "misses from timeout" counter, and per-fighter wire
panels showing the literal request/response each beat (
src/telemetry/). - Round/match structure: best-of-5, first to 3 round wins, auto-restarts
(
src/combat/match.ts,src/combat/beatClock.ts). - Local dev via
vercel dev,.env-based secrets, bundle checked for leaked keys.
Not done, because there's nothing to do: this project is explicitly not deployed anywhere. There's no hosting-platform step to complete.
Five moves: attackHigh, attackLow, block, dodge, feint.
| Move | Effect |
|---|---|
attackHigh |
30% damage on a clean hit |
attackLow |
25% damage on a clean hit |
block |
Negates any attack aimed at you · costs 12% stamina |
dodge |
Negates any attack aimed at you · costs 8% stamina |
feint |
No damage, no defense — a bait or a breather, regenerates stamina |
hesitate |
Not a chosen move — the call missed the shared deadline. Exposed exactly like a feint, but forced, not picked |
- A clean hit only lands on an undefended opponent (feinting or hesitating).
- Two attacks in the same beat trade — both connect.
feintprovides zero defense — mechanically identical exposure to hesitating, which is what makes bluffing genuinely risky.- Stamina drains per move and regenerates passively each beat; attacking while nearly empty halves that attack's damage.
- A round ends when a fighter's health hits 0. A match is best-of-5 — first fighter to 3 round wins takes the match, then it auto-restarts.
See src/combat/resolver.ts for the exact numbers — the on-screen legend
reads its numbers from the same constants, so it can't drift out of sync.
npm install
npm run devOpens the frontend only — /api/jev and /api/llm aren't served, so
every beat 404s and both fighters hesitate every time. That's the honest
default: there's no local fallback decision-maker standing in for either
side, because the entire point of this app is measuring real timing, not
looking alive without it.
To exercise the real proxies, with a .env (copied from .env.example)
holding a real OPENROUTER_API_KEY:
npm run dev:vercel # runs `vercel dev` via the local devDependencyFirst run prompts an interactive Vercel login/link in your own terminal.
vercel dev runs everything locally — no deployment happens.
Both fighters are reached through one OpenRouter key:
- Jev —
~typesafe/jev-latestvia OpenRouter's alpha/decisionsendpoint. (TypeSafe AI isn't taking new direct registrations right now — OpenRouter is the door in.) - The LLM —
~anthropic/claude-haiku-latestvia OpenRouter's normal/chat/completionsendpoint, prompted for one short sentence of reasoning plus a parsedMOVE:line.
Each beat is just one Jev call and one Haiku call — cost per session is low, but it's still real API spend for real, unbounded usage. Don't leave it running unattended.
Both proxies read OPENROUTER_API_KEY from the environment (api/jev.ts,
api/llm.ts) — never referenced from src/, so it can't end up in the
client bundle. Verified: npm run build && grep -r OPENROUTER dist/
comes back empty.
Built by Arpan — typed decisions vs. prose reasoning, raced on a clock.