<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gleb Rosev</title>
    <description>The latest articles on DEV Community by Gleb Rosev (@grumbash).</description>
    <link>https://dev.to/grumbash</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F689041%2F949d8113-a25c-4fa5-ac5e-8c79f0f7f519.jpeg</url>
      <title>DEV Community: Gleb Rosev</title>
      <link>https://dev.to/grumbash</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/grumbash"/>
    <language>en</language>
    <item>
      <title>I skipped Three.js for my hero scene. A WebGL dev found the bug in one sentence.</title>
      <dc:creator>Gleb Rosev</dc:creator>
      <pubDate>Sat, 07 Nov 2026 07:31:00 +0000</pubDate>
      <link>https://dev.to/grumbash/i-skipped-threejs-for-my-hero-scene-a-webgl-dev-found-the-bug-in-one-sentence-dkf</link>
      <guid>https://dev.to/grumbash/i-skipped-threejs-for-my-hero-scene-a-webgl-dev-found-the-bug-in-one-sentence-dkf</guid>
      <description>&lt;p&gt;I shipped my personal site with a 3D architecture topology in the hero: three decks of systems, packets running between nodes, faults you can trigger. No Three.js. No WebGL. Just Canvas 2D and about 20 lines of my own perspective projection.&lt;/p&gt;

&lt;p&gt;Then I connected with a developer who does WebGL for a living, and I asked the obvious question: would you have used Three.js here?&lt;/p&gt;

&lt;p&gt;He actually opened my code. His answer was "no, Canvas 2D is a perfectly reasonable choice for this". And then, almost as a side note: switch your animation to delta-time, so the pacing stays the same across refresh rates.&lt;/p&gt;

&lt;p&gt;I hadn't thought about it at all. Everything was per frame. On a 120 Hz screen my "calm, cinematic" scene ran exactly twice as fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Three.js
&lt;/h2&gt;

&lt;p&gt;The scene is a fixed set of boxes. Nodes are extruded rectangles, walls are quads, packets are dots moving along lines. No meshes from files, no materials, no lights, no shadows.&lt;/p&gt;

&lt;p&gt;For that, Three.js is a lot of library to ship to a portfolio's first screen. What I actually needed was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;project3D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;camFocusX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wy&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;camFocusY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wz&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;camFocusZ&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;camYaw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;dz&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;camYaw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;camYaw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dz&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;camYaw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;camPitch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rz&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;camPitch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;camPitch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rz&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;camPitch&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;persp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1100&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;340&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1100&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;camZoom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;persp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.54&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ry&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;persp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;persp&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rotate by yaw, rotate by pitch, divide by distance. The &lt;code&gt;Math.max(340, …)&lt;/code&gt; stops things from exploding when they get too close to the camera.&lt;/p&gt;

&lt;p&gt;No z-buffer either. It's the painter's algorithm: project everything, sort walls and nodes by depth, draw back to front. With a few dozen objects, sorting every frame costs nothing.&lt;/p&gt;

&lt;p&gt;The engine ended up at about 1,500 lines, and most of that is the stuff that makes it a site and not a demo: hover and focus, keyboard navigation, a guided story mode, fault simulation, reduced motion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: 0.12 is not a speed
&lt;/h2&gt;

&lt;p&gt;Here's what my camera easing looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;camYaw&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetYaw&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;camYaw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every frame, close 12% of the remaining distance. Feels great at 60 Hz.&lt;/p&gt;

&lt;p&gt;But "per frame" means the speed is whatever the monitor decides. At 120 Hz you close 12% twice as often, so the camera arrives twice as fast. Same with everything else: packets moved &lt;code&gt;+= 0.015&lt;/code&gt; per frame, faults progressed &lt;code&gt;+= 0.0042&lt;/code&gt; per frame, flashes decayed &lt;code&gt;*= 0.9&lt;/code&gt; per frame. On a 120 Hz screen the whole scene was on espresso.&lt;/p&gt;

