DEV Community

Cover image for How I built Refract: a live arena where two LLMs fight in GLSL
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

How I built Refract: a live arena where two LLMs fight in GLSL

Two models get the same prompt. Each writes a shader. Your GPU is the judge — and the error log is the commentator.

The idea

Benchmarks tell you a model "scores 82 on HumanEval." That's abstract. Refract makes model quality visible: one canvas alive with an animated neon tunnel, the other black with ERROR: 0:12: 'outColor' : undeclared identifier staring at you. Then the failing model gets exactly one chance to fix itself. That before/after frame is something a leaderboard can never give you — and it's pure screen-record bait.

The non-negotiable rule: never fake it

Everything renders live. The browser compiles the model's actual text output with a real WebGL2 context and shows the driver's real info log. No canned shaders, no "simulated error." Three verification experiments prove the rig measures reality: a tunnel prompt (something must animate), a deliberately broken shader (the log must appear verbatim and repair must change the code), and a double-run nonce check (byte-identical outputs twice = you're reading a cache).

Architecture

Browser (Vite :5173)                Node backend (:3001, auto-bump)        Providers
┌────────────────────┐  /api/forge ┌──────────────────────┐  fetch  ┌──────────────┐
│ Slot A cfg │ Slot B│ ──────────▶ │ Promise.all(A, B)    │ ──────▶ │ Particle.ai  │
│ canvas A   │ cv B  │ ◀────────── │ {raw, glsl, sha, tok}│ ◀────── │ Ollama / LMS │
│ single rAF ─────────│  /api/repair│ strip reasoning_     │         │ OpenRouter   │
│ u_time, u_resolution│ {glsl, log} │ content · retry 2×   │         └──────────────┘
└────────────────────┘  /api/models│ nonce per run        │
         ▲              (proxy)     └──────────────────────┘
         │ Vite dev middleware re-reads .backend-port per request
         │ so backend port bumps never break the proxy
Enter fullscreen mode Exit fullscreen mode

All model traffic goes through the backend. That single decision buys three things: localhost providers work with zero CORS setup, API keys never touch the client bundle, and reasoning_content can be stripped in one place before it ever reaches the browser.

The rendering core: ~40 lines of raw WebGL2

No three.js. The vertex shader is a fullscreen triangle with zero buffersgl_VertexID does all the work:

#version 300 es
void main(){
  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
Enter fullscreen mode Exit fullscreen mode
// frontend/src/webgl.ts
export function compileProgram(gl: WebGL2RenderingContext, fragSrc: string) {
  const vs = gl.createShader(gl.VERTEX_SHADER)!;
  gl.shaderSource(vs, VERT); gl.compileShader(vs);
  const fs = gl.createShader(gl.FRAGMENT_SHADER)!;
  gl.shaderSource(fs, fragSrc); gl.compileShader(fs);
  if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS))
    return { ok: false, log: gl.getShaderInfoLog(fs) }; // verbatim, never paraphrased
  const prog = gl.createProgram()!;
  gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog);
  return { ok: true, program: prog };
}
Enter fullscreen mode Exit fullscreen mode

One requestAnimationFrame loop drives both canvases, setting u_time and u_resolution per frame with DPR-aware viewports. Each canvas owns its own context, so a failed compile on one side can't touch the other.

The model layer: capability detection, not assumptions

The interesting bugs live here. Local providers (Ollama, LM Studio) don't report reasoning tokens — so the UI shows n/a and hides the thinking toggle instead of printing a fake 0. The chat_template_kwargs: {enable_thinking: false} field is sent only for Particle deepseek-* models; anything else would reject it. Reasoning budgets floor at 900 tokens, and an HTTP 200 with empty content triggers one retry at double budget — because a silent empty reply usually means the hidden chain-of-thought ate the whole window.

// Strip the CoT text; only its token COUNT may leave the server
const { reasoning_content: _strip, reasoning: _strip2, ...safeMsg } = msg;
const reasoningTokens = json?.usage?.completion_tokens_details?.reasoning_tokens ?? null;
Enter fullscreen mode Exit fullscreen mode

And the model picker degrades gracefully: GET {base}/models populates suggestions, but a hand-typed name always runs — because real models like deepseek-v4-flash-0731 don't even appear in the provider's own list.

The repair pass: the actual story

const repairPrompt = `The following GLSL ES 3.00 fragment shader failed to compile. ...`
  + failingShader + verbatimCompilerLog + freshNonce;
Enter fullscreen mode Exit fullscreen mode

The error log goes back verbatim to the same model. What comes back gets recompiled immediately. Over a session, the history strip accumulates compiled/failed per model — a visible scoreboard of who writes valid GLSL and who can debug it.

The unglamorous win: ports

The least exciting code had the most user-visible impact. Both servers auto-bump when their port is taken (Vite did already; the Express backend learned to scan 3001–3020 and write .backend-port), and the Vite dev proxy re-reads that file per request instead of caching :3001 at startup. npm run dev now survives a crowded machine.

What I'd add next

Tournament ELO across N models, a diff view for original-vs-repaired shaders, perceptual-hash scoring of canvas output as an automatic "most interesting" vote, and a WebGPU/WGSL variant of the same rig to compare failure modes across shading languages.

Refract 1

Refract 2

Refract 3

Code & more: https://www.dailybuild.xyz/project/261-refract

Top comments (0)