DEV Community

Cover image for I made two LLMs draw the same cat. The constraint is the whole point.
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

I made two LLMs draw the same cat. The constraint is the whole point.

Building Blindfolded Pictionary Duel: SVG path data, three.js, and four bugs that only showed up against a real provider.


There is a specific kind of dishonesty that language models are very good at, and it is easiest to see in prose. Ask a model to describe a cat and you will get a fluent paragraph. Ask it to draw one and it has to commit to coordinates. There is no hedging in a d attribute.

That is the entire idea behind this project. Two models get one byte-identical prompt:

Draw a cat as SVG path data. Use a 0 0 400 400 viewBox. Output ONLY JSON matching this schema: {"paths": [{"d": "<svg path data>", "stroke": "<hex colour>", "fill": "none|hex", "width": <number>}], "label": "<short label>"}. Use at least 3 and at most 40 paths. Coordinates must stay inside the viewBox. Do not include any text in the drawing.

Both drawings render live, side by side, on tilted three.js planes. The prompt escalates across rounds — a cata cat on a bicyclea cat riding a bicycle through a city at night — and each drawing reveals itself stroke by stroke, with a glowing pen head riding the tip of the current line.

No image model. No diffusion. No retouching. A chat model is asked for vector coordinates, and whatever comes back gets parsed and drawn.

Here is what I learned building it, including the four bugs that a mock would never have caught.


Architecture

Three pnpm workspaces. shared is the single source of truth for the wire contract, the prompt, and the round ladder, so the client and server cannot drift apart.

Architecture

The server is stateless and holds no credentials. The client sends its config — base URL, key, both model names — with every request. Promise.all fires both calls concurrently, so a duel takes as long as the slower model rather than the sum.

The reason there is no .env file anywhere in this project is deliberate: configuration lives in the app's Settings panel, in localStorage, and is sent per request. There is nothing to leak from the repo and nothing to configure before the first run except pasting a key.


Bug 1: models typo the key, and I was deleting their drawings

The first version of the validator looked for path.d. If it was a string, keep it. Otherwise drop the path.

Then I read the raw log and found this, from both models, independently:

{"d: ": "M140 320 L152 296 L168 284 …"}
Enter fullscreen mode Exit fullscreen mode

The value is perfect. The key is "d: " — a colon and a space instead of a closing quote. My validator dropped it. Silently. A whole building disappeared from a cityscape and nothing in the UI said so.

This is the class of bug that mocks hide, because when you write the mock you write the well-formed version. So the fix recovers aggressively:

// server/src/validate.ts
const PATH_KEY_CANDIDATES = ['d', 'd:', 'd: ', 'path', 'pathData', 'data', 'svg', 'points'];