&lt;p&gt;The lazy fix is "multiply by dt". For linear motion that's right. For easing it's wrong: &lt;code&gt;0.12 * 2&lt;/code&gt; is not what two frames of 12% easing do.&lt;/p&gt;

&lt;p&gt;Two frames at 12% leave &lt;code&gt;0.88 × 0.88 = 0.7744&lt;/code&gt; of the distance, so they close 22.56%, not 24%. The correct form is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Animation constants were tuned at 60 fps. `frames` is how many 60 fps frames actually elapsed.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastFrameTs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ease&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;camYaw&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetYaw&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;camYaw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// exponential easing&lt;/span&gt;
&lt;span class="nx"&gt;flash&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// exponential decay&lt;/span&gt;
&lt;span class="nx"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// linear motion: just scale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linear motion:&lt;/strong&gt; multiply by elapsed time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exponential easing:&lt;/strong&gt; &lt;code&gt;1 - (1 - rate)^frames&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decay:&lt;/strong&gt; &lt;code&gt;factor^frames&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And cap &lt;code&gt;frames&lt;/code&gt;, here at 4. Otherwise a tab that comes back from the background gets one giant step and everything teleports.&lt;/p&gt;

&lt;p&gt;I kept the constants in "60 fps frames" on purpose. The motion was tuned by eye at 60 Hz, so the numbers mean "how it felt then", and the formula keeps that feeling on any screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring parts that matter more
&lt;/h2&gt;

&lt;p&gt;A hero animation is the first thing on the page, so it's the first thing that can make the page bad.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's not in the first load.&lt;/strong&gt; The engine is a lazy chunk. Moving it out of the eager bundle took the page's eager JS from 20.9 KB to 2.8 KB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It stops when you can't see it.&lt;/strong&gt; An &lt;code&gt;IntersectionObserver&lt;/code&gt; and &lt;code&gt;document.hidden&lt;/code&gt; stop the loop offscreen or in a background tab, and &lt;code&gt;lastFrameTs&lt;/code&gt; resets, so the first frame back isn't a giant step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It stops when nothing moves.&lt;/strong&gt; With &lt;code&gt;prefers-reduced-motion&lt;/code&gt;, the loop runs only until every eased value is within 0.001 of its target, then leaves the last frame on the canvas until you touch something.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A perf gate in CI runs Lighthouse on mobile and desktop on every PR, so the hero can't quietly get heavier.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent did, and what it didn't
&lt;/h2&gt;

&lt;p&gt;I built most of the engine with a coding agent over two evenings. The logic was the easy part for it: projection, sorting, hit-testing, keyboard handling, tests.&lt;/p&gt;

&lt;p&gt;What it couldn't do was look at the screen and say "this feels nervous" or "this camera arrives too eagerly". Every constant in that easing was me squinting at a canvas. The agent also didn't catch the 120 Hz bug, and neither did I, because we were both looking at 60 Hz. It took a human who reads WebGL code for a living to see it in one pass.&lt;/p&gt;

&lt;p&gt;That's the part I keep relearning at work: &lt;strong&gt;an agent is great at making the code correct. Whether it's right, how it feels, what it's for, still needs someone who's looking at the actual thing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So: was skipping Three.js the right call? For a fixed scene of boxes, I still think yes. Ask me again when I want lighting.&lt;/p&gt;

&lt;p&gt;Go spin it: &lt;a href="https://rossev.dev/" rel="noopener noreferrer"&gt;rossev.dev&lt;/a&gt;. The engine is in &lt;code&gt;src/scripts/iso-engine.ts&lt;/code&gt; in &lt;a href="https://github.com/Grumbash/rossev.dev" rel="noopener noreferrer"&gt;the repo&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>gamedev</category>
      <category>ai</category>
    </item>
    <item>
      <title>I benchmarked 8 models on our own code reviews. The first bug was ours.</title>
      <dc:creator>Gleb Rosev</dc:creator>
      <pubDate>Fri, 06 Nov 2026 13:05:00 +0000</pubDate>
      <link>https://dev.to/grumbash/i-benchmarked-8-models-on-our-own-code-reviews-the-first-bug-was-ours-2c5p</link>
      <guid>https://dev.to/grumbash/i-benchmarked-8-models-on-our-own-code-reviews-the-first-bug-was-ours-2c5p</guid>
      <description>&lt;p&gt;Claude Opus 5.5 failed the same review three runs out of three. Same pull request, same reviewer role, same result: the stage output "failed to parse", the review was marked errored, and Opus dropped to the bottom of my table.&lt;/p&gt;

&lt;p&gt;When I finally pulled the raw response, Opus had done exactly what it was asked. It wrote two short paragraphs explaining its findings and then gave a well-formed JSON object in a fenced code block. The problem was one phrase in the explanation: "the only production caller passes just &lt;code&gt;{ db }&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;Our parser looked for the first &lt;code&gt;{&lt;/code&gt; and the last &lt;code&gt;}&lt;/code&gt; in the response and tried to parse whatever was between them. The first &lt;code&gt;{&lt;/code&gt; belonged to &lt;code&gt;{ db }&lt;/code&gt; in the prose. So the slice started in the middle of a sentence, &lt;code&gt;JSON.parse&lt;/code&gt; threw, and a correct review was thrown away.&lt;/p&gt;

&lt;p&gt;That was the first of three things the benchmark found that had nothing to do with which model is smarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I was benchmarking at all
&lt;/h2&gt;

&lt;p&gt;Moonshot released Kimi K3.1 Pro, and the community numbers looked strong. Vals AI had it as the top open-weights model on their index (62.5%) and #3 on Terminal-Bench 4.0. Plotly's data-analysis exam gave it 42 of 43. It moved from #17 to #8 on the Arena web-dev leaderboard. The list price was $2 / $10 per million tokens against $3 / $15 for Kimi K3, which we were running on our highest tier.&lt;/p&gt;

&lt;p&gt;Community benchmarks also disagreed with each other. Artificial Analysis showed 55 and fourth place in launch-week write-ups, and 46 and 22nd on its own model page a few days later. A hands-on coding benchmark put it sixth of eleven and called it "chatty". None of that tells me how it reviews &lt;em&gt;our&lt;/em&gt; pull requests.&lt;/p&gt;

&lt;p&gt;At YAGNI, Proctor is the agent that reviews pull requests. We already had an eval for it: five merged PRs from our own repository, each with gold findings a good reviewer should raise and things it must not say. Every review runs the real pipeline (a deep pass, a set of specialist roles, a refinement pass). A judge model matches the surviving findings against the gold, and the score is&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;composite = 100 · (0.7 · recall + 0.3 · precision) − 10 · forbidden_hits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first comparison was one run each, K3 against K3.1 Pro. K3.1 Pro scored 65.2 against 60.2, took 35 seconds per review instead of 92, and cost about $0.13 per review instead of $0.23. That cost is my calculation from token counts and list prices, without cache discounts. The "chatty" warning didn't show up here: K3.1 Pro wrote 7.4k completion tokens across the five reviews, and K3 wrote 16.6k.&lt;/p&gt;

&lt;p&gt;One run on five PRs is an anecdote, so I widened it. That's where I found the bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: Claude never ran
&lt;/h2&gt;

&lt;p&gt;I added Opus 5.5 and Sonnet 5.5 to the comparison. Before the full run I built a small spend ledger: every model call in the eval appends one line with the model, the tokens and the priced cost. Eval scripts run outside the server, so none of this reaches our production cost metrics. I wanted to see the spend while a run was still going, with alerts at fixed thresholds.&lt;/p&gt;

&lt;p&gt;The first smoke run of the Sonnet matrix logged 11 calls. None of them were Sonnet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$0.01   9 calls  accounts/fireworks/models/deepseek-v4p1-flash-1017
$0.01   1 calls  accounts/fireworks/models/glm-5p3
$0.00   1 calls  accounts/fireworks/models/kimi-k3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our router only sends traffic to Anthropic when a workspace explicitly pins it. That rule is deliberate. The eval asked for Claude through a per-call override, the router correctly ignored it, logged a warning nobody reads during an eval, and fell back to the default models. The eval then happily scored GLM, DeepSeek and Kimi under the name "Sonnet 5.5".&lt;/p&gt;