function recoverPathData(entry: Record<string, unknown>): string | null {
  if (typeof entry.d === 'string') return entry.d;

  for (const [key, value] of Object.entries(entry)) {
    if (typeof value !== 'string') continue;
    const normalised = key.trim().toLowerCase().replace(/[^a-z]/g, '');
    if (PATH_KEY_CANDIDATES.includes(normalised)) return value;
  }

  // Last resort: any string value that looks like path data.
  for (const value of Object.values(entry)) {
    if (typeof value === 'string' && /^[Mm][\s\d.,-]/.test(value.trim())) return value;
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Replaying the fix over 82 already-logged outputs recovered real strokes in four of them. Repairs are surfaced in the UI as a repaired badge with the reason attached, because a validator that silently fixes things is only marginally better than one that silently breaks them.


Bug 2: the reveal stalled at 38% and never moved

The hook of this app is watching a drawing appear stroke by stroke. So this was fatal:

progress frozen at 38%, state.strokeIndex stuck at 1
Enter fullscreen mode Exit fullscreen mode

The reveal works by advancing a distance each frame (totalLength / duration) and painting only the newly travelled part. The partial-segment branch looked like this in spirit:

paint from segment start → up to (start + remaining)
Enter fullscreen mode Exit fullscreen mode

Which is correct for one frame. But if a segment is longer than one frame's travel, the next frame redraws from the segment start again, never records how far it got, and the segment never completes. Progress freezes forever on the first long line.

The fix is one field:

interface DrawState {
  strokeIndex: number;
  segIndex: number;
  segT: number;
  /**
   * Distance already travelled INTO the current segment.
   * Without this, a segment longer than one frame's travel would restart from
   * its beginning every frame and the reveal would stall forever.
   */
  segOffset: number;
}
Enter fullscreen mode Exit fullscreen mode

segOffset accumulates in the partial branch, resets on segment completion, and feeds both the progress counter and the pen head position. This bug is invisible in code review — it looks fine — and invisible in a unit test unless you happen to assert on a segment longer than one frame's travel. It took reading canvas pixels in a headless browser to find it.


Bug 3: reasoning models return nothing at all, and max_tokens is why

This one cost an entire model its output and took the longest to understand.

max_tokens pays for the model's thinking as well as its answer. A thinking model that runs out mid-thought does not return a truncated drawing. It returns empty content. Measured against a real provider with deepseek-v4.1-flash:

max_tokens result
1,600 all 1,600 spent on reasoning, 0 paths
4,000 all 4,000 spent on reasoning, 0 paths
8,192 all 8,192 spent on reasoning, 0 paths (city scene)
16,384 ✅ 8,350 reasoning tokens, then 23 paths

The symptom the user sees is a seat that sits there showing nothing, then a forfeit. The default budget of 1,600 was below the threshold at which the model can succeed at all.

The original retry policy doubled the budget: 1,600 → 3,200. Still nowhere near enough. So with reasoning on, the server now treats the configured budget as a floor:

// shared/src/index.ts
export const REASONING_MIN_BUDGET = 16384;

// server/src/modelClient.ts
function startingBudget(config: DuelConfig): number {
  return config.disableReasoning
    ? config.maxTokens
    : Math.max(config.maxTokens, REASONING_MIN_BUDGET);
}

function escalateBudget(current: number): number {
  return Math.max(current * 2, REASONING_MIN_BUDGET);
}
Enter fullscreen mode Exit fullscreen mode

Below that floor a reasoning model cannot produce a drawing by construction. Honouring a small number literally would only guarantee a wasted call, so the value is raised — and the value actually sent is recorded in every logged request body, so it is auditable rather than assumed. The verification suite asserts on it.

reasoning_content is typed as unknown in the response interface and never read, logged, stored, or rendered. Only the token count is reported, from usage.completion_tokens_details.reasoning_tokens. The code that would touch the field does not exist.


Bug 4: the loading state said "nothing is happening"

With reasoning on, a single call can take two minutes. During that time the seat rendered:

Blank sheet
Pick a rung or type a subject to start the duel.

Because result is null while the request is in flight, and the component fell through to its idle overlay. The app was telling the user to start a duel they had already started.

The fix is a real phase rather than a null check:

const seatPhase: SeatPhase =
  duel.status === 'drawing' ? 'loading'
  : duel.status === 'error' ? 'error'
  : shown ? 'ready'
  : 'idle';
Enter fullscreen mode Exit fullscreen mode

And a working state that admits what it is doing — spinner, ticking clock, indeterminate sweep, and a line that changes once the wait gets long:

{phase === 'loading' && (
  <div className="seat__loading">
    <span className="seat__spinner" aria-hidden="true" />
    <p className="seat__loading-title" role="status">
      {reasoningOn ? 'Thinking, then drawing…' : 'Drawing…'}
    </p>
    <p className="seat__loading-elapsed" aria-hidden="true">{elapsed.toFixed(1)}s</p>
    <div className="seat__loading-track" aria-hidden="true"><span className="seat__loading-sweep" /></div>
    <p className="seat__loading-hint">{loadingHint}</p>
  </div>
)}
Enter fullscreen mode Exit fullscreen mode

I also made the reveal duration scale with density, because a 40-path cityscape flashing past in the same 3.4 seconds as a 12-path cat wastes the payoff after a two-minute wait.


Rendering: from d attribute to tilted plane

web/src/lib/svgPath.ts is a hand-written SVG path parser supporting M m L l H h V v C c S s Q q T t A a Z z, flattening curves into polylines. It deliberately does not use the DOM's getPointAtLength, because the reveal animation needs per-point positions to drive the pen head, and the pen head needs to be at an arbitrary fractional position along the current segment.

duelRenderer.ts paints those polylines into a 1024² canvas, maps it to a CanvasTexture on a subtly tilted PlaneGeometry, and adds a blurred offset copy as a soft shadow. Each seat tilts the opposite way; OrbitControls is enabled with a straight-on default camera. The drawing is the star — the 3D is there to make it feel like a physical sheet, not to compete with it.

The reveal paints append-only: each frame draws only the newly travelled distance, so 40 paths cost about as much as 3.

There is also a plain SVG fallback view that renders the same path data without WebGL. It is useful on machines without a GPU, and it is the honest way to show that the canvas contains nothing but the model's own coordinates.


Verification: no mocks, by design

The rule I set was: if it does not hit a real model, it is a unit test.

  • pnpm test — 17 unit tests over the pure functions: fence stripping, brace matching, command validation, coordinate clamping, malformed-key recovery, the 40-path cap, truncated JSON.
  • pnpm verify — drives the real app in a real browser and reads the same localStorage config the Settings panel writes. It has no API key of its own.

The harness configures itself through the app's own UI, and it exercises the failure paths the same way — by editing the model name and the token budget in the Settings panel, then pressing a rung. That is how it caught the budget problem: it asserted that a deliberately broken model forfeits while the other seat survives, and that the provider's real error text appears verbatim.

It measures the two drawings by reading pixels back out of the WebGL canvas and comparing 32×32 ink masks:

const c = document.createElement('canvas');
c.width = 256; c.height = 256;
const ctx = c.getContext('2d');
ctx.drawImage(webglCanvas, 0, 0, 256, 256);
const d = ctx.getImageData(0, 0, 256, 256).data;
// paper is #f5f3ec; anything meaningfully darker is ink
Enter fullscreen mode Exit fullscreen mode

If the two models ever produced the same drawing, that check fails. It never has — but the assertion is there precisely because "the app renders the same image twice" is the failure mode that would make the whole project a lie.

Two things the suite taught me about my own claims

A single duel is a noisy sample. My first suite asserted on one duel per rung and flaked roughly one run in three, because two models drawing "a cat" sometimes land on a similar layout. It now samples several duels and asserts on the aggregate.

Some of my README was wrong. I had claimed that cross-model divergence grows with difficulty, because one run measured 18.4% → 42.8%. A later run measured 51.1% → 32.8%. The trend was noise. I removed the claim and documented both runs.


The finding I did not expect: reasoning kills the escalation

The product promise is that the filmstrip shows drawings getting more complex as the prompt escalates. That holds — but only with reasoning off. Across every duel in the log:

mode Round 1 paths (A / B) Round 3 paths (A / B)
reasoning off 19.0 / 19.6 38.5 / 39.0
reasoning on 20.6 / 24.6 23.0 / 24.0

With reasoning off, path counts roughly double: models pad toward the 40-path cap. With reasoning on, they barely move, and individual pairs invert.

Thinking models plan the scene and then draw it economically — fewer, more deliberate paths. The escalation is real but it is a property of greedy, non-thinking generation, not of the model's understanding.

I changed the suite to assert escalation only in reasoning-off runs and to print a note otherwise. A test that passes by being weakened is worse than no test; a claim that is quietly false is worse than both.


What I would tell you before you build something like this

Constrain the output format, not the content. The prompt is almost entirely about shape — the viewBox, the schema, the path count, the coordinates. The model decides what a cat looks like. That division is why the output is legible without explanation.

Log the raw response, always. Every duel appends the raw assistant content, the exact prompt, and the full request body to a JSONL file. Both of the parsing bugs I found were found by reading that file, not by reasoning about the code.

Distrust your own verification. Two of the four bugs above were in my test suite, not the app: a false "identical drawings" failure caused by empty double-forfeits comparing equal, and a harness that corrupted the user's settings when it crashed mid-run. Both are fixed — the second now restores settings in a finally and on SIGINT.

Provider behaviour is not in the docs. "A reasoning model returns empty content when it exhausts its budget" is not a documented contract anywhere I could find. It is the kind of thing you only learn by measuring, and it silently cost a whole model its output.


Try it

git clone https://github.com/harishkotra/blindfolded-pictionary-duel.git
cd blindfolded-pictionary-duel
pnpm install
pnpm dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:5173, paste a base URL and an API key into Settings, and press a rung.

How this looks

Code & more: https://www.dailybuild.xyz/project/256-blindfolded-pictionary-duel

Top comments (0)