&lt;p&gt;If I hadn't been watching spend per model, I would have published a Claude score that was not Claude.&lt;/p&gt;

&lt;p&gt;The fix kept the router as it is: the eval now acts as a synthetic workspace whose pin is the model under test. A code review on the fix also asked for a safety check that I now think is the important half of it. If the call comes back from any model other than the one pinned, the stage fails loudly with &lt;code&gt;eval pin not honored&lt;/code&gt; instead of being scored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug three: Sonnet called tools that didn't exist
&lt;/h2&gt;

&lt;p&gt;With routing fixed, Sonnet still lost 2 of 15 reviews to parse failures, each time on a different role. I started saving unparseable output, and the two failures I caught on a rerun looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;invoke&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"finish_review"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;parameter&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"note"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;The material I was given had no analyzer entries at or
above ccn 10, so there was nothing to review. ...&lt;span class="nt"&gt;&amp;lt;/parameter&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/invoke&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two complexity-check roles were written for an agentic loop. Their instructions say "read_file the FULL method" and "your finish_review note should list each…". In the eval, and in production when the loop falls back to a single pass, those roles run without any tools. The open-weight models ignored the tool talk and returned JSON. Sonnet took the instructions literally and wrote the tool call as text.&lt;/p&gt;

&lt;p&gt;The fix is one paragraph added to the single-pass prompt, and only for roles whose instructions mention loop tools: there are no tools in this pass, answer with the JSON object. Every other role's prompt is byte-for-byte unchanged, so the earlier numbers for other models stay comparable. One of Sonnet's first-round failures was on a different role, and I didn't have its raw output yet. It didn't come back after the fix, but I haven't proven it was the same cause.&lt;/p&gt;

&lt;p&gt;The Opus fix needed one review round too, and the reviewer was Proctor itself, running on my pull request. My first version tried fenced code blocks before the brace slice. Proctor pointed out that a model which shows a fenced &lt;em&gt;example&lt;/em&gt; and then answers below it would now get the example parsed as its answer. That's a silent wrong verdict where the old code at least failed loudly. The final order is: the whole response, the brace slice, the brace slice of the text with fences removed, and only then the last fenced block.&lt;/p&gt;

&lt;p&gt;After both fixes, Opus and Sonnet went from 3 and 2 errored reviews out of 15 to 0 out of 15. Sonnet moved from second place to first in that round. Its reviews didn't change much. It just stopped losing them to our parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the judges
&lt;/h2&gt;

&lt;p&gt;All of those runs used one judge, Kimi K3. That's two conflicts in one: K3 was also a contestant, and it shares a family with K3.1 Pro, which was leading. The top four models were within three points of each other, so a small judge bias could reorder them.&lt;/p&gt;

&lt;p&gt;I added a judge panel from three families: Kimi K3, GLM 5.3 and Sonnet 5.5. Each review is graded by every panel judge except the model that wrote it, and the score is their mean. The per-judge scores go into the CSV so bias would be visible.&lt;/p&gt;

&lt;p&gt;It wasn't there. For every model, the three judges agreed within two points. K3.1 Pro got 50.4 from all three.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Composite&lt;/th&gt;
&lt;th&gt;Run-to-run SD&lt;/th&gt;
&lt;th&gt;Precision / Recall&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;th&gt;$ per review*&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen 3.8 Max&lt;/td&gt;
&lt;td&gt;55.4&lt;/td&gt;
&lt;td&gt;±6.3&lt;/td&gt;
&lt;td&gt;0.13 / 0.76&lt;/td&gt;
&lt;td&gt;0.20&lt;/td&gt;
&lt;td&gt;~$0.07–0.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM 5.3&lt;/td&gt;
&lt;td&gt;54.1&lt;/td&gt;
&lt;td&gt;±4.6&lt;/td&gt;
&lt;td&gt;0.11 / 0.73&lt;/td&gt;
&lt;td&gt;0.18&lt;/td&gt;
&lt;td&gt;$0.09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5&lt;/td&gt;
&lt;td&gt;54.1&lt;/td&gt;
&lt;td&gt;±4.2&lt;/td&gt;
&lt;td&gt;0.27 / 0.68&lt;/td&gt;
&lt;td&gt;0.33&lt;/td&gt;
&lt;td&gt;$0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5.5&lt;/td&gt;
&lt;td&gt;52.1&lt;/td&gt;
&lt;td&gt;±1.5&lt;/td&gt;
&lt;td&gt;0.15 / 0.69&lt;/td&gt;
&lt;td&gt;0.22&lt;/td&gt;
&lt;td&gt;$0.41&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3.1 Pro&lt;/td&gt;
&lt;td&gt;50.4&lt;/td&gt;
&lt;td&gt;±2.2&lt;/td&gt;
&lt;td&gt;0.23 / 0.62&lt;/td&gt;
&lt;td&gt;0.22&lt;/td&gt;
&lt;td&gt;$0.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash&lt;/td&gt;
&lt;td&gt;49.3&lt;/td&gt;
&lt;td&gt;±3.0&lt;/td&gt;
&lt;td&gt;0.14 / 0.64&lt;/td&gt;
&lt;td&gt;0.21&lt;/td&gt;
&lt;td&gt;$0.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;43.4&lt;/td&gt;
&lt;td&gt;±4.8&lt;/td&gt;
&lt;td&gt;0.28 / 0.51&lt;/td&gt;
&lt;td&gt;0.32&lt;/td&gt;
&lt;td&gt;$0.17&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five PRs × three runs per model, judged by the three-judge panel. *Cost is from the single-judge round: review stages only, priced from token counts at list prices without cache discounts. MiniMax M3 is missing: in its first run 36 stages hit our 16k output-token cap, every review errored, and I stopped it.&lt;/p&gt;

&lt;p&gt;The panel didn't settle the ranking. It showed where the noise actually comes from. Between the single-judge run and the panel run, K3.1 Pro went from 54.6 to 50.4 and GLM 5.3 from 50.1 to 54.1. The judges agree, so the movement is in the reviews themselves: the same model, given the same PR again, writes a different review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can and can't claim
&lt;/h2&gt;

&lt;p&gt;Across both rounds (six runs per model):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kimi K3 is the weakest working model&lt;/strong&gt; both times (49.9, then 43.4). That's the model on our highest tier, so this is the result I'll act on first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;K3.1 Pro is at least as good as K3&lt;/strong&gt; and costs about a third less per review on these PRs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeepSeek V4.1 Flash is close to the pack at about a fifth of K3.1 Pro's price&lt;/strong&gt; and is the fastest. It's a candidate for the high-volume specialist roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sonnet 5.5 was top three both times&lt;/strong&gt;, with the best F1 among the high-recall models and the fastest reviews of the strong group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opus 5.5 is the most expensive and wrote the noisiest reviews&lt;/strong&gt; (precision 0.15). I haven't tuned its effort setting for this eval, so I'd treat its row as untested rather than as a verdict on Opus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything between 49 and 55 is a tie&lt;/strong&gt; on five PRs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole exercise cost about $51 in model spend, debugging runs included.&lt;/p&gt;

&lt;p&gt;The open question is the middle of the table. Five PRs, all from our own repo, can tell me that K3 should go. They can't tell me whether Qwen, GLM or Sonnet should replace it. The next step is AACR-Bench: about 200 expert-reviewed pull requests from public repositories that none of these models have reviewed for us, with its own judge. I haven't run it yet.&lt;/p&gt;

&lt;p&gt;The lesson I'd keep from this round has nothing to do with Kimi. Before comparing models on your own pipeline, check that the pipeline can actually hear each of them. Two of my eight models were losing reviews to our parser and prompt, and a third was never called at all.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>codereview</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
