<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="http://blog.mathieuacher.com/feed.xml" rel="self" type="application/atom+xml" /><link href="http://blog.mathieuacher.com/" rel="alternate" type="text/html" /><updated>2026-08-06T07:26:43+00:00</updated><id>http://blog.mathieuacher.com/feed.xml</id><title type="html">Mathieu Acher</title><subtitle>Professor in Computer Science</subtitle><entry><title type="html">CallOfAcher and FIFAcher: Vibe Coding Game Variants with My 10-Year-Old Nephew</title><link href="http://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIAEN/" rel="alternate" type="text/html" title="CallOfAcher and FIFAcher: Vibe Coding Game Variants with My 10-Year-Old Nephew" /><published>2026-08-06T07:00:00+00:00</published><updated>2026-08-06T07:00:00+00:00</updated><id>http://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIAEN</id><content type="html" xml:base="http://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIAEN/"><![CDATA[<p>A week ago, I pasted <a href="https://x.com/mattshumer_/status/2081100590612922527">this prompt by Matt Shumer</a> (150 words, not a single line of technical spec) into <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, and went off to do something else. When I came back: a playable FPS in the browser. Not a demo, not a prototype. A game. I did it again two days later, replacing “Call of Duty” with “FIFA”, and that worked too. Then my 10-year-old nephew took over and grew both games, coming up with ideas, typing what he wanted straight into Claude’s terminal, playing, criticizing, and so on, right up to today.
Both games are online, playable right now (even on a phone!), with nothing to install:</p>
<ul>
  <li><strong><a href="https://blog.mathieuacher.com/ashfall/">blog.mathieuacher.com/ashfall</a></strong>: the FPS, with its two-player co-op mode, its environments, and so on. (<a href="https://www.youtube.com/shorts/DUihik38JEA">video demo</a>)</li>
  <li><strong><a href="https://blog.mathieuacher.com/fifacher/">blog.mathieuacher.com/fifacher</a></strong>: the football game, a match at Stade Raoul Brulat with cicadas and two exceptional players. (<a href="https://www.youtube.com/shorts/CMiOrjE6TMY">video demo</a>)</li>
</ul>

<p>You click, it runs. On a phone, the touch joysticks appear on their own. On a computer it is keyboard and mouse, and if you plug in a controller (a Switch pad over USB or Bluetooth, or any standard gamepad), it is recognized immediately. Nothing to download, nothing to compile, no account to create.</p>

<p>What came after the first shot interests me more than the first shot itself. And one clarification that holds for the rest of this post: apart from that initial prompt, <strong>everything was done with my 10-year-old nephew</strong>, who knows nothing about programming. The numbers below, the motorbikes, the extra enemies, the multiplayer mode, the Breton environments: that is a week spent side by side at the console. He understood the principle in ten minutes. Here is what I take away from it, with numbers to back it up.</p>

<p><img src="/assets/callofacher-ballon-cr7.jpg" alt="CallOfAcher: first-person view in a ruined city, a football in hand, a CR7 in a red shirt taking cover behind a car" style="max-width: 100%;" /></p>

<p><em>CallOfAcher. The objective shown in the top left, “MARQUEZ CONTRE LES RONALDO” (score against the Ronaldos), is my nephew’s idea: you don’t fire bullets, you fire footballs at CR7s. Everything else, the ruined city, the compass, the HUD, the vehicles, is generated in code. <a href="https://www.youtube.com/shorts/DUihik38JEA">Video demo</a>.</em></p>

<p><img src="/assets/fifacher-stade-raoul-brulat.jpg" alt="FIFAcher: night match at Stade Raoul Brulat, full stands, LED advertising boards, commentary line at the bottom of the screen" style="max-width: 100%;" /></p>

<p><em>FIFAcher, at Stade Raoul Brulat. The crowd in the stands is a single render object. The sponsors on the LED boards (Breizh Cola, Kroaz Air, Menhir Bank, Armor Telecom) were invented by the model, which preferred a fictional Breton league to real brands. At the bottom, the live commentary reacts to the fact that two Achers are on the same team. <a href="https://www.youtube.com/shorts/CMiOrjE6TMY">Video demo</a>.</em></p>

<h2 id="one-prompt-one-game">One Prompt, One Game</h2>

<p>The prompt fits in a single paragraph. It doesn’t say <em>how</em> to do it, it says <em>what we want</em> and <em>how to check</em>:</p>

<blockquote>
  <p>I want you to build a first-person shooter at the level of the most recent Call of Duty games. […] Fan out sub-agents and have sub-agents tackle each one individually […] have a separate sub-agent check it visually to ensure it looks triple A. That separate sub-agent should be a really harsh critic […] Do this in ThreeJS. /loop until it’s utterly perfect.</p>
</blockquote>

<p>For FIFAcher, I changed three words. That’s all. And the first shot is playable: you move, you shoot, it runs at 60 fps.</p>

<h2 id="what-it-sets-in-motion">What It Sets in Motion</h2>

<p>This is where I wanted to look under the hood. I analyzed the session logs, the commits, and the sub-agent transcripts. The number that surprised me most is not the line count, it is the agent count.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>CallOfAcher (ASHFALL)</th>
      <th>FIFAcher</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Working window</td>
      <td>July 26 → 31</td>
      <td>July 28 → 31</td>
    </tr>
    <tr>
      <td>Requests we typed</td>
      <td>45</td>
      <td>15</td>
    </tr>
    <tr>
      <td>Sub-agents launched</td>
      <td>56</td>
      <td>36</td>
    </tr>
    <tr>
      <td>Tokens processed</td>
      <td>585M</td>
      <td>562M</td>
    </tr>
    <tr>
      <td>Tokens generated</td>
      <td>1.03M</td>
      <td>1.15M</td>
    </tr>
    <tr>
      <td>Session logs</td>
      <td>465 MB</td>
      <td>462 MB</td>
    </tr>
    <tr>
      <td>Commits</td>
      <td>14</td>
      <td>12</td>
    </tr>
  </tbody>
</table>

<p>In total: <strong>92 sub-agents</strong>, <strong>1.15 billion tokens processed</strong>, <strong>2.18 million tokens generated</strong>, for <strong>60 requests</strong> typed by us. The ratio is dizzying: one sentence in French triggers hours of machine work. Three models took turns depending on the phase (Opus 4.8, Opus 5, Fable 5).</p>

<p>The “us” is literal: of those 60 requests, only two are the initial prompts. Everything else was formulated together, and a good share of it typed directly by my nephew. So these numbers do not describe the performance of the machine alone, but what a week of conversation between two people, one of them a 10-year-old, manages to set in motion.</p>

<p>The orchestration pattern deserves a word. For FIFAcher, the initial build was a workflow of 10 agents in parallel, one per subsystem (crowd, pitch, ball, lighting, audio commentary, post-processing, and so on), each writing its module against a shared interface contract: a 14 KB <code class="language-plaintext highlighter-rouge">CONTRACT.md</code> file generated beforehand. Two hours and forty minutes of wall clock, ten concurrent agents. Then rounds of “harsh critics” that take screenshots and score the result, until the scores plateau.</p>

<p>That visual verification loop is the heart of the setup, and it is <em>laborious</em>. ASHFALL left <strong>199 screenshots</strong> in the repository, FIFAcher 31, plus 37 small verification scripts (<code class="language-plaintext highlighter-rouge">voicecheck.mjs</code>, <code class="language-plaintext highlighter-rouge">switchcheck.mjs</code>, <code class="language-plaintext highlighter-rouge">rendercost.mjs</code>, and others) that the agent wrote to inspect itself. It doesn’t <em>see</em> the game running: it photographs it, frame by frame, and reasons about the photos.</p>

<h2 id="the-cost">The Cost</h2>

<p>I reconstructed it from the logs, at public API rates: <strong>between $1,000 and $1,200</strong> for both games (the range depends on the cache TTL applied). It is a reconstruction, not an invoice, since I was on a subscription, but the order of magnitude is right.</p>

<p>The most instructive part is not the amount, it is <em>where</em> the money goes. Certainly not into the code produced. Out of 1.15 billion tokens, the generated code accounts for only <strong>2.18 million tokens, that is 0.19% of the volume</strong>. The remaining 99.8% is <strong>context</strong>: on every call, the agent has the game it is modifying explained to it all over again. Across 5,295 API calls, that averages <strong>216,000 tokens of context per call</strong>, read over and over.</p>

<table>
  <thead>
    <tr>
      <th>Item</th>
      <th>Volume</th>
      <th>Share of cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Reading context (cache)</td>
      <td>1.11B tokens</td>
      <td>55%</td>
    </tr>
    <tr>
      <td>Writing context (cache)</td>
      <td>36.3M tokens</td>
      <td>39%</td>
    </tr>
    <tr>
      <td><strong>Code actually produced</strong></td>
      <td><strong>2.18M tokens</strong></td>
      <td><strong>6%</strong></td>
    </tr>
  </tbody>
</table>

<p>In other words: <strong>94% of what you pay is reading and re-reading the thousands of existing lines, and only 6% is the code written</strong>. That is where the real cost lies, and it is structural. The bigger the game gets, the more expensive the slightest change becomes, including a trivial one. Adding a motorbike on day five costs far more than adding the same motorbike on day two, not because the motorbike is more complicated, but because you have to traverse everything that already exists to put it in the right place. You are not paying for the feature, you are paying for the context it forces you to walk through. A 200-line project can be tinkered with for a few cents; the same gesture on 19,000 lines costs a hundred times more.</p>

<p>And that is the painful part. It is not within everyone’s reach. You also need frontier models, presumably: I doubt a model two notches down would hold a 92-agent loop together without going off the rails. That said, I am very confident about democratization. In a year, those $1,000 will be worth $50. Even today, you can already cut the cost drastically by combining lighter, specialized models with frontier models, or by using your technical expertise to constrain the agentic system more tightly. Given the breakdown above, context is in fact where to act first: split the modules better, target more precisely what you hand the agent to read, and don’t let every sub-agent swallow the whole project.</p>

<h2 id="everything-else-was-done-together">Everything Else Was Done Together</h2>

<p>This is the part I hadn’t anticipated. Once the game exists, extending it becomes a conversation. My nephew asks, I barely rephrase, he even types directly into the console, and the agent codes. In a week, we added:</p>

<ul>
  <li><strong>firing a football</strong> at enemies (with, it must be said, a “SIUU” celebration cry on the V key);</li>
  <li>drivable <strong>cars and motorbikes</strong>;</li>
  <li>more enemies, civilians who flee from gunfire;</li>
  <li><strong>real environments</strong>: the Vallée de Gandy, the Stade Vélodrome, and the village of Saint-Aubin-du-Cormier with its pond and its castle ruins;</li>
  <li><strong>difficulty settings</strong>;</li>
  <li><strong>controller</strong> support, almost immediate, thanks to the Web’s Gamepad API;</li>
  <li>a <strong>mobile mode</strong> with touch joysticks;</li>
  <li>a two-player <strong>online co-op mode</strong>, in <strong>peer-to-peer WebRTC, with no server</strong>: a 4-character code, and you play together.</li>
</ul>

<p>The TL;DR: it is <em>easy</em>. A 10-year-old can do it and grasps the principle very quickly. Yes, the waiting times are long, but meanwhile you play actual football, board games, you go for a swim, you live, and you come back whenever you feel like it. The development cycle follows the rhythm of the holidays, which is a sentence I never imagined writing.</p>

<h2 id="the-stack-or-why-the-web-changes-everything">The Stack, or Why the Web Changes Everything</h2>

<p>Technically, it is disarmingly simple, and that is precisely the point:</p>

<ul>
  <li><strong>Three.js</strong> (r185) and <strong>nothing else</strong> on the rendering side. No external assets: every texture, every model, every sound is <strong>generated in code</strong>. FIFAcher’s stadium crowd is tens of thousands of supporters in a single <code class="language-plaintext highlighter-rouge">InstancedMesh</code>, hence a single draw call, with a 64-cell atlas painted procedurally onto a canvas, and a separate mask so that tinting only ever touches clothing and never skin.</li>
  <li>~18,900 lines of JavaScript for ASHFALL, spread across 19 ES modules.</li>
  <li><strong>Vite</strong> for the build, <strong>Playwright/Puppeteer</strong> for automatic screenshots.</li>
  <li><strong>PeerJS/WebRTC</strong> for co-op, with the free broker used <em>only</em> for signalling.</li>
  <li><strong>Static deployment on GitHub Pages</strong> via GitHub Actions. Zero servers, zero infra, zero hosting cost.</li>
</ul>

<p>It is that last point that makes the whole thing real rather than anecdotal. One <code class="language-plaintext highlighter-rouge">git push</code>, and the game is online, for anyone, anywhere, immediately. No store, no review, no per-platform build. The same <code class="language-plaintext highlighter-rouge">index.html</code> file serves the kid on his phone on the train and the adult with a controller in front of a screen. Go click the two links at the top, it is faster than finishing this paragraph.</p>

<h2 id="what-doesnt-work">What Doesn’t Work</h2>

<p>Let’s be clear: <strong>this is not the level of a real Call of Duty or a real FIFA</strong>. The gap is real, and it has several causes.</p>

<p><strong>Graphics and 3D.</strong> This is where the gap is most visible. Procedural work without an artist shows.</p>

<p><strong>Instructions sometimes ignored.</strong> Sometimes a requested feature simply isn’t implemented, with nothing to flag it. You have to ask again.</p>

<p><strong>Verification stays superficial.</strong> The critic-agents score screenshots, they don’t play. The result: bugs and glitches all over the place, including things a human spots in three seconds with a controller.</p>

<p><strong>Playability and tuning: hard, really hard.</strong> This is the hardest point. Adjusting sensitivity, balance, the “feel”, takes dozens of back-and-forths, and it is the area where the agent is most helpless.</p>

<p><strong>Knowledge of real places is genuine but poorly rendered.</strong> On the Vélodrome, the model <em>knows</em> accurate things: the shape of the stands, the atmosphere. But the rendering stays approximate. On other environments, despite obvious knowledge, the result is frankly bullshit. Much more work is needed.</p>

<p>That said, an amusing nuance: for FIFAcher, the technical weakness <strong>almost becomes a feature</strong>. The game doesn’t play like a FIFA, it forces you to find other strategies. The gameplay is different, not merely inferior.</p>

<h2 id="the-real-ceiling">The Real Ceiling</h2>

<p><a href="https://x.com/karpathy/status/2083749667410727319">Karpathy posted</a> a reflection that overlaps exactly with what I observed. Two ideas struck me.</p>

<p>The first: we are leaving the territory of “draw me a pelican in SVG”. These games belong to a category of tasks that <strong>nobody in their right mind would have undertaken</strong>, too custom, too long, no return on investment. LLMs have all the patience in the world, so we go from “nobody would ever do this” to “why not, it’s basically free”. Hyper-personalized worlds on demand. That is exactly our experience: Saint-Aubin-du-Cormier as a video game did not exist and never would have.</p>

<p>The second, harsher, explains my bugs: <strong>models cannot audit their own work</strong> in this domain, because they do not natively perceive video and do not play the game. My 199 screenshots are the material proof of that handicap: a slow and painful verification loop, where a human with a controller settles it in five seconds.</p>

<p>This is not just an impression. We measured the phenomenon in a <a href="https://arxiv.org/abs/2606.15693">recent paper on imperfect visual verification</a>, using TikZ as a case study: how far does iterative refinement remain effective when the verifier itself is unreliable? Visual verifiers, whether LLM-based or tool-augmented, do not exceed an <strong>F1 of 0.815</strong> when determining whether a visual instruction has actually been applied to the code. In other words: in roughly one case out of five, the “generate, look, fix” loop is wrong about its own diagnosis. We also observe that feedback only really helps if it precisely identifies the flaw in the image and proposes an actionable correction, otherwise it adds noise. And if TikZ, which produces static 2D figures, already poses this problem, you can imagine the size of the gap on an animated 3D world that would have to be <em>played</em> to be evaluated.</p>

<p>As long as that lock holds, the gap with a real AAA will remain.</p>

<h2 id="the-wow-and-the-open-problems">The “Wow”, and the Open Problems</h2>

<p><strong>Anyone can create their own game.</strong> That is the incredible part. Player names, teams, stadium, setting: everything is customizable. In FIFAcher, the home team is made up of eleven Achers, first names on the shirts, each with their own hair colour. Gone are the days when add-ons were reserved for hackers.</p>

<p><strong>Copyright.</strong> I asked to incorporate licensed audio (Jump, for the players walking out): a flat refusal, and rightly so. But the boundary is blurry. What stops me from putting the real player names in? The model, for its part, drew its own line without being asked: it designed <em>original</em> crests in the clubs’ colours, explicitly writing in the README that the real badges are not reproduced. Learned caution, not an explicit rule.</p>

<p><strong>Multimodality opens up what comes next.</strong> Plenty of open source components exist for 3D modelling. Nothing prevents building real little workshops to seriously model the environments, the players, the stadiums, with AI of course. That is the missing link on graphics.</p>

<p><strong>Can an “open” FIFA emerge?</strong> Technically, the building block is there. But I am not so sure, and for a social rather than technical reason: can a shared <em>platform</em> emerge when anyone can walk away with their own variant? Customization taken to the extreme may be the enemy of critical mass.</p>

<p><strong>The AI / game-dev boundary.</strong> We developed in the console. Yes, really. And we clearly wanted to edit the game <em>inside</em> the game. The next ergonomic leap may well be there.</p>

<p><strong>We never looked at the code.</strong> Never talked about the code either, and just as well: that is what made collaborating with a 10-year-old possible. But you do need experience to steer the model back when it drifts, to deploy, to understand why it lags. Not much more, at this level of complexity. The famous gap will show up further along: when complexity and expectations rise (a real AAA), you will have to reclaim some of the technical ground.</p>

<p>That leaves the simplest thing, and it may be the real argument. Both games are there, at a URL, free, with no installation: <strong><a href="https://blog.mathieuacher.com/ashfall/">ashfall</a></strong> and <strong><a href="https://blog.mathieuacher.com/fifacher/">fifacher</a></strong>. Grab a controller, or your phone, and play for five minutes. You will see the seams, there are many. But you will be playing something nobody would ever have taken the time to build, written in a week of holidays by a researcher and a 10-year-old who has not read a single line of code. That is the news. And it is far from finished moving.</p>

<hr />

<p><em>Both games: <a href="https://blog.mathieuacher.com/ashfall/">ashfall</a> (<a href="https://github.com/acherm/ashfall">code</a>, <a href="https://www.youtube.com/shorts/DUihik38JEA">video demo</a>) and <a href="https://blog.mathieuacher.com/fifacher/">fifacher</a> (<a href="https://github.com/acherm/fifacher">code</a>, <a href="https://www.youtube.com/shorts/CMiOrjE6TMY">video demo</a>). Initial prompt by <a href="https://x.com/mattshumer_/status/2081100590612922527">Matt Shumer</a> (<a href="https://github.com/mshumer/Claude-of-Duty/blob/main/prompt.md">repository</a>), adapted for football.</em></p>

<p><em>Methodology for the numbers: reconstruction from Claude Code session transcripts (927 MB of JSONL). Requests are deduplicated by message identifier, and resumed sessions (which replay the previous session’s history) are merged so nothing is counted twice. The “requests” count only what we actually wrote: automatic task-completion notifications, command echoes, and “continue” messages are excluded. The cost is estimated at public API rates (input, output, cache write and cache read), not taken from an invoice. Models: Claude Opus 4.8, Opus 5, and Fable 5.</em></p>

<p><em>On visual verification: Charly Reux, Mathieu Acher, Djamel Eddine Khelladi, Clément Quinton, Olivier Barais, <a href="https://arxiv.org/abs/2606.15693">Imperfect Visual Verification for Code Edition: A Case Study on TikZ</a>, 2026.</em></p>

<p><em>This post is the English version of one originally published in French: <a href="https://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIA/">CallOfAcher et FIFAcher : vibe coder des variantes de jeux avec mon neveu de 10 ans</a>.</em></p>

<p><em>A comment, an idea, a bug found? mathieu.acher@irisa.fr</em></p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026callofacheren</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{CallOfAcher and FIFAcher: Vibe Coding Game Variants with My 10-Year-Old Nephew}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{aug}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIAEN/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIAEN/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="LLM" /><category term="coding agents" /><category term="Claude Code" /><category term="Three.js" /><category term="video games" /><category term="generative AI" /><category term="llm4code" /><category term="WebRTC" /><category term="GitHub Pages" /><category term="sub-agents" /><category term="vibe coding" /><summary type="html"><![CDATA[A week ago, I pasted this prompt by Matt Shumer (150 words, not a single line of technical spec) into Claude Code, and went off to do something else. When I came back: a playable FPS in the browser. Not a demo, not a prototype. A game. I did it again two days later, replacing “Call of Duty” with “FIFA”, and that worked too. Then my 10-year-old nephew took over and grew both games, coming up with ideas, typing what he wanted straight into Claude’s terminal, playing, criticizing, and so on, right up to today. Both games are online, playable right now (even on a phone!), with nothing to install: blog.mathieuacher.com/ashfall: the FPS, with its two-player co-op mode, its environments, and so on. (video demo) blog.mathieuacher.com/fifacher: the football game, a match at Stade Raoul Brulat with cicadas and two exceptional players. (video demo)]]></summary></entry><entry><title type="html">CallOfAcher et FIFAcher : vibe coder des variantes de jeux avec mon neveu de 10 ans</title><link href="http://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIA/" rel="alternate" type="text/html" title="CallOfAcher et FIFAcher : vibe coder des variantes de jeux avec mon neveu de 10 ans" /><published>2026-08-03T07:00:00+00:00</published><updated>2026-08-03T07:00:00+00:00</updated><id>http://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIA</id><content type="html" xml:base="http://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIA/"><![CDATA[<p>Il y a une semaine, j’ai collé <a href="https://x.com/mattshumer_/status/2081100590612922527">ce prompt de Matt Shumer</a> (150 mots, pas une ligne de spec technique) dans <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, et je suis allé faire autre chose. En rentrant : un FPS jouable dans le navigateur. Pas une démo, pas un prototype. Un jeu. J’ai recommencé deux jours plus tard en remplaçant “Call of Duty” par “FIFA”, et ça a marché aussi. Puis mon neveu de 10 ans a fait évoluer les deux jeux, en donnant des idées, en écrivant directement ce qu’il voulait dans le terminal de Claude, en jouant, en critiquant, et ainsi de suite, jusqu’à aujourd’hui.
Les deux jeux sont en ligne, jouables tout de suite (même sur téléphone !), sans installer quoi que ce soit :</p>
<ul>
  <li><strong><a href="https://blog.mathieuacher.com/ashfall/">blog.mathieuacher.com/ashfall</a></strong> : le FPS, avec son mode coopératif à deux, ses décors, etc. (<a href="https://www.youtube.com/shorts/DUihik38JEA">démo vidéo</a>)</li>
  <li><strong><a href="https://blog.mathieuacher.com/fifacher/">blog.mathieuacher.com/fifacher</a></strong> : le foot, un match au Stade Raoul Brulat avec les cigales et deux joueurs d’exception. (<a href="https://www.youtube.com/shorts/CMiOrjE6TMY">démo vidéo</a>)</li>
</ul>

<p>Vous cliquez, ça se lance. Sur téléphone, les joysticks tactiles apparaissent tout seuls. Sur ordinateur, c’est clavier-souris, et si vous branchez une manette (Switch en USB ou en Bluetooth, ou n’importe quel pad standard), elle est reconnue immédiatement. Rien à télécharger, rien à compiler, aucun compte à créer.</p>

<p>Ce qui a suivi le premier jet m’intéresse davantage que le premier jet lui-même. Et une précision qui vaut pour tout le reste de ce billet : à l’exception de ce prompt initial, <strong>tout a été fait avec mon neveu de 10 ans</strong>, qui ne connaît rien à la programmation. Les chiffres plus bas, les motos, les ennemis en plus, le mode multijoueur, les décors bretons : c’est une semaine à deux devant la console. Il a compris le principe en dix minutes. Voici ce que j’en retiens, chiffres à l’appui.</p>

<p><img src="/assets/callofacher-ballon-cr7.jpg" alt="CallOfAcher : vue à la première personne dans une ville en ruines, un ballon de football en main, un CR7 en maillot rouge à couvert derrière une voiture" style="max-width: 100%;" /></p>

<p><em>CallOfAcher. L’objectif affiché en haut à gauche, “MARQUEZ CONTRE LES RONALDO”, est une idée de mon neveu : on ne tire pas des balles, on tire des ballons de foot sur des CR7. Tout le reste, la ville en ruines, la boussole, le HUD, les véhicules, est généré en code. <a href="https://www.youtube.com/shorts/DUihik38JEA">Démo vidéo</a>.</em></p>

<p><img src="/assets/fifacher-stade-raoul-brulat.jpg" alt="FIFAcher : match de nuit au Stade Raoul Brulat, tribunes pleines, panneaux publicitaires LED, ligne de commentaire en bas de l'écran" style="max-width: 100%;" /></p>

<p><em>FIFAcher, au Stade Raoul Brulat. La foule des tribunes est un seul objet de rendu. Les sponsors sur les panneaux LED (Breizh Cola, Kroaz Air, Menhir Bank, Armor Telecom) ont été inventés par le modèle, qui a préféré une ligue bretonne fictive aux vraies marques. En bas, le commentaire en direct réagit au fait que deux Acher se trouvent dans la même équipe. <a href="https://www.youtube.com/shorts/CMiOrjE6TMY">Démo vidéo</a>.</em></p>

<h2 id="un-prompt-un-jeu">Un prompt, un jeu</h2>

<p>Le prompt tient en un paragraphe. Il ne dit pas <em>comment</em> faire, il dit <em>ce qu’on veut</em> et <em>comment vérifier</em> :</p>

<blockquote>
  <p>I want you to build a first-person shooter at the level of the most recent Call of Duty games. […] Fan out sub-agents and have sub-agents tackle each one individually […] have a separate sub-agent check it visually to ensure it looks triple A. That separate sub-agent should be a really harsh critic […] Do this in ThreeJS. /loop until it’s utterly perfect.</p>
</blockquote>

<p>Pour FIFAcher, j’ai changé trois mots. C’est tout. Et le premier jet est jouable : on se déplace, on tire, ça tourne à 60 fps.</p>

<h2 id="ce-que-ça-met-en-branle">Ce que ça met en branle</h2>

<p>C’est là que j’ai voulu regarder sous le capot. J’ai analysé les logs de session, les commits et les transcripts de sous-agents. Le chiffre qui m’a le plus surpris n’est pas le nombre de lignes de code, c’est le nombre d’agents.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>CallOfAcher (ASHFALL)</th>
      <th>FIFAcher</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Fenêtre de travail</td>
      <td>26 → 31 juillet</td>
      <td>28 → 31 juillet</td>
    </tr>
    <tr>
      <td>Demandes qu’on a tapées</td>
      <td>45</td>
      <td>15</td>
    </tr>
    <tr>
      <td>Sous-agents lancés</td>
      <td>56</td>
      <td>36</td>
    </tr>
    <tr>
      <td>Tokens traités</td>
      <td>585 M</td>
      <td>562 M</td>
    </tr>
    <tr>
      <td>Tokens générés</td>
      <td>1,03 M</td>
      <td>1,15 M</td>
    </tr>
    <tr>
      <td>Logs de session</td>
      <td>465 Mo</td>
      <td>462 Mo</td>
    </tr>
    <tr>
      <td>Commits</td>
      <td>14</td>
      <td>12</td>
    </tr>
  </tbody>
</table>

<p>Au total : <strong>92 sous-agents</strong>, <strong>1,15 milliard de tokens traités</strong>, <strong>2,18 millions de tokens générés</strong>, pour <strong>60 demandes</strong> tapées par nous. Le rapport est vertigineux : une phrase en français déclenche des heures de travail machine. Trois modèles se sont relayés selon les phases (Opus 4.8, Opus 5, Fable 5).</p>

<p>Le “nous” est littéral : sur ces 60 demandes, deux seulement sont les prompts initiaux. Tout le reste a été formulé à deux, et une bonne partie tapée directement par mon neveu. Ces chiffres ne décrivent donc pas une performance de la machine seule, mais ce qu’une semaine de conversation à deux, dont un enfant de 10 ans, arrive à mettre en mouvement.</p>

<p>Le pattern d’orchestration mérite un mot. Pour FIFAcher, la construction initiale a été un workflow de 10 agents en parallèle, un par sous-système (foule, pelouse, ballon, éclairage, commentaire audio, post-processing…), chacun écrivant son module contre un contrat d’interface partagé : un fichier <code class="language-plaintext highlighter-rouge">CONTRACT.md</code> de 14 Ko généré en amont. Deux heures quarante de mur, dix agents concurrents. Puis des rondes de “critiques sévères” qui prennent des captures d’écran et notent le résultat, jusqu’à ce que les scores plafonnent.</p>

<p>Cette boucle de vérification visuelle est le cœur du dispositif, et elle est <em>laborieuse</em>. ASHFALL a laissé <strong>199 captures d’écran</strong> dans le dépôt, FIFAcher 31, plus 37 petits scripts de vérification (<code class="language-plaintext highlighter-rouge">voicecheck.mjs</code>, <code class="language-plaintext highlighter-rouge">switchcheck.mjs</code>, <code class="language-plaintext highlighter-rouge">rendercost.mjs</code>…) que l’agent a écrits pour s’auto-inspecter. Il ne <em>voit</em> pas le jeu tourner : il le photographie, image par image, et raisonne sur les photos.</p>

<h2 id="le-coût">Le coût</h2>

<p>Je l’ai reconstruit depuis les logs, aux tarifs API publics : <strong>entre 1 000 et 1 200 dollars</strong> pour les deux jeux (la fourchette dépend du TTL de cache appliqué). C’est une reconstruction, pas une facture, puisque j’étais sur un abonnement, mais l’ordre de grandeur est bon.</p>

<p>Le plus instructif n’est pas le montant, c’est <em>où</em> part l’argent. Certainement pas dans le code produit. Sur 1,15 milliard de tokens, le code généré ne pèse que <strong>2,18 millions de tokens, soit 0,19 % du volume</strong>. Les 99,8 % restants sont du <strong>contexte</strong> : à chaque appel, l’agent se refait expliquer le jeu qu’il est en train de modifier. Sur 5 295 appels API, cela fait en moyenne <strong>216 000 tokens de contexte par appel</strong>, relus encore et encore.</p>

<table>
  <thead>
    <tr>
      <th>Poste</th>
      <th>Volume</th>
      <th>Part du coût</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Lecture de contexte (cache)</td>
      <td>1,11 Md tokens</td>
      <td>55 %</td>
    </tr>
    <tr>
      <td>Écriture de contexte (cache)</td>
      <td>36,3 M tokens</td>
      <td>39 %</td>
    </tr>
    <tr>
      <td><strong>Code effectivement produit</strong></td>
      <td><strong>2,18 M tokens</strong></td>
      <td><strong>6 %</strong></td>
    </tr>
  </tbody>
</table>

<p>Autrement dit : <strong>on paie à 94 % le fait de lire et relire les milliers de lignes existantes, et à 6 % seulement le code écrit</strong>. C’est là que se joue le vrai coût, et c’est structurel. Plus le jeu grossit, plus la moindre évolution devient chère, y compris une évolution triviale. Ajouter une moto au cinquième jour coûte bien plus qu’ajouter la même moto au deuxième, non pas parce que la moto est plus compliquée, mais parce qu’il faut traverser tout l’existant pour la poser au bon endroit. On ne paie pas la fonctionnalité, on paie le contexte qu’elle oblige à parcourir. Un projet de 200 lignes se bricole pour quelques centimes ; le même geste sur 19 000 lignes coûte cent fois plus.</p>

<p>Et c’est le point douloureux. Ce n’est pas donné à tout le monde. Il faut aussi, a priori, des modèles frontier : je doute qu’un modèle deux crans en dessous tienne une boucle de 92 agents sans partir en vrille. Cela dit, je suis très confiant sur la démocratisation. Dans un an, ces 1 000 dollars en vaudront 50. Même aujourd’hui, on peut d’ores et déjà réduire drastiquement le coût en combinant des modèles plus légers et spécialisés avec des modèles frontier, ou en utilisant son expertise technique pour davantage cadrer le système agentique. Vu la répartition ci-dessus, c’est d’ailleurs sur le contexte qu’il faut agir en priorité : mieux découper les modules, mieux cibler ce qu’on donne à lire à l’agent, et ne pas laisser chaque sous-agent avaler tout le projet.</p>

<h2 id="tout-le-reste-sest-fait-à-deux">Tout le reste s’est fait à deux</h2>

<p>C’est la partie que je n’avais pas anticipée. Une fois le jeu créé, l’étendre devient une conversation. Mon neveu demande, je reformule à peine, il écrit même directement dans la console, et l’agent code. En une semaine, on a ajouté :</p>

<ul>
  <li><strong>tirer un ballon de foot</strong> sur des ennemis (avec, il faut le dire, un cri de célébration “SIUU” sur la touche V) ;</li>
  <li>des <strong>voitures et des motos</strong> conduisables ;</li>
  <li>plus d’ennemis, des civils qui fuient les coups de feu ;</li>
  <li>des <strong>décors réels</strong> : la Vallée de Gandy, le Stade Vélodrome, et le bourg de Saint-Aubin-du-Cormier avec son étang et les ruines de son château ;</li>
  <li>des <strong>réglages de difficulté</strong> ;</li>
  <li>le support <strong>manette</strong>, quasi immédiat, grâce à la Gamepad API du Web ;</li>
  <li>un <strong>mode mobile</strong> avec joysticks tactiles ;</li>
  <li>un <strong>mode coopératif en ligne</strong> à deux joueurs, en <strong>WebRTC pair-à-pair, sans serveur</strong> : un code à 4 caractères, et on joue ensemble.</li>
</ul>

<p>Le TL;DR : c’est <em>facile</em>. Un enfant de 10 ans y arrive et comprend très vite le principe. Oui, les temps d’attente sont longs, mais pendant ce temps on fait du vrai foot, des jeux de société, on se baigne, on vit, et on revient quand ça nous chante. Le cycle de développement épouse le rythme des vacances, ce qui est une phrase que je n’imaginais pas écrire un jour.</p>

<h2 id="la-stack-ou-pourquoi-le-web-change-tout">La stack, ou pourquoi le Web change tout</h2>

<p>Techniquement, c’est d’une simplicité désarmante, et c’est ça le point :</p>

<ul>
  <li><strong>Three.js</strong> (r185) et <strong>rien d’autre</strong> côté rendu. Aucun asset externe : toutes les textures, tous les modèles, tous les sons sont <strong>générés en code</strong>. La foule du stade de FIFAcher, ce sont des dizaines de milliers de supporters dans un seul <code class="language-plaintext highlighter-rouge">InstancedMesh</code>, donc un seul appel de rendu, avec un atlas de 64 cellules peint procéduralement sur un canvas, et un masque séparé pour ne teinter que les vêtements et jamais la peau.</li>
  <li>~18 900 lignes de JavaScript pour ASHFALL, réparties en 19 modules ES.</li>
  <li><strong>Vite</strong> pour le build, <strong>Playwright/Puppeteer</strong> pour les captures automatiques.</li>
  <li><strong>PeerJS/WebRTC</strong> pour le co-op, le broker gratuit servant <em>uniquement</em> à la signalisation.</li>
  <li>Déploiement <strong>statique sur GitHub Pages</strong> via GitHub Actions. Zéro serveur, zéro infra, zéro coût d’hébergement.</li>
</ul>

<p>C’est ce dernier point qui rend l’affaire réelle plutôt qu’anecdotique. Un <code class="language-plaintext highlighter-rouge">git push</code>, et le jeu est en ligne, pour n’importe qui, partout, immédiatement. Pas de store, pas de validation, pas de build par plateforme. Le même fichier <code class="language-plaintext highlighter-rouge">index.html</code> sert le gamin sur son téléphone dans le train et l’adulte avec sa manette devant son écran. Allez cliquer sur les deux liens du début, c’est plus rapide que de finir ce paragraphe.</p>

<h2 id="ce-qui-ne-marche-pas">Ce qui ne marche pas</h2>

<p>Soyons nets : <strong>ce n’est pas le niveau d’un vrai Call of Duty ni d’un vrai FIFA</strong>. L’écart est réel, et il a plusieurs causes.</p>

<p><strong>Le graphisme et la 3D.</strong> C’est là que le fossé est le plus visible. Du procédural sans artiste, ça se voit.</p>

<p><strong>Les instructions parfois ignorées.</strong> Il arrive qu’une fonctionnalité demandée ne soit tout simplement pas implémentée, sans que rien ne le signale. Il faut re-demander.</p>

<p><strong>La vérification reste de surface.</strong> Les critiques-agents notent des captures d’écran, ils ne jouent pas. Résultat : des bugs et des glitchs un peu partout, y compris des choses qu’un humain repère en trois secondes de manette.</p>

<p><strong>La jouabilité et le tuning : dur, dur.</strong> C’est le point le plus difficile. Régler la sensibilité, l’équilibrage, le “feel”, ça demande des dizaines d’allers-retours, et c’est le domaine où l’agent est le plus démuni.</p>

<p><strong>La connaissance des décors est réelle mais mal restituée.</strong> Sur le Vélodrome, le modèle <em>sait</em> des choses justes : la forme des virages, l’ambiance. Mais la restitution reste approximative. Sur d’autres décors, malgré une connaissance manifeste, le résultat est franchement bullshit. Il faut travailler bien plus.</p>

<p>Cela dit, une nuance amusante : pour FIFAcher, la faiblesse technique <strong>devient presque une feature</strong>. Le jeu ne se joue pas comme un FIFA, il force à trouver d’autres stratégies. Le gameplay est différent, pas seulement inférieur.</p>

<h2 id="le-vrai-plafond">Le vrai plafond</h2>

<p><a href="https://x.com/karpathy/status/2083749667410727319">Karpathy a posté</a> une réflexion qui recoupe exactement ce que j’ai observé. Deux idées m’ont marqué.</p>

<p>La première : on quitte le territoire du “dessine-moi un pélican en SVG”. Ces jeux relèvent d’une catégorie de tâches que <strong>personne de sensé n’aurait entreprises</strong>, trop custom, trop longues, aucun retour sur investissement. Les LLMs ont toute la patience du monde, donc on passe de “personne ne ferait jamais ça” à “pourquoi pas, c’est quasi gratuit”. Des mondes hyper-personnalisés à la demande. C’est exactement notre expérience : Saint-Aubin-du-Cormier en jeu vidéo, ça n’existait pas et ça n’aurait jamais existé.</p>

<p>La seconde, plus sévère, explique mes bugs : <strong>les modèles ne savent pas auditer leur propre travail</strong> dans ce domaine, parce qu’ils ne perçoivent pas nativement la vidéo et ne jouent pas au jeu. Mes 199 captures d’écran sont la preuve matérielle de ce handicap : une boucle de vérification lente et douloureuse, là où un humain avec une manette tranche en cinq secondes.</p>

<p>Ce n’est pas qu’une impression. On a mesuré le phénomène dans un <a href="https://arxiv.org/abs/2606.15693">papier récent sur la vérification visuelle imparfaite</a>, en prenant TikZ comme cas d’étude : jusqu’où le raffinement itératif reste-t-il efficace quand le vérificateur lui-même n’est pas fiable ? Les vérificateurs visuels, LLM ou outillés, ne dépassent pas un <strong>F1 de 0,815</strong> pour déterminer si une instruction visuelle a bien été appliquée au code. Autrement dit : dans un cas sur cinq environ, la boucle “génère, regarde, corrige” se trompe sur son propre diagnostic. On observe aussi que le feedback n’aide vraiment que s’il identifie précisément le défaut de l’image et propose une correction actionnable, sinon il ajoute du bruit. Et si TikZ, qui produit des figures statiques en 2D, pose déjà ce problème, on imagine l’ampleur du gap sur un monde 3D animé qu’il faudrait <em>jouer</em> pour évaluer.</p>

<p>Tant que ce verrou tient, l’écart avec un vrai AAA restera.</p>

<h2 id="le-wow-et-les-problèmes-ouverts">Le “wow”, et les problèmes ouverts</h2>

<p><strong>Chacun peut créer son jeu.</strong> C’est ça, l’incroyable. Le nom des joueurs, les équipes, le stade, le décor : tout est personnalisable. Dans FIFAcher, l’équipe locale est composée de onze Acher, prénoms sur les maillots, chacun avec sa couleur de cheveux. Fini le temps où les add-ons étaient réservés aux hackers.</p>

<p><strong>Les droits d’auteur.</strong> J’ai demandé d’incorporer du son sous licence (Jump pour l’entrée des joueurs) : refus net, et à raison. Mais la frontière est floue. Qu’est-ce qui m’interdit de mettre les vrais noms de joueurs ? Le modèle, lui, a tracé sa propre ligne sans qu’on le lui demande : il a dessiné des blasons <em>originaux</em> aux couleurs des clubs, en écrivant explicitement dans le README que les vrais écussons ne sont pas reproduits. Prudence apprise, pas règle explicite.</p>

<p><strong>Le multimodal ouvre la suite.</strong> Plein de composants open source existent pour la modélisation 3D. Rien n’empêche de bâtir de vrais petits ateliers pour modéliser sérieusement les décors, les joueurs, les stades, avec l’IA évidemment. C’est le chaînon manquant du graphisme.</p>

<p><strong>Un FIFA “open” peut-il émerger ?</strong> Techniquement, la brique est là. Mais je n’en suis pas si sûr, et pour une raison sociale, pas technique : une <em>plateforme</em> partagée peut-elle émerger quand n’importe qui peut partir avec son propre variant ? La personnalisation à l’extrême est peut-être l’ennemie de la masse critique.</p>

<p><strong>La frontière IA / dev de jeu.</strong> On a développé dans la console. Si si. Et clairement, on avait envie d’éditer le jeu <em>dans</em> le jeu. Le prochain saut d’ergonomie est peut-être là.</p>

<p><strong>On n’a jamais regardé le code.</strong> Jamais parlé du code, non plus, et tant mieux : c’est ce qui a rendu la collaboration avec un enfant de 10 ans possible. Mais il faut de l’expérience pour recadrer le modèle quand ça dérape, pour déployer, pour comprendre pourquoi ça rame. Pas beaucoup plus, à ce niveau de complexité. Le fameux fossé arrivera plus loin : quand la complexité et l’exigence monteront (un vrai AAA), il faudra se réapproprier certains éléments techniques.</p>

<p>Reste le plus simple, et c’est peut-être le vrai argument. Les deux jeux sont là, à une URL, gratuits, sans installation : <strong><a href="https://blog.mathieuacher.com/ashfall/">ashfall</a></strong> et <strong><a href="https://blog.mathieuacher.com/fifacher/">fifacher</a></strong>. Prenez une manette, ou votre téléphone, jouez cinq minutes. Vous verrez les coutures, elles sont nombreuses. Mais vous jouerez à quelque chose que personne n’aurait jamais pris le temps de fabriquer, écrit en une semaine de vacances par un chercheur et un gamin de 10 ans qui n’a pas lu une ligne de code. C’est ça, la nouvelle. Et ça n’a pas fini de bouger.</p>

<hr />

<p><em>Les deux jeux : <a href="https://blog.mathieuacher.com/ashfall/">ashfall</a> (<a href="https://github.com/acherm/ashfall">code</a>, <a href="https://www.youtube.com/shorts/DUihik38JEA">démo vidéo</a>) et <a href="https://blog.mathieuacher.com/fifacher/">fifacher</a> (<a href="https://github.com/acherm/fifacher">code</a>, <a href="https://www.youtube.com/shorts/CMiOrjE6TMY">démo vidéo</a>). Prompt initial de <a href="https://x.com/mattshumer_/status/2081100590612922527">Matt Shumer</a> (<a href="https://github.com/mshumer/Claude-of-Duty/blob/main/prompt.md">dépôt</a>), adapté pour le football.</em></p>

<p><em>Méthodologie des chiffres : reconstruction depuis les transcripts de session Claude Code (927 Mo de JSONL). Les requêtes sont dédupliquées par identifiant de message, et les sessions reprises (qui rejouent l’historique de la session précédente) sont fusionnées pour ne pas compter deux fois. Les “demandes” comptent uniquement ce qu’on a réellement écrit : les notifications automatiques de fin de tâche, les échos de commandes et les “continue” sont exclus. Le coût est estimé aux tarifs API publics (input, output, écriture et lecture de cache), pas relevé sur une facture. Modèles : Claude Opus 4.8, Opus 5 et Fable 5.</em></p>

<p><em>Sur la vérification visuelle : Charly Reux, Mathieu Acher, Djamel Eddine Khelladi, Clément Quinton, Olivier Barais, <a href="https://arxiv.org/abs/2606.15693">Imperfect Visual Verification for Code Edition: A Case Study on TikZ</a>, 2026.</em></p>

<p><em>Un avis, une idée, un bug trouvé ? mathieu.acher@irisa.fr</em></p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026callofacher</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{CallOfAcher et FIFAcher : vibe coder des variantes de jeux avec mon neveu de 10 ans}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{aug}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIA/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CallOfAcherFIFAcherJeuxIA/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="LLM" /><category term="coding agents" /><category term="Claude Code" /><category term="Three.js" /><category term="jeux vidéo" /><category term="generative AI" /><category term="llm4code" /><category term="WebRTC" /><category term="GitHub Pages" /><category term="sous-agents" /><summary type="html"><![CDATA[Il y a une semaine, j’ai collé ce prompt de Matt Shumer (150 mots, pas une ligne de spec technique) dans Claude Code, et je suis allé faire autre chose. En rentrant : un FPS jouable dans le navigateur. Pas une démo, pas un prototype. Un jeu. J’ai recommencé deux jours plus tard en remplaçant “Call of Duty” par “FIFA”, et ça a marché aussi. Puis mon neveu de 10 ans a fait évoluer les deux jeux, en donnant des idées, en écrivant directement ce qu’il voulait dans le terminal de Claude, en jouant, en critiquant, et ainsi de suite, jusqu’à aujourd’hui. Les deux jeux sont en ligne, jouables tout de suite (même sur téléphone !), sans installer quoi que ce soit : blog.mathieuacher.com/ashfall : le FPS, avec son mode coopératif à deux, ses décors, etc. (démo vidéo) blog.mathieuacher.com/fifacher : le foot, un match au Stade Raoul Brulat avec les cigales et deux joueurs d’exception. (démo vidéo)]]></summary></entry><entry><title type="html">Le test du lave-auto : les LLMs ont-ils vraiment du sens commun ?</title><link href="http://blog.mathieuacher.com/LLMCarWashCommonSense/" rel="alternate" type="text/html" title="Le test du lave-auto : les LLMs ont-ils vraiment du sens commun ?" /><published>2026-05-26T06:00:00+00:00</published><updated>2026-05-26T06:00:00+00:00</updated><id>http://blog.mathieuacher.com/LLMCarWashCommonSense</id><content type="html" xml:base="http://blog.mathieuacher.com/LLMCarWashCommonSense/"><![CDATA[<p>L’exemple est devenu un classique : <em>“Je dois laver ma voiture. Le centre de lavage est à 100 m. J’y vais à pied ou en voiture ?”</em>. Réponse de sens commun : en voiture, évidemment, sinon la voiture n’est pas là pour être lavée. Sauf que la plupart des LLMs tombent dans le panneau et recommandent la marche pour des raisons d’écologie ou de courte distance.</p>

<p><a href="https://x.com/Fabien_Mikol/status/2058516722139701340">Fabien Mikol a relayé/relancé l’affaire il y a quelques jours, suite aux propos de Yann Lecun</a>, <a href="https://x.com/GrablyR/status/2058548560275058971">Raphael Grably a vérifié dans la foulée, avec d’autres</a>. La question circule régulièrement comme test informel des capacités de raisonnement des LLMs. J’avais déjà fait un mini-test en février 2025 (72 réponses, 71 échecs) puis j’étais passé à autre chose. Entre temps, GPT-5, Claude 4.7, Gemini 2.5 Pro, Qwen3-thinking et compagnie sont sortis. Donc je me suis dit : refaisons l’expérience sérieusement. Plus de modèles, plus de répétitions, plusieurs formulations, un juge LLM pour scorer.</p>

<p>Voici ce que j’ai trouvé sur plus de 4 300 réponses LLM générées et près de 4 000 jugements, plus un échantillon de 128 items re-jugés en parallèle par 4 agents Claude indépendants pour valider la méthodologie. Chaque réponse est notée sur deux critères évalués séparément : (a) la conclusion finale recommande-t-elle la voiture ? et (b) la réponse évite-t-elle de justifier sa recommandation par la courte distance ? Quatre surprises, dans l’ordre où elles me sont tombées dessus :</p>

<ol>
  <li>Le piège reste largement actif. Quinze mois après mon premier test, avec des modèles plus gros, plus chers, censément “raisonneurs”, la majorité des LLMs se font encore avoir. Quatre modèles sur huit n’atteignent jamais 25 % de bonne conclusion.</li>
  <li>Reformuler la question peut faire basculer un modèle de plus de 60 points de pourcentage, dans un sens ou dans l’autre. Effet stable à n=120 répétitions, pas du bruit. Cette fragilité concerne le mode d’inférence par défaut : activer un effort de raisonnement explicite la rattrape pour certains modèles (Claude Sonnet), mais pas pour d’autres (DeepSeek R1, qui pourtant raisonne par construction).</li>
  <li>GPT-5.5 sort du lot, mais pas où on l’attendrait. Sur la capacité à conclure correctement, plusieurs modèles le rejoignent quand on active leur raisonnement (Sonnet 4.6, Qwen3-thinking, à 95-100 %). Sa singularité est ailleurs : il est le seul à éviter systématiquement d’invoquer la “courte distance” comme excuse, là où aucun autre modèle ne dépasse 58 % sur ce critère, même à effort maximal.</li>
  <li>Le choix de la métrique change le classement. Claude Opus 4.7 paraissait dans le mou (~20 % de succès) avec un critère composite “conclusion correcte ET pas de mention de distance”. Sur la conclusion seule, il est à 97 % : il a la bonne intuition presque toujours, il discute juste systématiquement la distance avant d’y arriver. Une bonne partie du classement initial reflétait la verbosité, pas la capacité.</li>
</ol>

<p><em>Code et données : <a href="https://github.com/acherm/laveauto-llm">laveauto-llm</a>. Tous les modèles appelés via <a href="https://openrouter.ai">OpenRouter</a>. Juge LLM = GPT-5.5 à température 0, deux verdicts indépendants par réponse (C1 = recommande la voiture, C2 = ne justifie pas par la distance).</em></p>

<hr />

<h2 id="1-le-dispositif">1. Le dispositif</h2>

<p>J’ai conduit deux expériences en parallèle, mêmes conditions (mêmes modèles, mêmes répétitions, même juge), mais deux formulations différentes du piège :</p>

<ul>
  <li>V1 (la formulation “originale”, celle de février 2025) : <em>“Je veux laver ma voiture. Le lave auto est à 150 mètres de chez moi. J’y vais à pied ou en voiture ?”</em></li>
  <li>V2 (une reformulation en oui/non, <a href="https://x.com/GrablyR/status/2058548560275058971">utilisée notamment par GrablyR</a>) : <em>“Je dois aller laver ma voiture. Le centre de lavage automobile est à 100 mètres de chez moi. Dois-je y aller à pieds ?”</em></li>
</ul>

<p>L’intérêt d’en faire deux : tester ce qui est censé être la même question avec deux mises en forme proches. On verra dans les sections 4 et 5 que ce choix s’est avéré central. Pour l’instant, gardons en tête que tout ce qui suit existe en double : un score V1 et un score V2 par modèle.</p>

<p>Bonne réponse attendue dans les deux cas : <em>“en voiture”</em>, sinon la voiture n’est pas au lave-auto.</p>

<p>Critères du juge, évalués indépendamment :</p>
<ul>
  <li>C1 : la conclusion finale recommande la voiture.</li>
  <li>C2 : la réponse ne justifie pas une recommandation par la courte distance (un argument écologique séduisant mais hors-sujet, qui détourne du piège réel).</li>
</ul>

<p>Toutes les tables qui suivent reportent les deux critères séparément. La colonne “strict” correspond à C1 et C2 vrais tous les deux. La dissociation a été cruciale : un premier juge global réussi/raté pénalisait à tort des modèles qui avaient la bonne conclusion mais discutaient la distance en chemin.</p>

<p>Conditions principales : 8 modèles, n = 120 par modèle et par variante (240 réponses par modèle au total), température 0.7, aucun system prompt. Juge = GPT-5.5 à température 0.</p>

<p>8 modèles testés (mai 2026) :</p>
<ul>
  <li>Frontière fermée : <code class="language-plaintext highlighter-rouge">openai/gpt-5.5</code>, <code class="language-plaintext highlighter-rouge">anthropic/claude-opus-4.7</code>, <code class="language-plaintext highlighter-rouge">anthropic/claude-sonnet-4.6</code>, <code class="language-plaintext highlighter-rouge">openai/gpt-4o</code> (baseline), <code class="language-plaintext highlighter-rouge">google/gemini-2.5-pro</code></li>
  <li>Poids ouverts : <code class="language-plaintext highlighter-rouge">meta-llama/llama-3.3-70b-instruct</code>, <code class="language-plaintext highlighter-rouge">mistralai/mistral-large-2512</code>, <code class="language-plaintext highlighter-rouge">deepseek/deepseek-chat-v3.1</code></li>
</ul>

<hr />

<h2 id="2-le-verdict-de-base--conclusion-correcte-vs-critère-strict">2. Le verdict de base : conclusion correcte vs critère strict</h2>

<table>
  <thead>
    <tr>
      <th>Modèle</th>
      <th>C1 : conclusion = voiture</th>
      <th>C2 : pas de justif distance</th>
      <th>Strict (C1 + C2)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>openai/gpt-5.5</td>
      <td><strong>100.0 %</strong></td>
      <td><strong>64.2 %</strong></td>
      <td><strong>64.2 %</strong></td>
    </tr>
    <tr>
      <td>anthropic/claude-opus-4.7</td>
      <td><strong>97.5 %</strong></td>
      <td>5.8 %</td>
      <td>5.8 %</td>
    </tr>
    <tr>
      <td>anthropic/claude-sonnet-4.6</td>
      <td><strong>96.7 %</strong></td>
      <td>26.7 %</td>
      <td>26.7 %</td>
    </tr>
    <tr>
      <td>google/gemini-2.5-pro</td>
      <td>38.1 %</td>
      <td>4.2 %</td>
      <td>4.2 %</td>
    </tr>
    <tr>
      <td>meta-llama/llama-3.3-70b-instruct</td>
      <td>23.3 %</td>
      <td>20.8 %</td>
      <td>15.0 %</td>
    </tr>
    <tr>
      <td>deepseek/deepseek-chat-v3.1</td>
      <td>5.8 %</td>
      <td>0 %</td>
      <td>0 %</td>
    </tr>
    <tr>
      <td>mistralai/mistral-large-2512</td>
      <td>4.2 %</td>
      <td>0 %</td>
      <td>0 %</td>
    </tr>
    <tr>
      <td>openai/gpt-4o</td>
      <td>0.8 %</td>
      <td>0 %</td>
      <td>0 %</td>
    </tr>
  </tbody>
</table>

<p>(IC95 Wilson, n=120 par modèle, voir <code class="language-plaintext highlighter-rouge">results_rejudge_v1.json</code> pour le détail.)</p>

<p>Selon le critère utilisé, deux histoires. Sur la conclusion seule (C1), trois modèles dépassent 96 % (GPT-5.5, Opus 4.7, Sonnet 4.6) et quatre restent sous 25 %. Partage net entre “comprend le piège” et “n’a pas compris”. Sur le strict, seul GPT-5.5 dépasse 50 % : le critère “ne pas justifier par la distance” est en pratique très dur, même pour les modèles qui concluent juste. Opus 4.7 et Sonnet 4.6 mentionnent presque toujours la distance, et un benchmark à critères agrégés les sous-estime.</p>

<p>Trois observations qualitatives :</p>

<ul>
  <li>GPT-4o n’obtient quasiment aucun bon verdict (1 sur 120 en C1, 0 en strict). La réponse est souvent quasi-identique : <em>“Si le lave-auto est à seulement 150 mètres, il est plus écologique et pratique d’y aller à pied.”</em>. À température 0.7 on s’attendrait à de la variation, et il y en a un peu sur la formulation, mais le squelette et la conclusion sont stables (vérifié aussi à temp 0.3 et 1.0). Le dernier snapshot public est <code class="language-plaintext highlighter-rouge">gpt-4o-2024-11-20</code>, avec une <a href="https://help.openai.com/en/articles/9624314-model-release-notes">mise à jour le 29 janvier 2025</a> (cutoff de connaissance étendu à juin 2024). Le modèle a été <a href="https://help.openai.com/articles/20001051">retiré de ChatGPT le 13 février 2026</a>, mais reste disponible via l’API où il continue d’alimenter de nombreux produits. Ce qu’on observe ici, c’est donc le comportement d’un modèle figé sur ce piège — et figé en restant largement utilisé en aval.</li>
  <li>Opus 4.7 et Sonnet 4.6 sont au coude-à-coude sur C1 (97-98 %), avec des styles différents. Opus commence souvent par <em>“À pied !”</em> puis se ravise vers la voiture en fin de réponse ; Sonnet structure mieux son argumentaire. Le mythe “Opus &gt; Sonnet” ne se vérifie pas ici, mais pas non plus l’inverse.</li>
  <li>Gemini 2.5 Pro est bimodal sur C1 : 38 % de bonne conclusion, le reste à pied. Sur les ~75 réponses fausses, c’est la même structure “Excellente question existentielle…” suivie de “à pied, c’est plus écologique”. Llama 3.3 montre la même bimodalité.</li>
</ul>

<hr />

<h2 id="3-le-raisonnement-aide-sauf-quand-il-renforce-le-mauvais-prior">3. Le raisonnement aide, sauf quand il renforce le mauvais prior</h2>

<p>Pour les modèles qui supportent un effort de raisonnement explicite (<code class="language-plaintext highlighter-rouge">reasoning.effort=high</code> via OpenRouter), j’ai relancé V1 et V2 sur 4 modèles, n=100 par cellule. Les non-éligibles (GPT-4o, Mistral Large, Llama 3.3, DeepSeek-chat v3.1) ne sont pas reasoning-capables ; GPT-5.5 est déjà à 100 %. 800 réponses générées, jugement dissocié C1/C2.</p>

<table>
  <thead>
    <tr>
      <th>Modèle</th>
      <th>Variante</th>
      <th>C1</th>
      <th>C2</th>
      <th>Strict</th>
      <th>Tokens raison. (moy.)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>anthropic/claude-sonnet-4.6</td>
      <td>V1</td>
      <td><strong>100 %</strong></td>
      <td>58 %</td>
      <td>58 %</td>
      <td>167</td>
    </tr>
    <tr>
      <td>anthropic/claude-sonnet-4.6</td>
      <td>V2</td>
      <td><strong>99 %</strong></td>
      <td>49 %</td>
      <td>49 %</td>
      <td>183</td>
    </tr>
    <tr>
      <td>qwen/qwen3-235b-a22b-thinking</td>
      <td>V1</td>
      <td>95 %</td>
      <td>29 %</td>
      <td>29 %</td>
      <td>1 967</td>
    </tr>
    <tr>
      <td>qwen/qwen3-235b-a22b-thinking</td>
      <td>V2</td>
      <td>96 %</td>
      <td>6 %</td>
      <td>6 %</td>
      <td>1 498</td>
    </tr>
    <tr>
      <td>google/gemini-2.5-pro</td>
      <td>V1</td>
      <td>58 %</td>
      <td>11 %</td>
      <td>11 %</td>
      <td>1 427</td>
    </tr>
    <tr>
      <td>google/gemini-2.5-pro</td>
      <td>V2</td>
      <td>95 %</td>
      <td>57 %</td>
      <td>57 %</td>
      <td>1 224</td>
    </tr>
    <tr>
      <td>deepseek/deepseek-r1</td>
      <td>V1</td>
      <td>7 %</td>
      <td>1 %</td>
      <td>1 %</td>
      <td>455</td>
    </tr>
    <tr>
      <td>deepseek/deepseek-r1</td>
      <td>V2</td>
      <td>14 %</td>
      <td>2 %</td>
      <td>2 %</td>
      <td>467</td>
    </tr>
  </tbody>
</table>

<h3 id="sans-raisonnement-vs-avec-raisonnement-high-c1-uniquement">Sans raisonnement vs avec raisonnement HIGH (C1 uniquement)</h3>

<table>
  <thead>
    <tr>
      <th>Modèle</th>
      <th>V1 sans</th>
      <th>V1 avec</th>
      <th>Δ</th>
      <th>V2 sans</th>
      <th>V2 avec</th>
      <th>Δ</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>anthropic/claude-sonnet-4.6</td>
      <td>97 %</td>
      <td>100 %</td>
      <td>+3</td>
      <td>39 %</td>
      <td><strong>99 %</strong></td>
      <td><strong>+60</strong></td>
    </tr>
    <tr>
      <td>google/gemini-2.5-pro</td>
      <td>38 %</td>
      <td>58 %</td>
      <td>+20</td>
      <td>96 %</td>
      <td>95 %</td>
      <td>−1</td>
    </tr>
  </tbody>
</table>

<p>Le résultat le plus marquant : <strong>activer le raisonnement sur Sonnet 4.6 fait disparaître presque entièrement sa fragilité sur V2</strong> (39 % → 99 %). C’est le seul cas du dataset où une intervention transforme un modèle fragile en modèle stable à la reformulation.</p>

<p>Observations sur les deux autres modèles testés :</p>

<ul>
  <li>DeepSeek R1, modèle reasoning par construction, reste planté à 7-14 % C1 dans les deux variantes. Augmenter l’effort à <code class="language-plaintext highlighter-rouge">high</code> (vs <code class="language-plaintext highlighter-rouge">medium</code>, déjà testé séparément) ne change rien. Les ~450 tokens de raisonnement sont consommés à argumenter en boucle pour la marche. Le prior tient, le raisonnement sert à l’étayer.</li>
  <li>Qwen3-thinking est très stable sur la conclusion (95-96 % C1 dans les deux variantes), avec ~1 500-2 000 tokens de raisonnement par appel. Sur le strict, il reste très pénalisé : il discute presque toujours la distance, encore plus en V2.</li>
</ul>

<p>Note méthodologique : dans une exploration préliminaire à n=10 avec l’ancien juge global, Qwen3-thinking marquait “100 %” sur V1. Avec dissocié et n=100, c’est 95 % C1 et 29 % strict. Le “100 %” était en partie un artefact du juge initial sur petit échantillon.</p>

<p>Sur OpenRouter, <code class="language-plaintext highlighter-rouge">qwen/qwen3-235b-a22b-thinking-2507</code> refuse même de répondre avec <code class="language-plaintext highlighter-rouge">reasoning.enabled=false</code> (erreur 400 : <em>“Reasoning is mandatory for this endpoint and cannot be disabled.”</em>). On ne peut donc pas isoler “même modèle, sans thinking”.</p>

<h3 id="et-chez-openai--la-lignée-gpt-5-à-effort-variable">Et chez OpenAI ? La lignée GPT-5 à effort variable</h3>

<p>Pour compléter, j’ai testé deux ancêtres de GPT-5.5 — <code class="language-plaintext highlighter-rouge">gpt-5</code> (août 2025) et <code class="language-plaintext highlighter-rouge">gpt-5.1</code> (fin 2025) — aux trois efforts disponibles (low, medium, high), V1 et V2, n=100 par cellule.</p>

<table>
  <thead>
    <tr>
      <th>Modèle</th>
      <th>Effort</th>
      <th>V1 C1</th>
      <th>V1 C2</th>
      <th>V2 C1</th>
      <th>V2 C2</th>
      <th>Tok. raison. (V1)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>gpt-5</td>
      <td>low</td>
      <td>100 %</td>
      <td>44 %</td>
      <td>99 %</td>
      <td>31 %</td>
      <td>272</td>
    </tr>
    <tr>
      <td>gpt-5</td>
      <td>medium</td>
      <td>100 %</td>
      <td>23 %</td>
      <td>99 %</td>
      <td>45 %</td>
      <td>709</td>
    </tr>
    <tr>
      <td>gpt-5</td>
      <td>high</td>
      <td>100 %</td>
      <td>27 %</td>
      <td>100 %</td>
      <td>42 %</td>
      <td>1 341</td>
    </tr>
    <tr>
      <td>gpt-5.1</td>
      <td><strong>low</strong></td>
      <td>75 %</td>
      <td>24 %</td>
      <td><strong>13 %</strong></td>
      <td>1 %</td>
      <td><strong>7</strong></td>
    </tr>
    <tr>
      <td>gpt-5.1</td>
      <td>medium</td>
      <td>98 %</td>
      <td>35 %</td>
      <td>79 %</td>
      <td>31 %</td>
      <td>108</td>
    </tr>
    <tr>
      <td>gpt-5.1</td>
      <td>high</td>
      <td>100 %</td>
      <td>51 %</td>
      <td>93 %</td>
      <td>46 %</td>
      <td>210</td>
    </tr>
  </tbody>
</table>

<p>Quatre enseignements :</p>

<ul>
  <li>À effort <code class="language-plaintext highlighter-rouge">low</code>, <strong>gpt-5.1 est dramatiquement pire que gpt-5</strong> (V2 : 13 % vs 99 % C1). La régression apparente s’explique par la consommation : gpt-5 à <code class="language-plaintext highlighter-rouge">low</code> alloue encore ~272 tokens de raisonnement, gpt-5.1 à <code class="language-plaintext highlighter-rouge">low</code> n’en alloue que ~7. OpenAI a vraisemblablement redéfini ce que <code class="language-plaintext highlighter-rouge">low</code> signifie, pour en faire un mode quasi non-reasoner. Conséquence pratique : un utilisateur qui passe à gpt-5.1 en gardant <code class="language-plaintext highlighter-rouge">effort=low</code> peut voir ses résultats s’effondrer sans changer son code. Comparer un même <code class="language-plaintext highlighter-rouge">effort</code> entre deux versions n’est pas comparer un même budget de calcul.</li>
  <li>Lecture côté produit grand public : autour de février 2026, ChatGPT servait gpt-5.1 en mode “instant” (sans étape de réflexion explicite, équivalent à effort <code class="language-plaintext highlighter-rouge">low</code>) pour les réponses rapides. Sur ce piège, ça correspond donc à 75 % de bonne conclusion sur la formulation V1, et <strong>seulement 13 % sur V2</strong> — soit ce que recevaient les utilisateurs “instant” de ChatGPT à ce moment-là. Aujourd’hui (mai 2026), ChatGPT Free <a href="https://help.openai.com/en/articles/9275245-using-chatgpt-s-free-tier-faq">tourne sur GPT-5.5</a> (avec limites d’usage), qui passe le piège à 100 %. Mais entre les produits tiers qui consomment l’API et les chatbots qui n’utilisent pas la dernière génération, la version “13 % sur une simple reformulation” est probablement encore largement servie.</li>
  <li>Le seuil “minimum de calcul pour résister à la reformulation” est très visible dans la lignée gpt-5.1 : 7 tokens (low) = 13 % V2, ~108 tokens (medium) = 79 %, ~210 tokens (high) = 93 %. Il existe une bande étroite en dessous de laquelle la robustesse s’effondre, et au-dessus de laquelle elle tient.</li>
  <li>gpt-5 (août 2025), même à <code class="language-plaintext highlighter-rouge">low</code>, atteint 99-100 % C1 dans les deux variantes. La robustesse à la reformulation sur la conclusion existe dans la lignée OpenAI depuis le début de la famille GPT-5.</li>
  <li>Ce qui évolue monotonement entre gpt-5, gpt-5.1 et gpt-5.5 à effort élevé, c’est le C2 : 23-44 % chez gpt-5, 24-51 % chez gpt-5.1, 64-72 % chez gpt-5.5. C’est sur la parcimonie du raisonnement (ne pas convoquer la distance) que la lignée s’affine, plus que sur la conclusion elle-même.</li>
</ul>

<p>(Un caveat méthodologique sur la lecture des <code class="language-plaintext highlighter-rouge">reasoning_tokens</code> arrive en section 6.)</p>

<hr />

<h2 id="4-changer-la-formulation--tout-seffondre-ou-explose">4. Changer la formulation : tout s’effondre (ou explose)</h2>

<p>On passe maintenant à la deuxième formulation annoncée en section 1 :</p>

<blockquote>
  <p><em>“Je dois aller laver ma voiture. Le centre de lavage automobile est à 100 mètres de chez moi.</em></p>

  <p><em>Dois-je y aller à pieds?”</em></p>
</blockquote>

<p>Question oui/non, distance 100 m, vocabulaire “centre de lavage automobile” au lieu de “lave auto”. Tout le reste est inchangé : mêmes 8 modèles, n = 120, température 0.7, pas de system prompt, mêmes critères dissociés.</p>

<table>
  <thead>
    <tr>
      <th>Modèle</th>
      <th>C1 : conclusion = voiture</th>
      <th>C2 : pas de justif distance</th>
      <th>Strict (C1 + C2)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>openai/gpt-5.5</td>
      <td><strong>100.0 %</strong></td>
      <td><strong>71.7 %</strong></td>
      <td><strong>71.7 %</strong></td>
    </tr>
    <tr>
      <td>google/gemini-2.5-pro</td>
      <td><strong>95.8 %</strong></td>
      <td>40.0 %</td>
      <td>40.0 %</td>
    </tr>
    <tr>
      <td>anthropic/claude-opus-4.7</td>
      <td><strong>95.0 %</strong></td>
      <td>0 %</td>
      <td>0 %</td>
    </tr>
    <tr>
      <td>anthropic/claude-sonnet-4.6</td>
      <td>39.2 %</td>
      <td>0 %</td>
      <td>0 %</td>
    </tr>
    <tr>
      <td>deepseek/deepseek-chat-v3.1</td>
      <td>15.0 %</td>
      <td>1.7 %</td>
      <td>1.7 %</td>
    </tr>
    <tr>
      <td>openai/gpt-4o</td>
      <td>1.7 %</td>
      <td>0 %</td>
      <td>0 %</td>
    </tr>
    <tr>
      <td>meta-llama/llama-3.3-70b-instruct</td>
      <td>0 %</td>
      <td>0 %</td>
      <td>0 %</td>
    </tr>
    <tr>
      <td>mistralai/mistral-large-2512</td>
      <td>0 %</td>
      <td>0 %</td>
      <td>0 %</td>
    </tr>
  </tbody>
</table>

<p>Comparons côte-à-côte le C1 entre V1 et V2 :</p>

<table>
  <thead>
    <tr>
      <th>Modèle</th>
      <th>V1 C1</th>
      <th>V2 C1</th>
      <th>Δ</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>openai/gpt-5.5</td>
      <td>100.0 %</td>
      <td>100.0 %</td>
      <td>0</td>
    </tr>
    <tr>
      <td>anthropic/claude-opus-4.7</td>
      <td>97.5 %</td>
      <td>95.0 %</td>
      <td>−2.5</td>
    </tr>
    <tr>
      <td>anthropic/claude-sonnet-4.6</td>
      <td>96.7 %</td>
      <td>39.2 %</td>
      <td><strong>−57.5</strong></td>
    </tr>
    <tr>
      <td>google/gemini-2.5-pro</td>
      <td>38.1 %</td>
      <td>95.8 %</td>
      <td><strong>+57.7</strong></td>
    </tr>
    <tr>
      <td>meta-llama/llama-3.3-70b-instruct</td>
      <td>23.3 %</td>
      <td>0 %</td>
      <td>−23.3</td>
    </tr>
    <tr>
      <td>deepseek/deepseek-chat-v3.1</td>
      <td>5.8 %</td>
      <td>15.0 %</td>
      <td>+9.2</td>
    </tr>
    <tr>
      <td>mistralai/mistral-large-2512</td>
      <td>4.2 %</td>
      <td>0 %</td>
      <td>−4.2</td>
    </tr>
    <tr>
      <td>openai/gpt-4o</td>
      <td>0.8 %</td>
      <td>1.7 %</td>
      <td>+0.9</td>
    </tr>
  </tbody>
</table>

<p>Sonnet 4.6 et Gemini 2.5 Pro basculent de presque 58 points en direction opposée, même en isolant la conclusion. Cela enterre l’idée qu’on peut classer la capacité au sens commun d’un modèle avec un seul prompt.</p>

<p>Pourquoi ? Disclaimer immédiat : tout ce qui suit relève de l’interprétation, pas de la démonstration. Mes données me disent <em>que</em> les modèles basculent, pas <em>pourquoi</em>, et je n’ai pas conduit les expériences d’ablation qui permettraient de l’établir. Sous cette réserve : pour Sonnet, la forme oui/non de V2 semble corrélée à un comportement d’acquiescement (réponses qui commencent par <em>“Oui, sans hésiter, 100 mètres c’est très court”</em>). Effet de la syntaxe interrogative, du vocabulaire (“centre de lavage automobile” plutôt que “lave auto”), de la longueur du prompt, ou combinaison des trois — je ne peux pas trancher. Pour Gemini, la même formulation coïncide avec une rumination plus longue (1 525 tokens en sortie) au bout de laquelle il arrive à <em>“Non”</em>. Verbosité associée au succès, peut-être pas causale.</p>

<p>GPT-5.5 et Opus 4.7 sont les seuls stables sur la conclusion (95+ % sur les deux formulations). À l’autre bout, GPT-4o, Mistral et Llama échouent quel que soit le cadrage.</p>

<hr />

<h2 id="5-le-vrai-problème--la-sensibilité-au-prompt">5. Le vrai problème : la sensibilité au prompt</h2>

<p>Si on regarde le résultat le plus important de cette expérience, ce n’est pas le classement entre LLMs. C’est ça :</p>

<blockquote>
  <p>Sur la conclusion seule (C1), Sonnet 4.6 passe de 97 % à 39 %. Gemini 2.5 Pro passe de 38 % à 96 %. Sur le <em>même piège</em>, avec une simple reformulation.</p>
</blockquote>

<p>Ce n’est pas du bruit (n=120, IC95 disjoints à des kilomètres). Et ça pose un vrai problème conceptuel : une capacité de raisonnement, un “sens commun” au sens où on l’entend habituellement, devrait être invariante à la reformulation. Or, pour 6 modèles sur 8, ce n’est pas le cas. Le passage d’une question disjonctive à une question oui/non change tout.</p>

<p>Plusieurs explications peuvent être avancées, aucune n’est démontrée par cette étude seule. Je les liste comme pistes :</p>

<ul>
  <li>Possible effet d’acquiescement sur le oui/non : Sonnet 4.6 répond <em>“Oui, sans hésiter, 100 mètres c’est très court”</em> et s’aligne sur l’option proposée. Est-ce la forme interrogative qui pousse à l’acquiescement, le vocabulaire, l’ordre des éléments, ou autre chose ? Mes données ne tranchent pas.</li>
  <li>Possible appariement de motifs sur les marqueurs lexicaux : “courte distance”, “écologie”, “à pied”. Pour Llama 3.3 et GPT-4o, 90+ % des réponses commencent par cette ligne. Tentant d’y voir des tokens déclencheurs d’une heuristique pré-câblée, mais une corrélation entre entête et conclusion n’est pas une preuve de mécanisme.</li>
  <li>Possible effet de la rumination : Gemini, avec ses 1 500 tokens, arrive souvent à la bonne conclusion en fin de réponse. Opus 4.7, avec ses 400 tokens, commence souvent par “Oui à pied !” puis se ravise. Longueur associée au succès dans un cas, mais pour des modèles courts qui réussissent (GPT-5.5, 187 tokens) la relation est tout sauf monotone.</li>
  <li>Le post-training semble compter plus que la taille. Qwen3-235b-thinking (95 % C1 dans les deux variantes) vs son cousin de base (50 % C1 sur V1) à architecture, taille et budget de raisonnement comparables. C’est l’argument le plus solide, mais ce n’est pas non plus une ablation propre (les deux ne sortent pas du même entraînement avec une seule variable modifiée).</li>
  <li>Le raisonnement explicite peut compenser une partie de la fragilité au prompt. Sonnet 4.6 sans raisonnement : 97 % → 39 % entre V1 et V2. Avec <code class="language-plaintext highlighter-rouge">reasoning.effort=high</code> : 100 % → 99 % (chute disparue). Pour DeepSeek R1 c’est l’inverse : modèle entraîné à raisonner, il raisonne, et conclut faux dans les deux variantes (7-14 % C1). Le raisonnement n’est ni nécessaire ni suffisant, son effet dépend du prior.</li>
</ul>

<h3 id="ce-nest-pas-nouveau-et-ça-résiste-depuis-longtemps">Ce n’est pas nouveau (et ça résiste depuis longtemps)</h3>

<p>Cette fragilité au prompt n’est pas une découverte de mai 2026. Dans <a href="https://arxiv.org/abs/2210.14699"><em>Piloting Copilot, Codex, and StarCoder2: Hot Temperature, Cold Prompts, or Black Magic?</em></a> (Döderlein, Kouadio, Acher, Khelladi, Combemale), publié dès 2022 et étendu depuis, nous montrions sur des tâches de génération de code que des reformulations sémantiquement équivalentes du prompt produisaient des variations massives du taux de réussite, sans qu’aucune recette stable n’émerge. Quatre ans et plusieurs générations plus tard, sur une tâche de raisonnement de tous les jours, le même phénomène se reproduit, malgré l’arrivée du “reasoning by design”. Le problème semble structurel, pas conjoncturel.</p>

<p>J’ai aussi documenté ce genre d’instabilité sur d’autres tâches. Sur les échecs, <a href="https://blog.mathieuacher.com/GPT5-IllegalChessBench/">GPT-5 fait un coup illégal dès le 4e tour en moyenne</a> et <a href="https://blog.mathieuacher.com/ChessWinning7MovesGPT-copy/">les modèles GPT sont très sensibles aux en-êtes du format PGN</a>. Les <a href="https://blog.mathieuacher.com/GPTReasoningO3O4miniAndChess-copy/">limites de o3/o4-mini</a> racontent la même histoire. Je recommande fortement le <a href="https://blog.mathieuacher.com/LaParoleAuxMachinesMonsieurPhi/">livre de Monsieur Phi</a> ou sa vidéo sur <a href="https://www.youtube.com/watch?v=6D1XIbkm4JE">LLM+échecs</a>, qui montre à la fois les forces et les limites des modèles aux échecs.</p>

<p>Au-delà du code et du jeu d’échecs, de nombreux travaux montrent que les modèles sont sensiblement affectés par le prompt. 
C’est tout de même surprenenant de le constater dans le cadre du lave-auto, y compris sur des modèles récents. A surveiller dans un futur proche !</p>

<h3 id="au-final">Au final</h3>

<p>Mon intuition après lecture du jeu de données : en mode par défaut, les LLMs n’ont pas un sens commun “robuste”. Ils ont un mélange d’heuristiques apprises et de motifs lexicaux qui se cassent dès qu’on bouge la formulation. Avec raisonnement explicite, trois modèles deviennent stables entre V1 et V2 sur la conclusion : GPT-5.5 (sans qu’on le demande), Sonnet 4.6 + HIGH et Qwen3-thinking + HIGH (95-100 %). Gemini 2.5 Pro est intermédiaire : très bon sur V2 (95 %) mais seulement 58 % sur V1 même avec raisonnement HIGH. La question n’est plus exactement “ont-ils du sens commun ?”, mais “à quel point faut-il les pousser pour qu’ils l’utilisent ?”. Pour DeepSeek R1, le raisonnement ne fait rien. Et le “no excuse” reste largement non résolu.</p>

<hr />

<h2 id="6-caveat-méthodologique--sur-les-traces-de-raisonnement">6. Caveat méthodologique : sur les traces de raisonnement</h2>

<p>Un mot sur ce que disent (et ne disent pas) les compteurs de <code class="language-plaintext highlighter-rouge">reasoning_tokens</code> vus en section 3. C’est tentant d’interpréter ces compteurs comme une mesure du “temps de réflexion” du modèle, voire de sa qualité de raisonnement. Plusieurs travaux récents invitent à la prudence.</p>

<p>Kambhampati et al. plaident contre l’anthropomorphisation des tokens intermédiaires comme “pensée” du modèle (<a href="https://arxiv.org/abs/2504.09762"><em>Stop Anthropomorphizing Intermediate Tokens as Reasoning Traces</em>, 2024</a>), et montrent dans une étude associée que des traces sémantiquement <em>aberrantes</em> produisent des réponses tout aussi correctes que des traces propres (<a href="https://arxiv.org/abs/2505.13775"><em>Beyond Semantics: The Unreasonable Effectiveness of Reasonless Intermediate Tokens</em>, 2024</a>). Du côté d’Anthropic, des perturbations causales de chain-of-thought suggèrent que la réponse finale ne dépend pas toujours du raisonnement déclaré (<a href="https://www.anthropic.com/news/measuring-faithfulness-in-chain-of-thought-reasoning"><em>Measuring Faithfulness in Chain-of-Thought Reasoning</em></a>). Pour tester proprement le lien entre prompt, trace et sortie, il faut des baselines et des perturbations contrôlées (<a href="https://arxiv.org/abs/2605.01048"><em>Compared to What? Baselines and Metrics for Counterfactual Prompting</em></a>).</p>

<p>Concrètement ici : les chiffres de la section 3 renseignent sur l’effort facturé, pas sur le mécanisme. Sur ce piège, R1 raisonne longuement et conclut faux : impossible de dire à partir de cette seule expérience si c’est parce que sa chaîne de pensée tourne réellement autour du mauvais prior, ou parce que chaîne et réponse sont en partie découplées. Le piège du lave-auto n’est pas conçu pour répondre à cette question. Mais elle reste ouverte, et c’est un garde-fou utile.</p>

<hr />

<h2 id="ce-que-je-referais">Ce que je referais</h2>

<ul>
  <li>Plus de prompts inédits (générés a posteriori, pour minimiser les chances qu’ils soient dans le corpus d’entraînement).</li>
  <li>Plusieurs juges pour estimer le bruit d’évaluation. <em>Partiellement fait</em> : sur un échantillon stratifié de 128 réponses, j’ai lancé 4 sous-agents Claude indépendants en parallèle pour appliquer la même grille C1/C2. Accord avec le juge GPT-5.5 : 95.3 % sur C1 (Cohen κ = 0.904), 93.0 % sur C2 (κ = 0.792), taux de réussite agrégés quasi identiques. Les 6 désaccords C1 sont concentrés sur les réponses Opus/Sonnet de V2 (“oui à pied” puis ravisement) — cas-limite du départage interprétatif. Verdict : le juge GPT-5.5 n’est pas idiosyncratiquement biaisé sur cette tâche.</li>
  <li>Variation continue de la distance (1 m, 50 m, 500 m, 5 km) : où est le seuil de bascule de chaque modèle, et est-ce qu’il y a un <em>vrai</em> “à 5 km j’y vais en voiture parce que c’est loin”, ou bien “à 5 km j’y vais en voiture parce que c’est nécessaire” ?</li>
  <li>Autres langues (EN, DE, IT). Mode français-culturel ou universel ?</li>
</ul>

<p>Mais déjà avec 8 modèles, plusieurs conditions et plus de 4 000 réponses, l’histoire est plutôt nette :</p>

<blockquote>
  <p>Sur ce piège, en mode d’inférence par défaut, la plupart des LLMs ne montrent pas un sens commun généralisable : leurs heuristiques se cassent dès qu’on reformule la question, et le classement intra-LLM observé sur un prompt donné ne se transfère pas à une variante syntaxique pourtant équivalente sémantiquement. Avec raisonnement explicitement activé à effort élevé, plusieurs modèles récents (GPT-5.5 par défaut, Sonnet 4.6 + HIGH, Qwen3-thinking) deviennent robustes à la reformulation sur la conclusion. La fragilité est donc largement, mais pas entièrement, une affaire de mode d’inférence.</p>
</blockquote>

<p>Ce n’est pas une condamnation des LLMs. GPT-5.5 et Sonnet 4.6 sont des outils que j’utilise tous les jours et qui m’aident réellement. Le constat sur ce piège tient en trois points : GPT-5.5 est seul en avance sur le C2 (parcimonie du raisonnement), avec une robustesse qu’il atteint sans mode explicite ; les autres modèles modernes manquent de robustesse en mode par défaut, mais souvent récupèrent avec raisonnement HIGH ; les gros instruct non-reasoners (GPT-4o, Mistral, Llama, DeepSeek-chat) échouent de façon stable et le temps ne corrige pas cette classe d’erreurs.</p>

<p>Côté méthodes, un message pour le lecteur qui teste les LLMs un peu comme moi : essayer un prompt une fois, c’est très insuffisant, et <em>particulièrement</em> quand le modèle a juste. Une bonne réponse à un essai ne dit pas si elle se reproduira ni si elle survivra à une reformulation. Une mauvaise réponse, à l’inverse, suffit à identifier un problème : c’est asymétrique. Donc surtout quand “ça marche”, il faut répéter. Pour juger un modèle sur un piège donné : n significatif (10+ pour une vérification rapide, 100+ pour conclure) et au moins deux formulations sémantiquement équivalentes. Sans ça, on confond facilement le réflexe d’un essai avec une capacité installée.</p>

<h3 id="deux-questions-ouvertes">Deux questions ouvertes</h3>

<p><em>Plus de raisonnement suffit-il à avoir du sens commun ?</em> Pas mécaniquement. Le raisonnement explicite peut compenser une fragilité (Sonnet 4.6 sur V2 : 39 % → 99 % avec <code class="language-plaintext highlighter-rouge">reasoning.effort=high</code>), mais il peut aussi s’enrouler autour d’un mauvais prior (DeepSeek R1 produit ~450 tokens de raisonnement et reste à 7-14 % de bonne conclusion). On peut aussi émettre l’hypothèse que les dernières générations de modèles sont plus robustes de par leur raisonnement.</p>

<p><em>Les modèles “frontier” ont-ils été “patchés” pour passer ce test précis ?</em> C’est l’autre hypothèse. Mes données ne le prouvent pas et il faut rester prudent. Mais elles sont compatibles avec l’hypothèse : la lignée GPT-5 conclut juste depuis août 2025 ; ce qui s’améliore monotoniquement sur 9 mois (gpt-5 → 5.1 → 5.5) c’est précisément la parcimonie sur le critère “ne pas mentionner la distance”, exactement le détail qui rend ce piège visible plutôt qu’un autre. Le test est connu sur les réseaux depuis 2024, un fine-tuning ciblé serait plausible sans être avéré. Pour trancher, il faudrait tester des variantes inédites de la structure du piège: c’est dans la liste des “à faire” ;-)</p>

<hr />

<p><em>Toutes les données, prompts, et scripts de cette manip : <a href="https://github.com/acherm/laveauto-llm">github.com/acherm/laveauto-llm</a>.</em></p>

<p><em>Setup technique : Python 3 + openai SDK async, ~25 appels concurrents sur OpenRouter. Coût total ~quelques euros. Juge = GPT-5.5 à température 0, deux verdicts indépendants par réponse. Toutes les expériences sans system prompt, juste la question en message utilisateur.</em></p>

<p><em>Un avis, une variante de prompt à tester, un désaccord ? mathieu.acher@irisa.fr</em></p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026llmcarwashfr</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{Le test du lave-auto : les LLMs ont-ils vraiment du sens commun ?}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{may}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/LLMCarWashCommonSense/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/LLMCarWashCommonSense/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="LLM" /><category term="common sense" /><category term="OpenRouter" /><category term="benchmark" /><category term="GPT-5" /><category term="Claude" /><category term="Gemini" /><category term="DeepSeek" /><category term="Qwen" /><category term="reasoning" /><category term="prompt engineering" /><category term="prompt sensitivity" /><summary type="html"><![CDATA[L’exemple est devenu un classique : “Je dois laver ma voiture. Le centre de lavage est à 100 m. J’y vais à pied ou en voiture ?”. Réponse de sens commun : en voiture, évidemment, sinon la voiture n’est pas là pour être lavée. Sauf que la plupart des LLMs tombent dans le panneau et recommandent la marche pour des raisons d’écologie ou de courte distance.]]></summary></entry><entry><title type="html">A PhD Defense on Legacy Software Modernization: from Esope to Fortran 2008</title><link href="http://blog.mathieuacher.com/PhDDefenseLegacySoftwareModernizationEsopeFortran/" rel="alternate" type="text/html" title="A PhD Defense on Legacy Software Modernization: from Esope to Fortran 2008" /><published>2026-03-24T09:54:29+00:00</published><updated>2026-03-24T09:54:29+00:00</updated><id>http://blog.mathieuacher.com/PhDDefenseLegacySoftwareModernizationEsopeFortran</id><content type="html" xml:base="http://blog.mathieuacher.com/PhDDefenseLegacySoftwareModernizationEsopeFortran/"><![CDATA[<p>I had the pleasure of serving as a reviewer for Younoussa Sow’s PhD defense in Lille, on a topic that is both challenging and fascinating: the automatic migration of Esope to Fortran 2008. This CIFRE PhD, carried out with <a href="https://www.framatome.com/">Framatome</a> and co-supervised by <a href="https://chercheurs.lille.inria.fr/~nanqueti/nicolasAnquetil.html">Nicolas Anquetil</a> and <a href="http://stephane.ducasse.free.fr/">Stéphane Ducasse</a>, tackles a major modernization challenge: migrating around one million lines of scientific code built on Esope, a proprietary extension on top of Fortran 77.</p>

<p>What makes this work especially difficult is that the Esope ecosystem is closed and “legacy”. 
That makes parsing, transforming, and modernizing the code particularly hard, from a software engineering perspective.
The defense strongly resonated with my own current interests in RPG and COBOL modernization, and more broadly with the challenges posed by legacy or unusual/esoteric languages. No LLMs in this thesis, but many questions that connect with ongoing discussions around software migration and modernization, including in the context of LLM4Code.</p>

<p>It was also a great opportunity to exchange, the day before, on these topics. The defense was a reminder of two important things. First, how valuable it is to attend PhD defenses in person, to better understand the story, context, collaboration dynamics, and difficulties behind the work. Second, how essential computer science is to the evolution of critical systems across many domains… and how much we need computer scientists to make these systems evolve.</p>

<p>Two major publications from the thesis:</p>
<ul>
  <li>Younoussa Sow, Nicolas Anquetil, Léandre Brault, Stéphane Ducasse – Migrating Esope to Fortran 2008 using model transformations (SANER 2026)</li>
  <li>Younoussa Sow, Larisa Safina, Léandre Brault, Papa Ibou Diouf, Stéphane Ducasse, Nicolas Anquetil – Parsing Fortran-77 with proprietary extensions (ICSME 2023, pp. 453–462)</li>
</ul>

<p>Congratulations to Younoussa Sow for the defense, and thanks to the whole team for the discussions!</p>

<p><em>(new!) I realized that some of my blog post entries are sometimes cited in academic works, so why not using the following bibtex entry?</em></p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026phddefenseesope</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{A PhD Defense on Legacy Software Modernization: from Esope to Fortran 2008}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{mar}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/PhDDefenseLegacySoftwareModernizationEsopeFortran/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/PhDDefenseLegacySoftwareModernizationEsopeFortran/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="legacy software" /><category term="modernization" /><category term="Fortran" /><category term="Esope" /><category term="parsing" /><category term="software migration" /><category term="PhD defense" /><category term="LLM4Code" /><summary type="html"><![CDATA[I had the pleasure of serving as a reviewer for Younoussa Sow’s PhD defense in Lille, on a topic that is both challenging and fascinating: the automatic migration of Esope to Fortran 2008. This CIFRE PhD, carried out with Framatome and co-supervised by Nicolas Anquetil and Stéphane Ducasse, tackles a major modernization challenge: migrating around one million lines of scientific code built on Esope, a proprietary extension on top of Fortran 77.]]></summary></entry><entry><title type="html">BFChess: A Chess Engine in Brainfuck, Built by a Coding Agent</title><link href="http://blog.mathieuacher.com/BFChessChessEngineBrainfuck/" rel="alternate" type="text/html" title="BFChess: A Chess Engine in Brainfuck, Built by a Coding Agent" /><published>2026-03-23T13:00:00+00:00</published><updated>2026-03-23T13:00:00+00:00</updated><id>http://blog.mathieuacher.com/BFChessChessEngineBrainfuck</id><content type="html" xml:base="http://blog.mathieuacher.com/BFChessChessEngineBrainfuck/"><![CDATA[<p><a href="https://github.com/acherm/agentic-chessengine-brainfuck">BFChess</a> is a UCI-compatible chess engine written entirely in <a href="https://en.wikipedia.org/wiki/Brainfuck">Brainfuck</a>. The generated engine is 5.6 MB of raw Brainfuck code (eight distinct characters: <code class="language-plaintext highlighter-rouge">&gt;&lt;+-.,[]</code>), produced by a 7,400-line Python compiler. Here is what a small excerpt of the engine looks like:
<img src="/assets/bfchess-snippet.png" alt="A snippet of chess.bf: 5.6 MB of raw Brainfuck code made of eight characters" />
It implements depth-3 minimax search with alpha-beta pruning, full move generation (including castling, en passant, and promotion), and MVV-LVA evaluation with positional bonuses. It passes 11/11 perft validation positions. It is not strong: it beats random moves convincingly but loses every game against Stockfish at minimum settings. Each move takes between 45 seconds and 10 minutes to compute. The project shows that coding agents can operate in Brainfuck at a scale never achieved before for a chess engine, not through raw translation but by designing intermediate abstractions (a pointer-tracking emitter, a memory layout manager, runtime loop patterns) that make the task tractable. Yet despite a non-trivial implementation of search, evaluation, and special moves, the resulting engine is only capable of beating a random move generator, with a large gap to even the weakest conventional engines. Closing that gap, through human-AI co-design iteration, is an open and exciting direction.</p>

<p>Brainfuck has no variables, no functions, no arithmetic beyond increment/decrement, and no random memory access. Its only data structure is a linear tape of byte-sized cells. It is Turing-complete, so in theory one can write a chess engine in it. In practice, no one appears to have done so before (see <a href="#novelty-has-this-been-done-before">Novelty</a> below).</p>

<p>This work is part of a broader experiment where I ask coding agents to build chess engines from scratch across <a href="https://blog.mathieuacher.com/FromScratchChessEnginesPolyglot/">many programming languages</a>. The <a href="https://blog.mathieuacher.com/TeXCCChessEngine/">TeX engine</a> plays at roughly the level of a casual tournament player. Brainfuck is a significantly harder target, and the results reflect that.</p>

<h2 id="novelty-has-this-been-done-before">Novelty: Has This Been Done Before?</h2>

<p>A fully functioning, openly available chess engine written in Brainfuck does not appear to exist in durable public form as of February 2026. The best-documented serious attempt was a 2019 community project announced on <a href="https://talkchess.com/">TalkChess</a>, with an intended design described as “a simplified micro-Max” and an initial GitHub repository named <em>chessfuck</em>. The repository link publicly shared in 2019 is now dead (HTTP 404), strongly suggesting deletion or privatization; the surviving technical record is limited to the TalkChess discussion thread itself.</p>

<p>What does exist, with primary sources, are (a) chess-adjacent Brainfuck utilities such as <code class="language-plaintext highlighter-rouge">chessboard.b</code>, which renders an ASCII chessboard from a FEN position string (Daniel B. Cristofani, brainfuck.org), and (b) small-board game implementations with AI (notably tic-tac-toe), which demonstrate the kinds of state encoding, control-flow tricks, and sentinel-based “subroutine” patterns that would be required to scale toward chess-like complexity.</p>

<p>Two “enablers” appear repeatedly in the chess-in-BF discussion: speed improvements via optimized interpreters and JITs (e.g., Eli Bendersky’s 2017 BF JIT series), and hardware BF execution (e.g., a 256-core BF processor paper). These do not solve the human-factor problem of writing and maintaining BF at chess-engine scale, but they do change feasibility assumptions around runtime performance.</p>

<p>BFChess appears to be the first complete, publicly available implementation.</p>

<h2 id="why-brainfuck">Why Brainfuck?</h2>

<p>Because it represents an extreme point on the language-capability spectrum. If a coding agent can produce a working chess engine in Brainfuck, that tells us something about the boundaries of what these tools can handle in severely constrained language environments. Unlike TeX (which at least has integer registers and macro expansion), Brainfuck offers essentially nothing to build on. Every abstraction must be constructed from scratch, using only pointer movement and cell increment/decrement.</p>

<h2 id="architecture-a-python-to-bf-compiler">Architecture: A Python-to-BF Compiler</h2>

<p>BFChess is not hand-written Brainfuck. Instead, a Python compiler (<code class="language-plaintext highlighter-rouge">generate.py</code> and ~7,400 lines of supporting modules) emits the 5.6 MB <code class="language-plaintext highlighter-rouge">chess.bf</code> file. The compiler tracks a virtual data pointer at generation time, so it can emit minimal <code class="language-plaintext highlighter-rouge">&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;</code> sequences to navigate between memory cells. It is, in effect, a code generator targeting an extremely constrained instruction set.</p>

<table>
  <thead>
    <tr>
      <th>Module</th>
      <th style="text-align: right">Lines</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bf_movegen.py</code></td>
      <td style="text-align: right">4,470</td>
      <td>Move generation, search, evaluation</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bf_chess.py</code></td>
      <td style="text-align: right">498</td>
      <td>Board ops, UCI position parsing, move application</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bf_memory.py</code></td>
      <td style="text-align: right">262</td>
      <td>Memory layout (672 cell addresses)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bf_emitter.py</code></td>
      <td style="text-align: right">185</td>
      <td>Core BF emitter with pointer tracking</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bf_uci.py</code></td>
      <td style="text-align: right">184</td>
      <td>UCI protocol loop</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bf_primitives.py</code></td>
      <td style="text-align: right">182</td>
      <td>Control flow (<code class="language-plaintext highlighter-rouge">compare_eq</code>, <code class="language-plaintext highlighter-rouge">switch_on_value</code>)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bf_io.py</code></td>
      <td style="text-align: right">108</td>
      <td>I/O (stdin line reading, output)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">generate.py</code></td>
      <td style="text-align: right">39</td>
      <td>Entry point</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bfi.c</code></td>
      <td style="text-align: right">177</td>
      <td>RLE-optimized BF interpreter</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td style="text-align: right"><strong>~7,400</strong></td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>The generated <code class="language-plaintext highlighter-rouge">chess.bf</code> is <strong>5.6 MB</strong> of raw Brainfuck. An RLE-optimized interpreter (<code class="language-plaintext highlighter-rouge">bfi.c</code>) compresses it at load time from ~3.8M characters to ~520K instructions (7x compression), with a precomputed jump table for O(1) bracket matching.</p>

<h3 id="memory-layout">Memory Layout</h3>

<p>The engine uses 672 cells on the BF tape, each with a carefully assigned role:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> Cells 0-15:      Temporaries
 Cells 16-29:     Game state (side to move, castling rights, king positions, EP file)
 Cells 23-28:     Move generation hot cells (BEST_FROM, BEST_TO, HAVE_LEGAL)
 Cells 30-93:     Chess board (64 squares)
 Cells 100-227:   UCI input buffer (128 bytes)
 Cells 120-129:   Legality workspace (make/unmake)
 Cells 130-159:   Evaluation (scoring, attack detection, exchange, check)
 Cells 160-183:   Depth-2/3 search control
 Cells 400-471:   Depth-2 state backup (64 board + 8 state cells)
 Cells 600-671:   Depth-3 state backup
</code></pre></div></div>

<h3 id="the-fundamental-problem-no-random-access">The Fundamental Problem: No Random Access</h3>

<p>Brainfuck has no <code class="language-plaintext highlighter-rouge">board[index]</code> where <code class="language-plaintext highlighter-rouge">index</code> is a runtime value. To read a square whose index is computed at runtime, the engine must execute a <strong>64-way switch</strong>: compare the index against 0, 1, 2, …, 63 and copy from the matching fixed cell. This single constraint is the dominant cost driver. Every board read or write is ~20 KB of BF code: a 64-way cascade of comparisons and pointer movements.</p>

<p>This is what makes Brainfuck fundamentally different from TeX (which has 32K indexed <code class="language-plaintext highlighter-rouge">\count</code> registers) or even <a href="https://blog.mathieuacher.com/CodingAgentsMnMLang/">M&amp;M’s language</a> (which at least has named variables).</p>

<h2 id="features">Features</h2>

<p>Despite the language constraints, BFChess implements:</p>

<ul>
  <li><strong>Depth-3 minimax search</strong> with alpha-beta pruning and captures-first move ordering</li>
  <li><strong>Full move generation</strong> for all piece types, including castling (kingside/queenside), en passant, and promotion</li>
  <li><strong>MVV-LVA evaluation</strong> with positional bonuses: center control, piece activity, pawn advancement, check detection, exchange awareness, anti-repetition penalty</li>
  <li><strong>3-pass attack analysis</strong> for legality checking, destination safety, and check detection, with zero code duplication (a single <code class="language-plaintext highlighter-rouge">is_attacked()</code> routine wrapped in a 3-iteration BF loop)</li>
  <li><strong>Stalemate detection</strong> at all search depths</li>
  <li><strong>Perft validation</strong>: 11/11 positions correct (including castling and EP edge cases)</li>
  <li><strong>UCI protocol</strong>: <code class="language-plaintext highlighter-rouge">uci</code>, <code class="language-plaintext highlighter-rouge">isready</code>, <code class="language-plaintext highlighter-rouge">position startpos</code>, <code class="language-plaintext highlighter-rouge">domove</code>, <code class="language-plaintext highlighter-rouge">go</code>, <code class="language-plaintext highlighter-rouge">perft</code>, <code class="language-plaintext highlighter-rouge">quit</code></li>
</ul>

<h3 id="size-optimization-from-119-mb-to-56-mb">Size Optimization: From 119 MB to 5.6 MB</h3>

<p>The naive approach (unrolling every board access at compile time) produced a 119 MB BF program. Four optimization strategies brought it down:</p>

<ol>
  <li><strong>Runtime loops</strong> instead of compile-time unrolling (117 MB -&gt; 1.7 MB)</li>
  <li><strong>Runtime offset dispatch</strong> for knight/king/sliding piece movement (1.7 MB -&gt; 282 KB)</li>
  <li><strong>Cell proximity</strong>: placing frequently co-accessed cells near each other to minimize pointer travel</li>
  <li><strong>Efficient encoding</strong>: values &gt;128 use decrements from 0 (2 characters) instead of 128+ increments</li>
</ol>

<p>The depth-1 engine was 943 KB. Adding depth-2 and depth-3 search (each requiring full 72-cell state save/restore) brought the final size to 5.6 MB.</p>

<h2 id="how-strong-is-it">How Strong Is It?</h2>

<p>Short answer: not very. But it plays legal chess and beats random moves convincingly.</p>

<h3 id="vs-stockfish-18-minimum-settings">vs Stockfish 18 (minimum settings)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Results: +0 =0 -10 / 10 (0%)
All 10 losses by checkmate (12-32 moves)
</code></pre></div></div>

<p>BFChess captures material and controls the center in the opening, but 3 plies of search is not enough to see the tactical threats Stockfish exploits. Here is a typical game:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. d4 d5 2. e4 c6 3. Nc3 a6 4. Qf3 dxe4 5. Nxe4 Nd7 6. d5 Qa5+
7. Bd2 Qxd5 8. Nf6+ exf6 9. Qxd5 cxd5 10. Bxa6 ...
</code></pre></div></div>

<p>BFChess plays reasonable opening moves (d4, e4, Nc3) and grabs material when it can, but lacks the ability to defend against multi-move threats.</p>

<h3 id="vs-random-legal-moves">vs Random Legal Moves</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Results: +4 =6 -0 / 10 (70%)
4 wins by checkmate, 6 draws by stalemate, 0 losses
</code></pre></div></div>

<p>It dominates random play in the opening/middlegame, but 60% of games end in stalemate: the engine captures everything and accidentally traps the bare king. Stalemate positions forming beyond the 3-ply horizon remain invisible.</p>

<h3 id="summary">Summary</h3>

<table>
  <thead>
    <tr>
      <th>Opponent</th>
      <th>Score</th>
      <th>Win%</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Random legal moves</td>
      <td>+4 =6 -0</td>
      <td>70%</td>
    </tr>
    <tr>
      <td>Stockfish (minimum settings)</td>
      <td>+0 =0 -10</td>
      <td>0%</td>
    </tr>
  </tbody>
</table>

<p>The sample sizes are too small (10 games each) and the win rates too extreme (0% and 70%) for a meaningful Elo estimate. What we can say: BFChess is clearly stronger than random play, but clearly weaker than even the weakest Stockfish. For context, the <a href="https://blog.mathieuacher.com/TeXCCChessEngine/">TeX engine</a> plays at roughly the level of a casual tournament player, with quiescence search and piece-square tables. The gap is real and largely comes from depth and evaluation quality.</p>

<h2 id="what-would-it-take-to-improve">What Would It Take to Improve?</h2>

<p>The main weaknesses are clear:</p>

<ul>
  <li><strong>No quiescence search</strong>: the engine can’t see beyond the fixed 3-ply horizon, so it routinely walks into tactical threats</li>
  <li><strong>No piece-square tables</strong>: evaluation is crude (center bonus + MVV-LVA), without the positional nuance that piece-square tables provide</li>
  <li><strong>No iterative deepening or time management</strong>: every position gets the same fixed-depth search, whether it takes 45 seconds or 10 minutes</li>
  <li><strong>No opening book or endgame tablebase</strong></li>
  <li><strong>Stalemate blindness</strong>: a major practical issue against weaker opponents</li>
</ul>

<p>Adding quiescence search would likely have the biggest impact, but in Brainfuck, every additional search depth multiplies code size (each depth level needs its own 72-cell state backup region) and execution time. A single move already takes 45 to 600+ seconds on the RLE-optimized interpreter. The challenge is not just algorithmic: Brainfuck’s computational overhead makes deeper search extremely expensive.</p>

<p>There is also a meta-challenge: <strong>assessing the engine is itself costly</strong>. Each 10-game tournament against Stockfish takes several hours of wall-clock time. Iterating on the evaluation function or search algorithm means regenerating the 5.6 MB BF file, then waiting hours for results. The feedback loop is orders of magnitude slower than for a normal chess engine, where one can run thousands of games overnight.</p>

<h2 id="the-effort-sessions-tokens-and-cost">The Effort: Sessions, Tokens, and Cost</h2>

<p>BFChess was built using <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a> powered by Claude Opus 4.6. Here is what the development looked like:</p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Calendar span</td>
      <td>30 days (Feb 21 - Mar 23, 2026)</td>
    </tr>
    <tr>
      <td>Active working time</td>
      <td>~34 hours</td>
    </tr>
    <tr>
      <td>Sessions</td>
      <td>17</td>
    </tr>
    <tr>
      <td>User messages</td>
      <td>~1,973</td>
    </tr>
    <tr>
      <td>Total API calls</td>
      <td>~2,700 (main + subagents)</td>
    </tr>
    <tr>
      <td>Total tokens</td>
      <td>~221M (mostly cache reads)</td>
    </tr>
    <tr>
      <td>Estimated API cost</td>
      <td>~$758 (at standard Opus pricing)</td>
    </tr>
    <tr>
      <td>Git commits</td>
      <td>10</td>
    </tr>
  </tbody>
</table>

<p>The token count is dominated by cache reads (~198M tokens): Claude Code reloads context at each turn, so the same codebase gets re-read hundreds of times. The actual novel output is ~344K tokens. At standard Anthropic API pricing, that’s about $758, though with a Claude Max subscription the cost structure is different (flat monthly fee).</p>

<h3 id="what-kind-of-instructions-were-given">What Kind of Instructions Were Given?</h3>

<p>The approach was deliberately iterative. I did not hand Claude Code a specification. Instead, the development went roughly like this:</p>

<ol>
  <li><strong>“Build a chess engine in Brainfuck”</strong>: the initial prompt. Claude Code produced the first working UCI engine (733 KB) in the first session.</li>
  <li><strong>“Add depth-2 search, then depth-3”</strong>: progressive deepening, each requiring new state management.</li>
  <li><strong>Bug reports from testing</strong>: most of my messages were reporting bugs found during Stockfish tournaments (cell collisions, wrong flag cells, stale values). Claude Code would diagnose and fix them, sometimes discovering that a “fix” introduced a new cell collision.</li>
  <li><strong>“Run perft and compare to python-chess”</strong>: verification-driven development. Perft caught several move generation bugs early.</li>
  <li><strong>“Add castling”, “Add en passant”</strong>: feature requests, each requiring extensive make/unmake support.</li>
  <li><strong>“Play 10 games against Stockfish and show me the PGN”</strong>: assessment loops.</li>
</ol>

<p>The hardest bugs were <strong>cell collisions</strong>: multiple code paths accidentally writing to the same memory cell. In a normal language, you’d get a clear error. In Brainfuck, one routine silently corrupts another routine’s state, and the symptom shows up moves later. Claude Code found and fixed at least 9 such bugs across the development, including one where the <code class="language-plaintext highlighter-rouge">print_char</code> function was using cell 30 (= <code class="language-plaintext highlighter-rouge">BOARD_START</code>) as scratch space, corrupting the board every time any text was printed.</p>

<h3 id="the-meta-problem-feedback-loop-cost">The Meta-Problem: Feedback Loop Cost</h3>

<p>The real bottleneck was not writing the code, but evaluating it. Each iteration of “change something, regenerate chess.bf, play games, see if it improved” takes hours. Some representative times:</p>

<ul>
  <li>Generating <code class="language-plaintext highlighter-rouge">chess.bf</code>: ~1-2 minutes</li>
  <li>One move from the engine: 45 seconds (opening) to 600+ seconds (complex middlegame)</li>
  <li>A 10-game tournament: 3-8 hours</li>
  <li>A complete test-fix-retest cycle: half a day</li>
</ul>

<p>This means the engine has seen far fewer iterations than a normal chess engine development project would involve. There is almost certainly low-hanging fruit in the evaluation function, but finding it requires patience and compute.</p>

<h2 id="what-does-this-show">What Does This Show?</h2>

<p>As a chess engine, BFChess has no practical value: it is too slow and too weak. But as an artifact, it raises several points worth noting.</p>

<p><strong>On language boundaries.</strong> This appears to be the first publicly available chess engine in Brainfuck. Chess engines have been written in C, Java, Rust, JavaScript, COBOL, TeX, and many other languages, but the esoteric end of the spectrum was largely unexplored. BFChess shows that the language boundary for chess engines extends to extremely minimal Turing-complete languages. The bottleneck is not expressiveness per se (Turing-completeness guarantees that), but the practical cost of encoding complex state management (672 cells, zero collisions) and search algorithms (3-ply minimax with full board save/restore) in a language with no abstraction facilities.</p>

<p><strong>On coding agents.</strong> Building this required Claude Code to manage a flat memory layout across ~7,400 lines of code generator, reason about pointer arithmetic and cell allocation, debug silent corruption bugs where one routine overwrites another’s state, and optimize code size from 119 MB to 5.6 MB. The hardest class of bugs (cell collisions) would be difficult for a human to track manually at this scale, yet the agent found and fixed at least 9 of them across 17 sessions. At the same time, the agent could not independently assess the quality of its output: it needed the human to run tournaments, report failures, and guide the iteration. The division of labor was roughly: the agent writes and debugs code, the human steers priorities and evaluates results.</p>

<p><strong>On staying in pure Brainfuck.</strong> A parallel attempt was made with <a href="https://github.com/openai/codex">Codex</a> (GPT-5.3) to build the same kind of engine. The result is instructive: the repository contains real Brainfuck kernels, but upon inspection the live engine also relies on Python for search (<code class="language-plaintext highlighter-rouge">src/search.py</code>) and several board-operation fast paths (<code class="language-plaintext highlighter-rouge">src/bf_board.py</code>). The Brainfuck components (<code class="language-plaintext highlighter-rouge">src/brainfuck_selector.bf</code>) are kept for backward compatibility but are not the active engine core. In other words, the agent quietly migrated critical logic back into Python, presumably to optimize for convenience or speed. The constraint of staying in pure Brainfuck was not enforced explicitly enough, and the agent did not maintain it on its own. This is not a comment on the quality of Codex as an agent, but it illustrates two broader points: (1) keeping all engine logic in Brainfuck is genuinely difficult, and agents under pressure to produce working code will naturally gravitate toward more expressive languages when given the opportunity; (2) when working with coding agents on constrained tasks, the human must actively verify that the constraints are respected, because the agent may “solve” the problem by relaxing them. This pattern is not unique to Brainfuck: a similar drift was observed in the <a href="https://blog.mathieuacher.com/PrintfOrientedProgrammingCodingAgents/">printf-oriented programming experiment</a>, where agents tasked with implementing logic purely through <code class="language-plaintext highlighter-rouge">printf</code> format strings would occasionally fall back to conventional C code. In the BFChess project with Claude Code, the constraint held because the architecture (Python compiler emitting BF, with the engine running solely through <code class="language-plaintext highlighter-rouge">./bfi chess.bf</code>) made it structurally impossible to smuggle Python logic into the runtime. The Codex attempt is documented in <a href="https://github.com/acherm/agentic-chessengine-brainfuck-codexfailure">its own repository</a> for those interested in the details.</p>

<p><strong>On feasibility and cost.</strong> The project consumed ~221M tokens (~$758 at API pricing) and ~34 hours of active work over 30 days. Most of that time was spent on the feedback loop (regenerate, test, diagnose), not on writing code. This suggests that for Brainfuck-scale complexity, the main barrier to improvement is not the agent’s coding ability but the cost of evaluation: each iteration takes hours of wall-clock time, limiting how many hypotheses can be tested.</p>

<p><strong>As a starting point.</strong> The architecture (Python compiler to BF engine) and the infrastructure (perft tests, Stockfish tournaments, RLE interpreter) are all in place. Improving the engine from here is a matter of adding features (quiescence search, piece-square tables, better move ordering), which is difficult but incremental.</p>

<h2 id="trying-it">Trying It</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/acherm/agentic-chessengine-brainfuck
<span class="nb">cd </span>chess-brainfuck-cc
make                <span class="c"># builds bfi + generates chess.bf</span>
<span class="nb">printf</span> <span class="s1">'uci\nisready\nposition startpos\ngo\nquit\n'</span> | ./bfi chess.bf
<span class="c"># -&gt; bestmove d2d4</span>
</code></pre></div></div>

<p>You can play interactively with <code class="language-plaintext highlighter-rouge">python3 play.py</code> or run tournaments with <code class="language-plaintext highlighter-rouge">python3 play_stockfish.py</code>. Patience is required.</p>

<h2 id="summary-1">Summary</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>BFChess</th>
      <th><a href="https://blog.mathieuacher.com/TeXCCChessEngine/">TeXCCChess</a></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Language</td>
      <td>Brainfuck</td>
      <td>TeX</td>
    </tr>
    <tr>
      <td>Engine size</td>
      <td>5.6 MB (raw BF)</td>
      <td>~2,100 lines TeX</td>
    </tr>
    <tr>
      <td>Compiler</td>
      <td>7,400 lines Python</td>
      <td>N/A (direct TeX)</td>
    </tr>
    <tr>
      <td>Search depth</td>
      <td>3 (minimax + alpha-beta)</td>
      <td>3 (negamax + alpha-beta + quiescence)</td>
    </tr>
    <tr>
      <td>vs Stockfish (min)</td>
      <td>0/10</td>
      <td>Competitive</td>
    </tr>
    <tr>
      <td>Move time</td>
      <td>45-600+ seconds</td>
      <td>2-30 seconds</td>
    </tr>
    <tr>
      <td>Development cost</td>
      <td>~$758 / 221M tokens / 34h</td>
      <td>TBD</td>
    </tr>
  </tbody>
</table>

<p>BFChess is weaker, slower, and harder to improve than TeXCCChess. But it exists: a complete, legal-move-generating, UCI-speaking, depth-3-searching chess engine in a language with eight instructions and no memory abstraction. It is a solid starting point and, to the best of our knowledge, the first of its kind. Making it stronger will require more work, more compute, and likely some creative rethinking of what is feasible within Brainfuck’s severe constraints.</p>

<p><em>BFChess was developed by <a href="https://mathieuacher.com">Mathieu Acher</a> and <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a> (Opus 4.6). Source code: <a href="https://github.com/acherm/agentic-chessengine-brainfuck">https://github.com/acherm/agentic-chessengine-brainfuck</a></em></p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026bfchess</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{BFChess: A Chess Engine in Brainfuck, Built by a Coding Agent}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{mar}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/BrainfuckChessEngine/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/BrainfuckChessEngine/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="chess" /><category term="chess engine" /><category term="LLM" /><category term="coding agents" /><category term="Brainfuck" /><category term="esoteric languages" /><category term="software engineering" /><category term="generative AI" /><category term="llm4code" /><category term="Claude Code" /><summary type="html"><![CDATA[BFChess is a UCI-compatible chess engine written entirely in Brainfuck. The generated engine is 5.6 MB of raw Brainfuck code (eight distinct characters: &gt;&lt;+-.,[]), produced by a 7,400-line Python compiler. Here is what a small excerpt of the engine looks like: It implements depth-3 minimax search with alpha-beta pruning, full move generation (including castling, en passant, and promotion), and MVV-LVA evaluation with positional bonuses. It passes 11/11 perft validation positions. It is not strong: it beats random moves convincingly but loses every game against Stockfish at minimum settings. Each move takes between 45 seconds and 10 minutes to compute. The project shows that coding agents can operate in Brainfuck at a scale never achieved before for a chess engine, not through raw translation but by designing intermediate abstractions (a pointer-tracking emitter, a memory layout manager, runtime loop patterns) that make the task tractable. Yet despite a non-trivial implementation of search, evaluation, and special moves, the resulting engine is only capable of beating a random move generator, with a large gap to even the weakest conventional engines. Closing that gap, through human-AI co-design iteration, is an open and exciting direction.]]></summary></entry><entry><title type="html">Corners, Free Kicks, and Set Pieces Across Europe’s Top Football Leagues: What the Data Actually Says</title><link href="http://blog.mathieuacher.com/CornersSetPiecesFootballEN/" rel="alternate" type="text/html" title="Corners, Free Kicks, and Set Pieces Across Europe’s Top Football Leagues: What the Data Actually Says" /><published>2026-03-18T11:00:00+00:00</published><updated>2026-03-18T11:00:00+00:00</updated><id>http://blog.mathieuacher.com/CornersSetPiecesFootballEN</id><content type="html" xml:base="http://blog.mathieuacher.com/CornersSetPiecesFootballEN/"><![CDATA[<p>Corners and set pieces are one of football’s most debated topics. “Arsenal are unstoppable from corners,” “Marseille are hopeless on free kicks,” and so on. Opinions, gut feelings, conventional wisdom. But sometimes the data surprises: did you know that Tottenham, fighting relegation in the Premier League, are on the European podium for corner goals this season, just behind Arsenal and Inter?
What if we actually checked?
I analyzed <strong>5 seasons of data</strong> across Europe’s top 5 leagues (Premier League, Ligue 1, Serie A, Bundesliga, La Liga). Every shot, every goal, every expected goal (xG) from corners, indirect free kicks, and direct free kicks. 484 team-seasons. Here’s what I found in 10 key takeaways, and some results are surprising, underappreciated, or counter-intuitive.</p>

<p><em>Data: Understat via understatapi. 5 European leagues, 5 seasons (2021-2026), 484 team-seasons. Note: the 2025-26 season is ongoing (~26-30 matchdays depending on the league). Trends are clear but figures will evolve. I’ll update at the end of the season.</em></p>

<h2 id="1-tottenham-kings-of-corners-and-16th-in-the-premier-league">1. Tottenham, kings of corners… and 16th in the Premier League</h2>

<p>This is the wildest story of the current season (~30 PL matchdays played). Tottenham are <strong>3rd in Europe</strong> for goals scored from corners: 14 goals, behind Arsenal (16) and Inter (15). A 21.5% conversion rate, the best in Europe.</p>

<p>The problem? Spurs are currently <strong>16th in the Premier League</strong> with 30 points.</p>

<p><img src="/assets/en_tottenham_open_vs_corners.png" alt="Tottenham vs Arsenal: Open Play vs Corner Goals" /></p>

<p>The chart speaks for itself. Tottenham are isolated in the top left: lots of corner goals, very few from open play (25 goals, 14th in the league). Arsenal sit in the top right: 16 corner goals AND 39 from open play.</p>

<p><strong>35.0% of Tottenham’s goals come from set pieces.</strong> That’s the highest rate among major league clubs (excluding relegation-zone teams). When over a third of your goals depend on situations that account for ~15% of all shots, you’re building on sand.</p>

<p>For comparison, Bayern Munich, Europe’s best attack, are at just 15.6% set piece dependency. Because when you score 65 open play goals, corners are a bonus, not a crutch.</p>

<p>And then there’s Arsenal, the outlier: <strong>16 corner goals (1st in Europe)</strong>, plus 39 from open play, 70 points (1st in the Premier League). Arsenal aren’t just lethal offensively from set pieces, they dominate defensively too. In their Champions League match against Bayer Leverkusen, <a href="https://x.com/bayer04_en/status/2031723079886401891">Bayer 04’s official account</a> noted that Arsenal conceded zero corners. In a <a href="https://talksport.com/football/4043546/john-obi-mikel-interview-arsenal-chelsea-mourinho-nigeria/">controversial interview</a>, John Obi Mikel went as far as accusing Arsenal of cheating or suggesting their success depends solely on corners. Tottenham are the perfect counter-example: there’s far more to football than corners.
One could also argue Tottenham are in serious danger: without set pieces, their situation would be even worse than 16th place.</p>

<hr />

<h2 id="2-elche-2022-23-107-corner-shots-the-anatomy-of-desperation">2. Elche 2022-23: 107 corner shots, the anatomy of desperation</h2>

<p>Beyond Tottenham in 2025-26, there are other extreme cases. Elche, 2022-23 season, La Liga. The team was relegated with 25 points, yet they fired <strong>107 shots from corners</strong>, a La Liga record that season.</p>

<p><img src="/assets/en_elche_corners_2022_23.png" alt="Corner shot distribution, La Liga 2022-23" /></p>

<p>The z-score is 2.26, a statistical outlier. But the right-hand chart is even more telling: <strong>26.4% of all Elche’s shots came from corners</strong>. The La Liga average is 15.6%. No one else exceeds 19%.</p>

<p>Why? Because Elche couldn’t do anything else. Just 406 total shots (the lowest in La Liga), non-existent open play creation. Corners were their only route to goal. Lucas Boye, their target man, took 19 corner shots alone, shouldering the entire aerial workload.</p>

<p>When you can’t do anything else, you’re left with corners. Tottenham 2025-26 is a less extreme version of the same syndrome.</p>

<p>For the record, I haven’t watched a single Elche match. But the statistics are quite clear (I also checked whether this was a data anomaly or a single misrecorded match inflating the count, but no). <strong>I’m looking for Elche followers or supporters, because this is a truly remarkable phenomenon</strong>, and despite some searching, I haven’t found anything documenting this Elche exploit.</p>

<hr />

<h2 id="3-marseille-from-shame-to-partial-renaissance-on-corners">3. Marseille, from shame to (partial) renaissance on corners</h2>

<p>In November 2025, <a href="https://x.com/Rubenzf911/status/1985468834401116253">@Rubenzf911</a> asked the question many Marseille supporters had on their minds:</p>

<blockquote>
  <p><em>“OM’s set pieces, is anyone ever going to sound the alarm?”</em></p>
</blockquote>

<p>The alarm, the data had been ringing it for a while. Here’s Marseille’s evolution over 5 seasons:</p>

<p><img src="/assets/en_table_marseille_evolution.png" alt="Marseille evolution, 5 seasons" /></p>

<p>The table tells a three-act story:</p>

<p><strong>Act 1, The glory (2022-23)</strong>: 17 dead ball goals (1st in Ligue 1!), 11 from corners alone, 14.9 xG. OM were slightly overperforming (+2.1 goals vs xG). A 73-point season, their best in 5 years.</p>

<p><strong>Act 2, The collapse (2023-24 and 2024-25)</strong>: a crash to 6 then 7 dead ball goals. But note the 2023-24 xG: <strong>13.0 xG for just 6 goals</strong> (-7.0 underperformance). OM were creating good set piece chances, they just weren’t converting them. It was bad luck as much as inefficiency. De Zerbi’s first season was, from this perspective, quite a feat: despite dreadful set pieces, OM still finished 2nd.</p>

<p><strong>Act 3, The recovery (2025-26)</strong>: 7 dead ball goals, 4th in Ligue 1. Corners are improving (6 goals, 3rd in L1). One could argue the staff’s work (particularly Pancho Abardonado, apparently the set piece coach) has been <a href="https://x.com/TeamOM_Officiel/status/2029306295904272504">unfairly criticized</a>.</p>

<p>But a black hole remains.</p>

<p><img src="/assets/en_marseille_evolution_plot.png" alt="Marseille evolution: DB Goals vs xG" /></p>

<hr />

<h2 id="4-marseilles-black-hole-0-goals-from-indirect-free-kicks">4. Marseille’s black hole: 0 goals from indirect free kicks</h2>

<p>Zero. 21 shots from indirect free kicks this season, not a single goal. An xG per shot of 0.060, the worst among Ligue 1’s top 6. OM aren’t even creating good chances from these situations.</p>

<p><img src="/assets/en_table_top10_ligue1.png" alt="Top 10 Ligue 1, Dead Ball Goals" /></p>

<p>Look at Lorient: 10th in the table, 37 points, yet <strong>level with PSG and Lens</strong> on dead ball goals (10 each). Lorient score 4 from indirect free kicks. Marseille: zero. Even Metz, bottom of Ligue 1 (13 points), have scored 3 non-corner dead ball goals.</p>

<p>The comparison is harsh but raises questions.</p>

<p>To be fair, the coaching staff have clearly improved corners (from 3 goals / 15th in L1 to 6 goals / 3rd). But indirect free kicks (long throw-ins, set plays from wide free kicks) remain an open construction site. The xG confirms it: 0.060 per shot on indirect FKs, versus 0.097 on corners. You might think one or two free kick goals would bring OM back to average. Not even: OM simply aren’t creating good chances from these situations.</p>

<hr />

<h2 id="5-bayern-munich-the-best-attack-in-europe-period">5. Bayern Munich: the best attack in Europe, period</h2>

<p>If you’re looking for a model, it’s Bayern. Not because they’re the best from set pieces (Arsenal and Dortmund have 19 goals vs 14), but because they’re the best <strong>at everything simultaneously</strong>.</p>

<p><img src="/assets/en_bayern_european_context.png" alt="Bayern in European context" /></p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Bayern</th>
      <th>Rank /96</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Points/match</td>
      <td>2.58</td>
      <td><strong>1st</strong></td>
    </tr>
    <tr>
      <td>Goals/match</td>
      <td>3.46</td>
      <td><strong>1st</strong></td>
    </tr>
    <tr>
      <td>Open play goals</td>
      <td>65</td>
      <td><strong>1st</strong></td>
    </tr>
    <tr>
      <td>DB goals/match</td>
      <td>0.54</td>
      <td>2nd</td>
    </tr>
    <tr>
      <td>Corner conversion</td>
      <td>20.0%</td>
      <td>2nd</td>
    </tr>
  </tbody>
</table>

<p>They’re the <strong>only club</strong> positioned in the top right of every scatter plot: elite in open play AND from set pieces. Arsenal are co-leaders on set pieces (19 goals, tied with Dortmund) but relatively modest in open play (1.26 goals/match). Barcelona are strong in open play but average from set pieces. Inter are good at both but a notch below. Bayern have no blind spots.</p>

<p>And unlike Dortmund or Tottenham, this efficiency is backed by xG: 9.8 DB xG for 14 goals, an overperformance of +4.2, high but not outrageous.</p>

<hr />

<h2 id="6-dortmund-a-house-of-cards-built-on-corners">6. Dortmund: a house of cards built on corners?</h2>

<p>Borussia Dortmund co-lead Europe in dead ball goals this season with Arsenal: <strong>19 goals</strong> (13 corners, 6 indirect free kicks). Impressive? Yes. Sustainable? Probably not.</p>

<p><img src="/assets/en_bundesliga_big3_evolution.png" alt="Bundesliga Big 3, 5-season evolution" /></p>

<p>The problem is in the xG: Dortmund have scored 19 goals from just 11.8 xG. That’s an overperformance of +7.2, the second highest in our entire dataset (484 team-seasons). The only comparable season? Union Berlin 2022-23, with +10.5 overperformance on set pieces. The following year, Union Berlin collapsed.</p>

<p><img src="/assets/en_table_top20_all_seasons.png" alt="Top 20 dead ball seasons in Europe" /></p>

<p>But there’s perhaps a warning signal and a “dependency”: <strong>34.5% of Dortmund’s goals come from set pieces</strong>. Just 32 open play goals in 26 matches, their worst in 5 seasons. Set pieces are masking an open play attack that’s sputtering. 8 Bundesliga matchdays remain: the end of the season will tell whether this overperformance holds or corrects, as it has historically for other clubs.</p>

<hr />

<h2 id="7-lens-the-corner-machine-that-broke-down-in-november">7. Lens: the corner machine that broke down in November</h2>

<p>Lens are 2nd in Ligue 1 with 56 points and L1’s corner leader (10 goals). But there’s a before and after November.</p>

<p>On X (formerly Twitter), <a href="https://x.com/Laurentmazure/status/2032907605606080960">@Laurentmazure</a> recently complained:</p>

<blockquote>
  <p><em>“At some point, the media and pundits covering RC Lens need to stop saying the team is ‘effective’ and ‘dangerous’ from set pieces. That was true until November. Since then, Lens have scored just 1 of their last 26 goals from set pieces!”</em></p>
</blockquote>

<p>And <a href="https://x.com/MechTuyot/status/2032909166894092442">@MechTuyot</a> added:</p>

<blockquote>
  <p><em>“That’s the thing… you score two goals in 3 matches from set pieces, and you’re labeled a set piece team for the next 24 months, even if you lose your taker and your aerial threat… regardless of the league or the team.”</em></p>
</blockquote>

<p>The data proves them entirely right. Look at the curve.</p>

<p><img src="/assets/en_lens_timeline.png" alt="Lens, Dead Ball Timeline" /></p>

<p>The cumulative curve is brutal: <strong>9 dead ball goals in 14 matches through November, then just 1 in 12 matches since</strong>. The curve flatlines completely after November 22.</p>

<table>
  <thead>
    <tr>
      <th>Period</th>
      <th>Matches</th>
      <th>DB Goals</th>
      <th>Conversion</th>
      <th>DB xG</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Aug-Nov</td>
      <td>14</td>
      <td><strong>9</strong></td>
      <td>15.5%</td>
      <td>11.3</td>
    </tr>
    <tr>
      <td>Dec-Mar</td>
      <td>12</td>
      <td><strong>1</strong></td>
      <td>1.9%</td>
      <td>5.7</td>
    </tr>
  </tbody>
</table>

<p>It’s not a volume problem. Lens keep shooting (47 dead ball shots since December). Conversion dropped from 15.5% to 1.9%. The xG also halved. The quality of chances has degraded, not just the finishing. @MechTuyot’s observation is relevant: was a set piece taker or an aerial threat lost? The data doesn’t say who, but it confirms the when (late November) and the magnitude (total collapse).
It will be interesting to see whether Lens recover their efficiency by the end of the season, or whether the drop-off is structural.</p>

<p>The most ironic part: pundits continue praising Lens’ set pieces in March, when they haven’t worked for <strong>four months</strong>. Reputations outlive facts, and that’s precisely why we need data.</p>

<hr />

<h2 id="8-ligue-1-bottom-of-the-class-on-set-pieces">8. Ligue 1, bottom of the class on set pieces</h2>

<p>This isn’t just a Marseille problem or any single club’s issue. It’s the <strong>entire league</strong> that’s behind.</p>

<p><img src="/assets/en_leagues_evolution_5seasons.png" alt="League averages, 5-season evolution" /></p>

<table>
  <thead>
    <tr>
      <th>League</th>
      <th>DB Goals/team</th>
      <th>DB Conv</th>
      <th>xG/DB shot</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Premier League</strong></td>
      <td><strong>8.9</strong></td>
      <td><strong>9.2%</strong></td>
      <td><strong>0.111</strong></td>
    </tr>
    <tr>
      <td>Bundesliga</td>
      <td>7.8</td>
      <td>9.3%</td>
      <td>0.099</td>
    </tr>
    <tr>
      <td>Serie A</td>
      <td>7.8</td>
      <td>8.3%</td>
      <td>0.100</td>
    </tr>
    <tr>
      <td>La Liga</td>
      <td>6.4</td>
      <td>7.3%</td>
      <td>0.096</td>
    </tr>
    <tr>
      <td><strong>Ligue 1</strong></td>
      <td><strong>5.3</strong></td>
      <td><strong>6.7%</strong></td>
      <td><strong>0.093</strong></td>
    </tr>
  </tbody>
</table>

<p>Ligue 1 is <strong>last on every metric</strong>: fewer goals, worse conversion, lower shot quality (xG/shot). And the gap is widening: in 2021-22, L1 averaged 9.8 DB goals per team, ahead of the Premier League (9.2). By 2025-26, it’s 5.3 versus 8.9. The decline is spectacular.</p>

<p>Is it a tactical issue? Corner-taking quality? Defensive quality? The physique of attackers/defenders in aerial duels? Probably a bit of everything. But the data is clear: for equal investment in set pieces, a Ligue 1 club will get significantly less return than a Premier League club.</p>

<hr />

<h2 id="9-dead-ball-conversion-predicts-nothing-for-top-teams">9. Dead ball conversion predicts nothing for top teams</h2>

<p>This may be the most counter-intuitive finding of the entire analysis. You’d think the teams that convert best from set pieces are the ones at the top of the table. That’s true… on average. But look closer, and it’s far more nuanced.</p>

<p><img src="/assets/en_nonlinear_analysis.png" alt="Non-linear analysis" /></p>

<p>I split the 484 team-seasons into performance quartiles (by points, normalized within each league-season). Here are the correlations between dead ball conversion and points <strong>within each tier</strong>:</p>

<table>
  <thead>
    <tr>
      <th>Tier</th>
      <th>DB Conv vs Points (r)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Bottom 25%</td>
      <td>+0.12</td>
    </tr>
    <tr>
      <td>25-50%</td>
      <td>+0.05</td>
    </tr>
    <tr>
      <td>50-75%</td>
      <td>+0.12</td>
    </tr>
    <tr>
      <td><strong>Top 25%</strong></td>
      <td><strong>-0.11</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>For teams in the top 25%, dead ball conversion is slightly negatively correlated with points.</strong> In other words, among good teams, converting more from set pieces doesn’t help you climb the table. It can even signal over-dependency (hello Tottenham).</p>

<p>The examples speak for themselves:</p>
<ul>
  <li>Juventus 2024-25: 2.7% DB conversion, 70 points (4th in Serie A)</li>
  <li>Tottenham 2025-26: 21.5% DB conversion, 30 points (16th in PL)</li>
  <li>Marseille 2024-25: 6.8% DB conversion, 65 points (2nd in Ligue 1)</li>
</ul>

<p>The rolling correlation (bottom right chart) shows the effect of DB conversion is <strong>strongest between 30 and 50 points</strong>, i.e. mid-table teams. For them, set pieces can make the difference between survival and relegation. But above 65 points, the effect vanishes entirely.</p>

<p>Between groups, the gap is massive: the top 10% DB converters average <strong>16.8 more points</strong> than the bottom 10% (p&lt;0.001). But within a given performance group, DB conversion explains nothing.</p>

<hr />

<h2 id="10-the-marseille-paradox-what-if-improving-set-pieces-changes-nothing">10. The Marseille paradox: what if improving set pieces changes nothing?</h2>

<p>Let’s summarize OM’s situation. Overall conversion of 14.2% (excellent, well above average). Dead ball conversion of 7.6% (poor). Zero goals from indirect free kicks. The diagnosis is clear: a gap in the armor.</p>

<p>But the data also says that for a team of Marseille’s caliber (top 25% in Ligue 1), <strong>improving dead ball conversion doesn’t mechanically translate to more points</strong>. OM finished 2nd in 2024-25 with one of the worst corner records in L1’s top 15. They’re 3rd in 2025-26 with significantly better efficiency. The table hasn’t shifted proportionally.</p>

<p>If OM scored 3-4 more goals from indirect free kicks this season (which would simply be the Ligue 1 average), it might mean 2-3 extra points. Match scenarios could change too. But is that what closes an 8-point gap to PSG and Lens? Probably not.</p>

<p>The real levers are open play quality, defensive solidity, and not overperforming xPoints by 5 points, which tends to correct over time.</p>

<p>And that’s exactly what Habib Beye says, <a href="https://x.com/MassiliaZone/status/2032092476702670859">relayed by @MassiliaZone</a>:</p>

<blockquote>
  <p><em>“I value hard work, but situations need to open up beyond just set pieces.”</em></p>
</blockquote>

<p>Marseille’s coach reads it the same way as the data. Set pieces are worth working on (and the staff probably don’t do it enough). But Marseille’s ceiling doesn’t depend on it. Beye knows it, the xG confirms it.</p>

<p><strong>OM should invest more in set pieces, especially free kicks, but the return on investment is likely to be moderate. It’s not the only lever that changes Marseille’s ceiling.</strong></p>

<hr />

<h2 id="in-summary">In summary</h2>

<p>Set pieces are a bonus, not an engine. Tottenham are on Europe’s corner podium (14 goals, 3rd) and are fighting relegation. Marseille were among Ligue 1’s worst from corners in 2024-25 and finished 2nd. Data from 484 team-seasons confirms it: for good teams, dead ball conversion predicts nothing.</p>

<p>What sets Arsenal or Bayern apart isn’t just being good from set pieces. It’s being good at everything. Set pieces are merely the icing on a cake that needs to exist first. Lens have been ineffective from set pieces since November, but are a deserving and impressive 2nd.</p>

<p>Of course, the 2025-26 sample remains fragile (26-30 matchdays), and some trends (Dortmund’s overperformance, Lens’ collapse, Tottenham’s conversion) could correct by season’s end. The four previous seasons are complete and robust. That’s the whole point of cross-referencing analyses.</p>

<p>You can talk about football with data without betraying the game. Set pieces are a perfect subject: technical enough for numbers to add value, visible enough for everyone to have an opinion. And often, data confirms the intuition of supporters and coaches. For instance, I was convinced Marseille were catastrophic on set pieces. It’s a bit more subtle than I thought. Sometimes, data refines our views.</p>

<p><strong>Data is a starting point, a clue, food for thought, not a final verdict. It doesn’t tell everything, but sometimes pushes you to change or refine a snap judgement. With the democratization of tools and data, we can hope for football debates that are a little less instinct-driven and a little more evidence-based. And why not, more compelling.</strong></p>

<hr />

<p><em>Data and source code for this analysis are available on GitHub. All visualizations were generated with Python (matplotlib, soccerdata, scipy). xG data comes from Understat. Monte Carlo simulations for xPoints use 5,000 iterations per match with a Poisson distribution.</em></p>

<p><em>The 2025-26 season is not over: current figures reflect ~26-30 matchdays depending on the league. The trends identified are robust, but final standings and some overperformances (Dortmund, Tottenham) may shift. I’ll publish an update at the end of the season to see what held up and what corrected.</em></p>

<p><em>Have an opinion, a question, or want to share this article? Want to know how I did it? Contact me at mathieu.acher@irisa.fr</em></p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026cornersfootball</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{Corners, Free Kicks, and Set Pieces Across Europe's Top Football Leagues: What the Data Actually Says}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{mar}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CornersSetPiecesFootballEN/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CornersSetPiecesFootballEN/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="football" /><category term="data analysis" /><category term="corners" /><category term="set pieces" /><category term="Ligue 1" /><category term="Premier League" /><category term="Marseille" /><category term="Arsenal" /><category term="xG" /><category term="Understat" /><summary type="html"><![CDATA[Corners and set pieces are one of football’s most debated topics. “Arsenal are unstoppable from corners,” “Marseille are hopeless on free kicks,” and so on. Opinions, gut feelings, conventional wisdom. But sometimes the data surprises: did you know that Tottenham, fighting relegation in the Premier League, are on the European podium for corner goals this season, just behind Arsenal and Inter? What if we actually checked? I analyzed 5 seasons of data across Europe’s top 5 leagues (Premier League, Ligue 1, Serie A, Bundesliga, La Liga). Every shot, every goal, every expected goal (xG) from corners, indirect free kicks, and direct free kicks. 484 team-seasons. Here’s what I found in 10 key takeaways, and some results are surprising, underappreciated, or counter-intuitive.]]></summary></entry><entry><title type="html">Corners, coups francs, fantasmes et surprises dans les grands championnats européens de football : ce que les données disent vraiment</title><link href="http://blog.mathieuacher.com/CornersSetPiecesFootball/" rel="alternate" type="text/html" title="Corners, coups francs, fantasmes et surprises dans les grands championnats européens de football : ce que les données disent vraiment" /><published>2026-03-18T10:00:00+00:00</published><updated>2026-03-18T10:00:00+00:00</updated><id>http://blog.mathieuacher.com/CornersSetPiecesFootball</id><content type="html" xml:base="http://blog.mathieuacher.com/CornersSetPiecesFootball/"><![CDATA[<p>On parle souvent des corners et des coups de pied arrêtés (CPA) au café du commerce, sur les réseaux sociaux, ou dans les médias. “Arsenal est injouable sur corners”, “L’OM est nul sur coups francs”, etc. Des impressions, des ressentis. Et parfois des surprises : saviez-vous que Tottenham, à la lutte pour le maintien en Premier League, est sur le podium européen des buts sur corners cette saison, juste derrière Arsenal et l’Inter ? Non ? Oui ? 
Et si on allait vérifier ?
J’ai analysé <strong>5 saisons de données</strong> sur les 5 grands championnats européens (Premier League, Ligue 1, Serie A, Bundesliga, La Liga). Chaque tir, chaque but, chaque expected goal (xG) sur corner, coup franc indirect, coup franc direct. 484 équipes-saisons. Voici ce que j’ai trouvé en 10 points clés, et certains résultats me semblent surprenants, méconnus, ou contre-intuitifs.</p>

<p><em>Données : Understat via understatapi. 5 ligues européennes, 5 saisons (2021-2026), 484 équipes-saisons. Note : la saison 2025-26 est en cours (~26-30 journées selon les championnats). Les tendances sont claires mais les chiffres évolueront. Je mettrai à jour en fin de saison.</em></p>

<h2 id="1-tottenham-roi-des-corners-et-16e-de-premier-league">1. Tottenham, roi des corners… et 16e de Premier League</h2>

<p>C’est l’histoire la plus folle de cette saison (en cours, ~30 journées de PL jouées). Tottenham est <strong>3e européen</strong> sur les buts marqués sur corners : 14 buts, derrière Arsenal (16) et l’Inter (15). Conversion de 21.5%, la meilleure d’Europe.</p>

<p>Le problème ? Les Spurs sont actuellement <strong>16es de Premier League</strong> avec 30 points.</p>

<p><img src="/assets/fr_tottenham_open_vs_corners.png" alt="Tottenham vs Arsenal, Jeu ouvert vs Corners" /></p>

<p>Le graphique est sans appel. Tottenham est isolé en haut à gauche : beaucoup de buts sur corners, très peu en jeu ouvert (25 buts, 14e du championnat). Arsenal, lui, est en haut à droite : 16 buts sur corners ET 39 en jeu ouvert.</p>

<p><strong>35.0% des buts de Tottenham viennent de phases arrêtées.</strong> C’est le taux le plus élevé parmi les clubs des grands championnats (hors relégables). Quand plus d’un tiers de tes buts dépendent de situations de jeu qui représentent ~15% des tirs, tu construis sur du sable.</p>

<p>À titre de comparaison, le Bayern Munich, meilleure attaque d’Europe, n’est qu’à 15.6% de dépendance aux CPA. Parce que quand tu marques 65 buts en jeu ouvert, les corners sont un bonus, pas une béquille.</p>

<p>Et puis il y a Arsenal, l’ovni : <strong>16 buts sur corners (1er en Europe)</strong>, mais également 39 en jeu ouvert, 70 points (1er de Premier League). Arsenal ne se contente pas d’être redoutable offensivement sur CPA, ils le sont aussi dans le jeu. Lors du match de Ligue des Champions contre le Bayer Leverkusen, <a href="https://x.com/bayer04_en/status/2031723079886401891">le compte officiel du Bayer 04</a> a veinement interdit à Arsenal d’obtenir des corners. Des voies s’élèvent même en Premier League. 
Dans une interview explosive, John Obi Mikel a carrément accusé Arsenal de tricher ou que le succès d’Arsenal dépend uniquement des corners: https://talksport.com/football/4043546/john-obi-mikel-interview-arsenal-chelsea-mourinho-nigeria/. 
Tottenham est un parfait contre-exemple : il n’y a pas que les corners… 
Et on peut aussi penser que Tottenham est en grand danger, car sans les CPA la situation serait encore plus compliquée que la 16ème place.</p>

<hr />

<h2 id="2-elche-2022-23--107-tirs-sur-corners-lanatomie-du-désespoir">2. Elche 2022-23 : 107 tirs sur corners, l’anatomie du désespoir</h2>

<p>Outre Tottenham en 2025-26, il y a d’autres cas extrêmes : Elche, saison 2022-23, La Liga. L’équipe a été reléguée avec 25 points, et pourtant, elle a tiré <strong>107 fois sur corners</strong>, le record de La Liga cette saison-là.</p>

<p><img src="/assets/fr_elche_corners_2022_23.png" alt="Distribution des tirs sur corners, La Liga 2022-23" /></p>

<p>Le z-score est de 2.26, un outlier statistique. Mais le graphique de droite est encore plus parlant : <strong>26.4% de tous les tirs d’Elche venaient de corners</strong>. La moyenne de La Liga est à 15.6%. Personne d’autre ne dépasse 19%.</p>

<p>Pourquoi ? Parce qu’Elche ne savait rien faire d’autre.
Seulement 406 tirs au total (le plus bas de La Liga), une création en jeu ouvert inexistante. Les corners étaient leur seule route vers le but. Lucas Boyé, leur attaquant de pointe, a tiré 19 fois sur corner à lui seul, un homme-cible qui faisait tout le boulot aérien.</p>

<p>Quand tu ne sais rien faire d’autre, il te reste les corners. Tottenham 2025-26 est une version moins extrême du même syndrome.</p>

<p>Rassurez-vous, je n’ai pas regardé tous les matchs de Elche. En fait, je pense que j’ai dû en regarder à peu près zéro. Mais les statistiques sont assez claires (j’ai regardé également si c’était une anomalie des données ou par exemple un match mal renseigné peut explique ce haut score en corners, mais non, a priori). Ceci dit, <strong>je cherche des suiveurs ou mieux supporters de Elche, car c’est un phénomène assez incroyable</strong>, et malgré quelques recherches ici et là, je n’ai rien trouvé qui relate cet exploit de Elche.</p>

<hr />

<h2 id="3-lom-de-la-honte-à-la-renaissance-partielle-sur-corners">3. L’OM, de la honte à la renaissance (partielle) sur corners</h2>

<p>En novembre 2025, <a href="https://x.com/Rubenzf911/status/1985468834401116253">@Rubenzf911</a> posait la question que beaucoup de supporters marseillais avaient en tête :</p>

<blockquote>
  <p><em>“Les CPA de l’OM est-ce qu’un jour on va tirer la sonnette d’alarme svp?”</em></p>
</blockquote>

<p>La sonnette, les données l’ont tirée depuis un moment. Voici l’évolution de Marseille sur 5 saisons :</p>

<p><img src="/assets/fr_table_marseille_evolution.png" alt="Évolution Marseille, 5 saisons" /></p>

<p>Le tableau raconte une histoire en trois actes :</p>

<p><strong>Acte 1, La gloire (2022-23)</strong> : 17 buts sur CPA (1er de Ligue 1 !), 11 sur corners seuls, 14.9 xG. L’OM surperformait légèrement (+2.1 buts vs xG). Saison à 73 points, la meilleure des 5 années.</p>

<p><strong>Acte 2, La chute (2023-24 et 2024-25)</strong> : effondrement à 6 puis 7 buts CPA. Mais attention au xG de 2023-24 : <strong>13.0 xG pour seulement 6 buts</strong> (-7.0 de sous-performance). L’OM créait de bonnes occasions sur CPA, il ne les convertissait juste pas. C’était de la malchance autant que de l’inefficacité. La première saison de De Zerbi est d’un certain point de vue une sacré performance : malgré des CPA inefficaces l’OM a quand même fini 2ème.</p>

<p><strong>Acte 3, La remontée (2025-26)</strong> : 7 buts CPA, 4e de Ligue 1. Les corners vont mieux (6 buts, 3e en L1). On peut donc se dire que le travail du staff (avec en ligne de mire Pancho Abardonado, apparemment entraîneur en charge des CPA) est inutilement critiqué https://x.com/TeamOM_Officiel/status/2029306295904272504</p>

<p>Mais un trou noir persiste.</p>

<p><img src="/assets/fr_marseille_evolution_plot.png" alt="Évolution Marseille, Buts CPA vs xG" /></p>

<hr />

<h2 id="4-le-trou-noir-marseillais--0-but-sur-coups-francs-indirects">4. Le trou noir marseillais : 0 but sur coups francs indirects</h2>

<p>Zéro. 21 tirs issus de coups francs indirects cette saison, pas un seul but. Un xG par tir de 0.060, le pire des 6 premiers de Ligue 1. L’OM ne crée même pas de bonnes occasions sur ces phases de jeu.</p>

<p><img src="/assets/fr_table_top10_ligue1.png" alt="Top 10 Ligue 1, Phases arrêtées" /></p>

<p>Regardons Lorient : 10e au classement, 37 points, et pourtant à égalité avec le PSG et Lens sur les buts CPA (10 chacun). Lorient marque 4 buts sur coups francs indirects. L’OM : zéro. Même Metz, dernier de Ligue 1 (13 points), a marqué 3 buts sur CPA hors corners.</p>

<p>La comparaison est cruelle mais pose des questions.</p>

<p>Soyons justes : le travail du staff a clairement amélioré les corners (de 3 buts / 15e en L1 à 6 buts / 3e). Mais les coups francs indirects (rentrées en touche longues, combinaisons sur coups francs excentrés) restent un chantier ouvert. Les xG le confirment : 0.060 par tir sur CF indirects, contre 0.097 sur corners. 
Il suffirait d’un ou deux buts sur coup franc pour remettre l’OM à niveau me direz-vous… Même pas : L’OM ne crée tout simplement pas de bonnes occasions sur ces phases-là.</p>

<hr />

<h2 id="5-bayern-munich--la-meilleure-attaque-deurope-point-final">5. Bayern Munich : la meilleure attaque d’Europe, point final</h2>

<p>Si vous cherchez un modèle, c’est le Bayern. Pas parce qu’ils sont les meilleurs sur CPA (Arsenal et Dortmund font 19 buts CPA contre 14), mais parce qu’ils sont les meilleurs <strong>partout en même temps</strong>.</p>

<p><img src="/assets/fr_bayern_contexte_europeen.png" alt="Bayern en contexte européen" /></p>

<table>
  <thead>
    <tr>
      <th>Métrique</th>
      <th>Bayern</th>
      <th>Rang /96</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Points/match</td>
      <td>2.58</td>
      <td><strong>1er</strong></td>
    </tr>
    <tr>
      <td>Buts/match</td>
      <td>3.46</td>
      <td><strong>1er</strong></td>
    </tr>
    <tr>
      <td>Buts en jeu ouvert</td>
      <td>65</td>
      <td><strong>1er</strong></td>
    </tr>
    <tr>
      <td>Buts CPA/match</td>
      <td>0.54</td>
      <td>2e</td>
    </tr>
    <tr>
      <td>Conversion corners</td>
      <td>20.0%</td>
      <td>2e</td>
    </tr>
  </tbody>
</table>

<p>C’est le <strong>seul club</strong> situé en haut à droite de tous les nuages de points : élite en jeu ouvert ET en phases arrêtées. Arsenal est co-leader sur CPA (19 buts, à égalité avec Dortmund) mais relativement modeste en jeu ouvert (1.26 but/match). Barcelone est fort en jeu ouvert mais moyen sur CPA. Inter est bon partout mais à un cran en dessous. Le Bayern n’a aucun angle mort.</p>

<p>Et contrairement à Dortmund ou Tottenham, cette efficacité est soutenue par les xG : 9.8 xG CPA pour 14 buts, une surperformance de +4.2, élevée mais pas délirante.</p>

<hr />

<h2 id="6-dortmund--un-château-de-cartes-construit-sur-corners">6. Dortmund : un château de cartes construit sur corners?</h2>

<p>Borussia Dortmund est co-leader européen des buts sur phases arrêtées cette saison avec Arsenal : <strong>19 buts</strong> (13 corners, 6 coups francs indirects). Impressionnant ? Oui. Durable ? Probablement pas.</p>

<p><img src="/assets/fr_bundesliga_big3_evolution.png" alt="Bundesliga Big 3, Évolution 5 saisons" /></p>

<p>Le problème est dans les xG : Dortmund a marqué 19 buts pour seulement 11.8 xG. C’est une surperformance de +7.2, la deuxième plus élevée de tout notre dataset (484 équipes-saisons). La seule saison comparable ? Union Berlin 2022-23, avec +10.5 de surperformance sur CPA. L’année suivante, Union Berlin s’est écroulé.</p>

<p><img src="/assets/fr_table_top20_all_seasons.png" alt="Top 20 meilleures saisons CPA en Europe" /></p>

<p>Mais il y a peut-être un signal d’alarme et une “dépendance” : <strong>34.5% des buts de Dortmund viennent de CPA</strong>. Seulement 32 buts en jeu ouvert en 26 matchs, le pire de leur 5 dernières saisons. Les phases arrêtées masquent une attaque en jeu ouvert qui tourne au ralenti. Il reste 8 journées en Bundesliga : la fin de saison dira si cette surperformance tient ou si elle se corrige, comme elle l’a fait historiquement pour d’autres clubs.</p>

<hr />

<h2 id="7-lens--la-machine-à-corners-qui-sest-enrayée-en-novembre">7. Lens : la machine à corners qui s’est enrayée en novembre</h2>

<p>Lens est 2e de Ligue 1 avec 56 points et leader sur les corners en L1 (10 buts). Mais il y a un avant et un après novembre.</p>

<p>Sur X (ex-Twitter), <a href="https://x.com/Laurentmazure/status/2032907605606080960">@Laurentmazure</a> s’agaçait récemment :</p>

<blockquote>
  <p><em>“Il faudra aussi, un jour, que les médias et ceux qui commentent les matchs du #RCLens (cc Josse-Bravo) arrêtent de dire que le Racing est “efficace” et toujours “dangereux” sur CPA. C’était le cas jusqu’en novembre. Depuis, Lens n’a marqué que 1 de ses 26 derniers buts sur CPA !”</em></p>
</blockquote>

<p>Et <a href="https://x.com/MechTuyot/status/2032909166894092442">@MechTuyot</a> de renchérir :</p>

<blockquote>
  <p><em>“Ah ça… tu marques deux buts en 3 matchs sur cpa, t’es catalogué équipe de cpa pour les 24 mois qui suivent même si tu perds ton tireur et ton mec qui met des têtes… peu importe le championnat et l’équipe.”</em></p>
</blockquote>

<p>Les données leur donnent entièrement raison. Regardez la courbe.</p>

<p><img src="/assets/fr_lens_timeline.png" alt="Lens, Timeline CPA" /></p>

<p>La courbe cumulative est brutale : <strong>9 buts CPA en 14 matchs jusqu’en novembre, puis 1 seul but en 12 matchs depuis</strong>. La courbe se fige complètement après le 22 novembre.</p>

<table>
  <thead>
    <tr>
      <th>Période</th>
      <th>Matchs</th>
      <th>Buts CPA</th>
      <th>Conversion</th>
      <th>xG CPA</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Août-Novembre</td>
      <td>14</td>
      <td><strong>9</strong></td>
      <td>15.5%</td>
      <td>11.3</td>
    </tr>
    <tr>
      <td>Décembre-Mars</td>
      <td>12</td>
      <td><strong>1</strong></td>
      <td>1.9%</td>
      <td>5.7</td>
    </tr>
  </tbody>
</table>

<p>Ce n’est pas un problème de volume. Lens continue de tirer (47 tirs CPA depuis décembre). La conversion est passée de 15.5% à 1.9%. Le xG aussi a chuté de moitié. La qualité des occasions s’est dégradée, pas juste la finition. L’observation de @MechTuyot est pertinente : est-ce qu’un tireur ou un “mec qui met des têtes” a été perdu ? Les données ne disent pas qui, mais elles confirment le quand (fin novembre) et l’ampleur (effondrement total). 
Il sera intéressant de voir si Lens retrouve son efficacité d’ici la fin de saison, ou si le décrochage est structurel.h</p>

<p>Le plus ironique : les commentateurs continuent de vanter les CPA lensoises en mars, alors que ça fait <strong>quatre mois</strong> que ça ne fonctionne plus. Les réputations survivent longtemps aux faits, et c’est exactement pour ça qu’on a besoin de données. 
<strong>Message de service: les commentaires/analystes/émissions foot, qui cumulent des milliers d’heures, gagneraient à s’appuyer sur de telles données pour alimenter les débats.</strong> C’est en tout cas un point de départ intéressant pour de nombreux débats.</p>

<hr />

<h2 id="8-la-ligue-1-dernière-de-la-classe-sur-phases-arrêtées">8. La Ligue 1, dernière de la classe sur phases arrêtées</h2>

<p>Ce n’est pas juste un problème de Marseille ou de tel club. C’est tout le championnat qui est en retard.</p>

<p><img src="/assets/fr_ligues_evolution_5saisons.png" alt="Moyennes par ligue, Évolution 5 saisons" /></p>

<table>
  <thead>
    <tr>
      <th>Ligue</th>
      <th>Buts CPA/équipe</th>
      <th>Conv. CPA</th>
      <th>xG/tir CPA</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Premier League</strong></td>
      <td><strong>8.9</strong></td>
      <td><strong>9.2%</strong></td>
      <td><strong>0.111</strong></td>
    </tr>
    <tr>
      <td>Bundesliga</td>
      <td>7.8</td>
      <td>9.3%</td>
      <td>0.099</td>
    </tr>
    <tr>
      <td>Serie A</td>
      <td>7.8</td>
      <td>8.3%</td>
      <td>0.100</td>
    </tr>
    <tr>
      <td>La Liga</td>
      <td>6.4</td>
      <td>7.3%</td>
      <td>0.096</td>
    </tr>
    <tr>
      <td><strong>Ligue 1</strong></td>
      <td><strong>5.3</strong></td>
      <td><strong>6.7%</strong></td>
      <td><strong>0.093</strong></td>
    </tr>
  </tbody>
</table>

<p>La Ligue 1 est <strong>dernière sur tout</strong> : moins de buts, moins bonne conversion, moins bonne qualité d’occasion (xG/tir). Et l’écart se creuse : en 2021-22, la L1 était à 9.8 buts CPA par équipe, devant la Premier League (9.2). En 2025-26, elle est à 5.3 contre 8.9. Le décrochage est spectaculaire.</p>

<p>Est-ce un problème tactique ? De qualité des tireurs de corners ? De qualité des défenses ? De gabarit des défenseurs/attaquants sur les phases aériennes ? Probablement un peu de tout. Mais les données sont claires : à investissement égal dans les CPA, un club de Ligue 1 en tirera significativement moins qu’un club de Premier League.</p>

<hr />

<h2 id="9-la-conversion-sur-cpa-ne-prédit-rien-pour-les-grosses-équipes">9. La conversion sur CPA ne prédit rien pour les grosses équipes</h2>

<p>C’est peut-être le résultat le plus contre-intuitif de toute l’analyse. On pourrait penser que les équipes qui convertissent le mieux sur CPA sont les meilleures au classement. C’est vrai… en moyenne. Mais en regardant de plus près, c’est beaucoup plus nuancé.</p>

<p><img src="/assets/fr_analyse_nonlineaire.png" alt="Analyse non-linéaire" /></p>

<p>J’ai découpé les 484 équipes-saisons en quartiles de performance (par points, normalisés au sein de chaque ligue-saison). Voici les corrélations entre conversion CPA et points <strong>au sein de chaque tier</strong> :</p>

<table>
  <thead>
    <tr>
      <th>Tier</th>
      <th>Conv. CPA vs Points (r)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Bas 25%</td>
      <td>+0.12</td>
    </tr>
    <tr>
      <td>25-50%</td>
      <td>+0.05</td>
    </tr>
    <tr>
      <td>50-75%</td>
      <td>+0.12</td>
    </tr>
    <tr>
      <td><strong>Haut 25%</strong></td>
      <td><strong>-0.11</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>Pour les équipes du top 25%, la conversion CPA est légèrement négativement corrélée avec les points.</strong> Autrement dit, parmi les bonnes équipes, mieux convertir les CPA ne te fait pas monter au classement. Ça peut même être le signe d’une sur-dépendance (hello Tottenham).</p>

<p>Les exemples sont parlants :</p>
<ul>
  <li>Juventus 2024-25 : 2.7% de conversion CPA, 70 points (4e de Serie A)</li>
  <li>Tottenham 2025-26 : 21.5% de conversion CPA, 30 points (16e de PL)</li>
  <li>Marseille 2024-25 : 6.8% de conversion CPA, 65 points (2e de Ligue 1)</li>
</ul>

<p>La corrélation glissante (graphique en bas à droite) montre que l’effet de la conversion CPA est maximal entre 30 et 50 points :les équipes de milieu de tableau. Pour elles, les phases arrêtées peuvent faire la différence entre le maintien et la relégation. Mais au-delà de 65 points, l’effet disparaît complètement.</p>

<p>Entre les groupes, l’écart est massif : les 10% meilleurs convertisseurs CPA ont en moyenne 16.8 points de plus que les 10% pires (p&lt;0.001). Mais à l’intérieur d’un même groupe de niveau, la conversion CPA n’explique rien.</p>

<hr />

<h2 id="10-le-paradoxe-marseillais--et-si-améliorer-les-cpa-ne-changeait-rien-">10. Le paradoxe marseillais : et si améliorer les CPA ne changeait rien ?</h2>

<p>Résumons la situation de l’OM. Conversion globale de 14.2% (excellente, bien au-dessus de la moyenne). Conversion CPA de 7.6% (médiocre). Zéro but sur coups francs indirects. Le diagnostic est clair : un trou dans la raquette.</p>

<p>Mais les données disent aussi que pour une équipe du calibre de Marseille (top 25% de Ligue 1), <strong>améliorer la conversion CPA ne se traduit pas mécaniquement en points supplémentaires</strong>. L’OM est 2e en 2024-25 avec la pire efficacité corners du top 15. Il est 3e en 2025-26 avec une efficacité nettement meilleure. Le classement n’a pas bougé proportionnellement.</p>

<p>Si l’OM marquait 3-4 buts de plus sur coups francs indirects cette saison (ce qui serait simplement la moyenne de Ligue 1), cela ferait peut-être 2-3 points de plus.
Des scénarios de match peuvent changer également. Mais est-ce que c’est ça qui comble un écart de 8 points avec le PSG et Lens ? Probablement pas.</p>

<p>Les autres (vraies) leviers, c’est le jeu ouvert, la solidité défensive, et arrêter de surperformer ses xPoints de 5 points, ce qui finit souvent par se corriger.</p>

<p>Et c’est exactement ce que dit Habib Beye, <a href="https://x.com/MassiliaZone/status/2032092476702670859">relayé par @MassiliaZone</a> :</p>

<blockquote>
  <p><em>“J’accorde beaucoup d’importance au travail mais les situations doivent s’ouvrir aussi sur autre chose que des coups de pieds arrêtés.”</em></p>
</blockquote>

<p>L’entraîneur de l’OM a la même lecture que les données. Les CPA, c’est bien, il faut y travailler (et le staff ne le fait certainement pas assez). Mais le plafond de Marseille ne se joue pas forcément là. Beye le sait, les xG le confirment.</p>

<p><strong>L’OM doit d’avantage investir dans les CPA, surtout les coups francs, mais le “retour sur investissement” risque d’être modéré. Ce n’est pas le seul levier qui change le plafond de Marseille.</strong></p>

<hr />

<h2 id="en-résumé">En résumé</h2>

<p>Les CPA sont un bonus, pas un moteur. Tottenham est sur le podium européen des corners (14 buts, 3e) et lutte pour le maintien. L’OM était un des pires de Ligue 1 sur corners en 2024-25 et a fini 2e. Les données de 484 équipes-saisons le confirment : pour les bonnes équipes, la conversion CPA ne prédit rien.</p>

<p>Ce qui distingue Arsenal ou le Bayern, ce n’est pas uniquement d’être bons sur CPA. C’est d’être bons partout. Les phases arrêtées ne sont qu’une cerise sur un gâteau qui doit d’abord exister. Lens est inefficace depuis novembre sur CPA, mais est un très beau et méritant 2ème.</p>

<p>Bien sûr, sur 2025-26, l’échantillon reste fragile (26-30 journées), et certaines tendances (la surperformance de Dortmund, l’effondrement de Lens, la conversion de Tottenham) pourraient se corriger d’ici la fin de saison. Les quatre saisons précédentes, elles, sont complètes et solides. C’est tout l’intérêt de croiser les analyses.</p>

<p>On peut parler de football avec des données sans trahir le jeu. Les CPA sont un sujet parfait : assez technique pour que les chiffres apportent quelque chose, assez visible pour que tout le monde ait un avis. Et souvent, les données confirment l’intuition des supporters et des coachs ou des “experts”. A titre d’exemple, j’étais persuadé que l’OM était catastrophique sur CPA. C’est un peu plus subtile que ce que je pensais. Aussi, parfois, les données corrigent les avis.</p>

<p><strong>Les données sont un point de départ, un indice, du grain à moudre, pas un jugement final. Elles ne disent pas tout, mais poussent parfois à changer ou raffiner son jugement initial ou un avis tranché. Avec la démocratisation des outils et des données, on peut espérer des débats foot un peu moins à l’instinct et un peu plus étayés. Et pourquoi pas, plus passionnants.</strong></p>

<hr />

<p><em>Les données et le code source de cette analyse sont disponibles sur GitHub. Toutes les visualisations ont été générées avec Python (matplotlib, soccerdata, scipy). Les xG proviennent d’Understat. Les simulations Monte Carlo pour les xPoints utilisent 5000 itérations par match avec distribution de Poisson.</em></p>

<p><em>La saison 2025-26 n’est pas terminée : les chiffres actuels reflètent ~26-30 journées selon les championnats. Les tendances identifiées sont robustes, mais les classements finaux et certaines surperformances (Dortmund, Tottenham) pourraient évoluer. Je publierai une mise à jour en fin de saison pour voir ce qui a tenu et ce qui s’est corrigé.</em></p>

<p><em>Vous avez un avis, une question, envie de partager l’article sur d’autres medium ? Ou simplement savoir comment je m’y suis pris ? Contactez-moi mathieu.acher@irisa.fr</em></p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026cornersfootballfr</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{Corners, coups francs, fantasmes et surprises dans les grands championnats européens de football : ce que les données disent vraiment}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{mar}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CornersSetPiecesFootball/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CornersSetPiecesFootball/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="football" /><category term="data analysis" /><category term="corners" /><category term="set pieces" /><category term="Ligue 1" /><category term="Premier League" /><category term="Marseille" /><category term="Arsenal" /><category term="xG" /><category term="Understat" /><summary type="html"><![CDATA[On parle souvent des corners et des coups de pied arrêtés (CPA) au café du commerce, sur les réseaux sociaux, ou dans les médias. “Arsenal est injouable sur corners”, “L’OM est nul sur coups francs”, etc. Des impressions, des ressentis. Et parfois des surprises : saviez-vous que Tottenham, à la lutte pour le maintien en Premier League, est sur le podium européen des buts sur corners cette saison, juste derrière Arsenal et l’Inter ? Non ? Oui ? Et si on allait vérifier ? J’ai analysé 5 saisons de données sur les 5 grands championnats européens (Premier League, Ligue 1, Serie A, Bundesliga, La Liga). Chaque tir, chaque but, chaque expected goal (xG) sur corner, coup franc indirect, coup franc direct. 484 équipes-saisons. Voici ce que j’ai trouvé en 10 points clés, et certains résultats me semblent surprenants, méconnus, ou contre-intuitifs.]]></summary></entry><entry><title type="html">Can Coding Agents Program in M&amp;amp;Ms Language?</title><link href="http://blog.mathieuacher.com/CodingAgentsMnMLang/" rel="alternate" type="text/html" title="Can Coding Agents Program in M&amp;amp;Ms Language?" /><published>2026-03-17T13:00:00+00:00</published><updated>2026-03-17T13:00:00+00:00</updated><id>http://blog.mathieuacher.com/CodingAgentsMnMLang</id><content type="html" xml:base="http://blog.mathieuacher.com/CodingAgentsMnMLang/"><![CDATA[<p>What happens when you challenge a coding agent with an esoteric programming language it has never seen (freshly invented, with very few examples), and where the source code is literally colored M&amp;Ms on a table? No documentation beyond the opcode table. No Stack Overflow threads. No training examples to memorize. Just six candy colors, a stack machine, and a dare.
<img src="/assets/mnm-mul-table.png" alt="Multiplication table in MNM Lang, written by Claude Code and rendered as M&amp;M candies" style="max-height: 400px;" />
I tried it with <a href="https://github.com/mufeedvh/mnmlang">MNM Lang</a>, a toy language amazingly created by <a href="https://mufeedvh.com/posts/i-made-a-programming-language-with-mnms/">Mufeed VH</a> where programs are grids of candy-colored tokens (<code class="language-plaintext highlighter-rouge">B</code>lue, <code class="language-plaintext highlighter-rouge">G</code>reen, <code class="language-plaintext highlighter-rouge">R</code>ed, <code class="language-plaintext highlighter-rouge">Y</code>ellow, <code class="language-plaintext highlighter-rouge">O</code>range, brow<code class="language-plaintext highlighter-rouge">N</code>). Token length encodes operand values. Strings and inputs live in a sidecar JSON file. The whole thing compiles to a PNG of M&amp;M sprites. So funny by design, for humans. But could an LLM learn to write it?</p>

<p>Again: no training examples, no worked solutions, no hints. Just the README spec and the opcode table. Could an LLM figure out the semantics and produce correct MNM Lang programs?</p>

<p>I gave <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a> (Claude Opus 4.6) 26 challenges of increasing difficulty, from “sum 1 to N” to “write a Brainfuck interpreter”. No hand-holding, no worked examples beyond the opcode table in the README. The agent had to figure out the language semantics from the spec and produce working programs.</p>

<p>The result: all 26 challenges solved. Including a 1,260-line Brainfuck interpreter.</p>

<h2 id="what-mnm-lang-looks-like">What MNM Lang Looks Like</h2>

<p>Each row is one instruction. The opcode is determined by the color and length of the first token. Operands follow. <code class="language-plaintext highlighter-rouge">GGG GG</code> means <code class="language-plaintext highlighter-rouge">STORE</code> (3 greens) into variable slot 1 (2 greens, value = length - 1 = 1). That’s the entire encoding: count the candies.</p>

<h2 id="the-challenges">The Challenges</h2>

<p>I designed 26 challenges across five difficulty tiers:</p>

<p><strong>Easy (1-10):</strong> Sum 1..N, countdown, even/odd, max of two, absolute value, multiplication table, power of two, multiply by addition, integer average, sum of digits.</p>

<p><strong>Easy/Medium (11-20):</strong> M&amp;M total counter, most common color, vending machine change, staircase pattern, string length, repeat phrase, palindrome number, Collatz sequence, run-length decoding, mini gradebook (min/max/average).</p>

<p><strong>Hard (21-22):</strong> Recursive Fibonacci using <code class="language-plaintext highlighter-rouge">CALL</code>/<code class="language-plaintext highlighter-rouge">RET</code>, a tiny VM interpreter (meta-interpretation: a VM inside a VM).</p>

<p><strong>Bonus (23-25):</strong> M&amp;M histogram, digital clock with carry, Caesar cipher.</p>

<p><strong>Epic (26):</strong> A full Brainfuck interpreter.</p>

<p>The agent tackled them in order. Each challenge required reading the spec, understanding the stack semantics (pop order matters!), and producing both a <code class="language-plaintext highlighter-rouge">.mnm</code> source file and a <code class="language-plaintext highlighter-rouge">.mnm.json</code> sidecar with the right variable layout and input queues.</p>

<h2 id="what-went-wrong-at-first">What Went Wrong (At First)</h2>

<p>It wasn’t a clean sweep on the first try. Off-by-one in token encoding (35 R’s instead of 36 for ASCII 35), inverted branch polarity in the palindrome checker, variable slot collisions causing infinite loops, wrong comparison operators exiting one iteration too late. The kind of bugs you’d expect from anyone learning an unfamiliar stack machine with no type checker and no linter.</p>

<p>But the agent self-corrected every time: I pointed out the wrong output, and it re-read the spec, found the bug, and fixed it. The debugging cycle was exactly what a human would do with an unfamiliar language, just faster.</p>

<h2 id="the-brainfuck-interpreter">The Brainfuck Interpreter</h2>

<p>Challenge 26 is where things got wild. The task: implement a complete Brainfuck interpreter <em>in MNM Lang</em>. All 8 BF operations (<code class="language-plaintext highlighter-rouge">&gt; &lt; + - . , [ ]</code>), including nested bracket matching with depth tracking.</p>

<p>The fundamental problem: MNM Lang has no arrays and no indirect addressing. You can <code class="language-plaintext highlighter-rouge">LOAD var[3]</code> but you can’t <code class="language-plaintext highlighter-rouge">LOAD var[x]</code> where <code class="language-plaintext highlighter-rouge">x</code> is computed at runtime. For a BF interpreter, you need random access to both the program (to fetch the current instruction) and the tape (to read/write the current cell).</p>

<p>The solution: comparison chain dispatch. To fetch <code class="language-plaintext highlighter-rouge">program[IP]</code>, the interpreter does:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Is IP == 0? Load var[10], return.
# Is IP == 1? Load var[11], return.
# Is IP == 2? Load var[12], return.
# ... (120 cases)
</code></pre></div></div>

<p>Three subroutines (<code class="language-plaintext highlighter-rouge">fetch</code>, <code class="language-plaintext highlighter-rouge">tape_load</code>, <code class="language-plaintext highlighter-rouge">tape_store</code>), each a linear scan over all possible indices. The <code class="language-plaintext highlighter-rouge">fetch</code> subroutine alone is 120 cases. The tape subroutines are 20 each. The whole program is 1,260 lines of MNM source, using 174 labels. It was generated by a Python script (<code class="language-plaintext highlighter-rouge">generate.py</code>) that the agent also wrote.</p>

<p>And it works. Here’s what it looks like as candy:</p>

<p><img src="/assets/mnm-histogram.png" alt="M&amp;M Histogram challenge in MNM Lang" style="max-height: 500px;" /></p>

<p><em>The M&amp;M histogram challenge (53 lines), which prints horizontal bar charts of candy counts. The Brainfuck interpreter is 1,260 lines of this.</em></p>

<h2 id="cross-validation">Cross-Validation</h2>

<p>To verify the Brainfuck interpreter actually works, I co-developed with the agent a cross-validation test suite (<code class="language-plaintext highlighter-rouge">tests/test_brainfuck.py</code>). I pushed it toward this testing style: every BF program runs through both a reference Python BF interpreter and the MNM BF interpreter, and we assert identical output. Writing correct Brainfuck programs is itself non-trivial (especially with unbounded integers instead of wrapping cells), so several BF test programs needed debugging too. 39 test cases across multiple categories:</p>

<table>
  <thead>
    <tr>
      <th>Category</th>
      <th>Programs</th>
      <th>Examples</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Basic</td>
      <td>5</td>
      <td>Empty, single dot, increment, decrement</td>
    </tr>
    <tr>
      <td>Movement</td>
      <td>3</td>
      <td>Multi-cell read/write, bounce</td>
    </tr>
    <tr>
      <td>Loops</td>
      <td>6</td>
      <td>Simple multiply, clear, skip, nested, countdown</td>
    </tr>
    <tr>
      <td>Input</td>
      <td>3</td>
      <td>Echo, add, reverse</td>
    </tr>
    <tr>
      <td>Edge cases</td>
      <td>3</td>
      <td>No output, adjacent loops, add two inputs</td>
    </tr>
    <tr>
      <td>Real-world</td>
      <td>15</td>
      <td>Hello World, multiply, power-of-2, sum-of-N, Fibonacci</td>
    </tr>
    <tr>
      <td>Tape state</td>
      <td>4</td>
      <td>Verify final memory layout</td>
    </tr>
  </tbody>
</table>

<p>The Hello World BF program runs through the MNM interpreter in ~1.4 seconds and 1.6 million steps. It produces the correct 13 ASCII values: <code class="language-plaintext highlighter-rouge">72 101 108 108 111 32 87 111 114 108 100 33 10</code> (Hello World! plus newline).</p>

<p>The Fibonacci BF program, itself running inside the MNM BF interpreter, correctly outputs <code class="language-plaintext highlighter-rouge">[1, 1, 2, 3, 5, 8, 13]</code> for N=7. That’s a Fibonacci computation running inside a Brainfuck program running inside an MNM Lang interpreter. Three levels of interpretation.</p>

<h2 id="what-this-tells-us">What This Tells Us</h2>

<p>MNM Lang is a good test for coding agents for a few reasons:</p>

<p><strong>Unfamiliar territory.</strong> There are essentially zero MNM Lang programs in any training corpus. The agent can’t pattern-match against memorized solutions. It has to actually <em>reason</em> about the language specification.</p>

<p><strong>Hostile encoding.</strong> The token-length-as-value scheme means you can’t eyeball correctness. Is <code class="language-plaintext highlighter-rouge">GGGGGGG</code> a <code class="language-plaintext highlighter-rouge">DEC</code> instruction or a 6-green operand? Context matters, and off-by-one errors are invisible until runtime.</p>

<p><strong>No safety net.</strong> No type checker, no linter, no error messages beyond “wrong output” or “step limit exceeded”. Debugging requires mentally tracing a stack machine, which the agent did successfully.</p>

<p><strong>Scaling test.</strong> The jump from challenge 1 (10 lines) to challenge 26 (1,260 lines) is two orders of magnitude. The agent needed to invent architectural patterns (comparison chain dispatch, generated subroutines) that aren’t in any MNM Lang tutorial because there are no MNM Lang tutorials. And the approach of generating MNM programs via a Python script was a practical engineering decision the agent made on its own.</p>

<p>This is similar in spirit to what I observed with <a href="https://blog.mathieuacher.com/PrintfOrientedProgrammingCodingAgents/">Printf-Oriented Programming</a> and <a href="https://blog.mathieuacher.com/TeXCCChessEngine/">a chess engine written in LaTeX</a>. Coding agents can master unfamiliar paradigms, even absurd ones like encoding game logic inside <code class="language-plaintext highlighter-rouge">\expandafter</code> chains or <code class="language-plaintext highlighter-rouge">printf</code> format strings. But the really interesting question is <em>how far</em> they can scale. With POP, the agents hit a wall at chess-level complexity. With LaTeX, the agent produced a playable chess engine but needed extensive macro plumbing that pushed TeX to its limits. With MNM Lang, the agent pushed through to a working Brainfuck interpreter, but needed a code generator to manage the complexity. The pattern is consistent: agents handle novel paradigms well at small scale, and find creative workarounds (compilers, generators, meta-programming) when direct coding becomes intractable.</p>

<h2 id="try-it-yourself">Try It Yourself</h2>

<p>The fork with all 26 challenges is at <a href="https://github.com/acherm/mnmlang">github.com/acherm/mnmlang</a>. Each challenge has a <code class="language-plaintext highlighter-rouge">.mnm</code> source and sidecar JSON. Run any of them with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run mnm run challenges/01_sum_1_to_n/sum.mnm
uv run mnm run challenges/26_brainfuck/bf.mnm
</code></pre></div></div>

<p>The cross-validation test suite:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run <span class="nt">--extra</span> dev pytest tests/test_brainfuck.py <span class="nt">-v</span>
</code></pre></div></div>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026mnmlang</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{Can Coding Agents Program in M&amp;Ms Language?}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{mar}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CodingAgentsMnMLang/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/CodingAgentsMnMLang/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="LLM" /><category term="coding agents" /><category term="esoteric languages" /><category term="MNM Lang" /><category term="Brainfuck" /><category term="generative AI" /><category term="llm4code" /><category term="Claude Code" /><summary type="html"><![CDATA[What happens when you challenge a coding agent with an esoteric programming language it has never seen (freshly invented, with very few examples), and where the source code is literally colored M&amp;Ms on a table? No documentation beyond the opcode table. No Stack Overflow threads. No training examples to memorize. Just six candy colors, a stack machine, and a dare. I tried it with MNM Lang, a toy language amazingly created by Mufeed VH where programs are grids of candy-colored tokens (Blue, Green, Red, Yellow, Orange, browN). Token length encodes operand values. Strings and inputs live in a sidecar JSON file. The whole thing compiles to a PNG of M&amp;M sprites. So funny by design, for humans. But could an LLM learn to write it?]]></summary></entry><entry><title type="html">Can Coding Agents Master Printf-Oriented Programming?</title><link href="http://blog.mathieuacher.com/PrintfOrientedProgrammingCodingAgents/" rel="alternate" type="text/html" title="Can Coding Agents Master Printf-Oriented Programming?" /><published>2026-03-05T08:00:00+00:00</published><updated>2026-03-05T08:00:00+00:00</updated><id>http://blog.mathieuacher.com/PrintfOrientedProgrammingCodingAgents</id><content type="html" xml:base="http://blog.mathieuacher.com/PrintfOrientedProgrammingCodingAgents/"><![CDATA[<p>Can coding agents understand and apply a programming paradigm they’ve (almost certainly) never seen in training data? Not a standard design pattern, not something from a textbook, but an esoteric hack from an obfuscated C contest: Printf-Oriented Programming (aka POP). The idea is that printf is Turing complete, but can coding agents leverage that fact to write real programs <em>in</em> <code class="language-plaintext highlighter-rouge">printf</code>?
I tried. The answer is: it’s complicated, fascinating, and ultimately led me somewhere I didn’t expect.</p>

<h2 id="printf-oriented-programming">Printf-Oriented Programming</h2>

<p><a href="https://github.com/carlini/printf-tac-toe">Printf-oriented programming (POP)</a> is a brilliant paradigm invented by Nicholas Carlini for the <a href="https://www.ioccc.org/">IOCCC 2020</a>.
The idea is to encode program behavior inside printf’s format string and arguments. Using specifiers like %hhn to write the number of printed bytes to memory, together with positional parameters and dynamic width/precision modifiers (e.g., %2$d, %3$.<em>4$d), the format string updates state and drives the program’s execution. One <code class="language-plaintext highlighter-rouge">printf</code> invocation becomes a tiny virtual machine.
Carlini demonstrated this with <a href="https://github.com/carlini/printf-tac-toe">printf-tac-toe</a>: a fully playable tic-tac-toe where <code class="language-plaintext highlighter-rouge">printf</code> *is</em> the execution engine, not just the renderer.</p>

<p>Can we go beyond tic-tac-toe? Can we scale POP to <em>chess</em>? Can coding agents master this esoteric paradigm? I gave the challenge to two agents:</p>

<ul>
  <li><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a> (Claude Opus 4.6)</li>
  <li><a href="https://github.com/openai/codex">Codex CLI</a> (GPT-5.2)</li>
</ul>

<h2 id="act-1-the-illusion-of-success">Act 1: The Illusion of Success</h2>

<p>First impressions were exciting. Both agents produced working chess programs with the signature POP loop:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">while</span> <span class="p">(</span><span class="o">*</span><span class="n">d</span><span class="p">)</span> <span class="n">printf</span><span class="p">(</span><span class="n">fmt</span><span class="p">,</span> <span class="n">arg</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Claude Code’s version (~14 KB) chains side-effect calls in the <code class="language-plaintext highlighter-rouge">arg</code> macro. Codex’s version (~31 KB) uses eight <code class="language-plaintext highlighter-rouge">pop_a*</code> functions with <code class="language-plaintext highlighter-rouge">%hhn</code> directives. Both compile, both play chess, both handle legal move validation, check/checkmate/stalemate. I was ready to write a triumphant blog post about how coding agents had conquered POP.</p>

<p>Then I looked more carefully.</p>

<p>The chess logic (move generation, legal filtering, check detection, AI) was implemented in conventional C helper functions (~400 lines of <code class="language-plaintext highlighter-rouge">if</code>, <code class="language-plaintext highlighter-rouge">switch</code>, <code class="language-plaintext highlighter-rouge">for</code>, <code class="language-plaintext highlighter-rouge">while</code>). These helpers run as side effects during argument evaluation, <em>before</em> <code class="language-plaintext highlighter-rouge">printf</code> ever touches the format string. The format string itself mostly just renders pre-computed buffers via <code class="language-plaintext highlighter-rouge">%s</code>. The <code class="language-plaintext highlighter-rouge">while(*d) printf(...)</code> loop is there, sure, but the <em>spirit</em> of POP (having <code class="language-plaintext highlighter-rouge">printf</code> act as the execution engine, performing state transitions through format specifiers) was largely absent.
It’s like claiming you wrote a program in assembly while actually calling a high-level library for everything interesting. The structure is POP-shaped. The substance is C with <code class="language-plaintext highlighter-rouge">printf</code> as a display layer.</p>

<h2 id="act-2-trying-to-force-pop-purity">Act 2: Trying to Force POP Purity</h2>

<p>So I pushed back. I asked the agents to use real POP techniques: <code class="language-plaintext highlighter-rouge">%hhn</code> for state mutation, <code class="language-plaintext highlighter-rouge">%.*s</code> for conditional branching, format-string-driven control flow. I gave detailed feedback, pointed at Carlini’s techniques, explained what was and wasn’t POP-pure.</p>

<p>It didn’t go well.</p>

<p>The agents would acknowledge my feedback, propose elaborate plans, and then… produce code that was still fundamentally C-with-a-printf-wrapper. Sometimes they’d move <em>one</em> thing into the format string (say, loop termination via <code class="language-plaintext highlighter-rouge">%hhn</code>) while keeping everything else in conventional C. Other times they’d spend hours designing complex architectures that simply didn’t compile. Claude Code’s attempt to “print all legal moves using only printf logics” was, in its own way, a spectacular failure.</p>

<p>I started with ad-hoc review criteria, then converged to a formal <strong>POP purity checklist</strong> to make feedback precise and repeatable. Five dimensions, each scored 0-2:</p>

<table>
  <thead>
    <tr>
      <th>Dimension</th>
      <th>What it measures</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Loop purity</strong></td>
      <td>Is the main loop <code class="language-plaintext highlighter-rouge">while (*d) printf(fmt, ARGS)</code> with no other control flow?</td>
    </tr>
    <tr>
      <td><strong>Mutation purity</strong></td>
      <td>Are all state transitions via <code class="language-plaintext highlighter-rouge">%hhn</code> writes?</td>
    </tr>
    <tr>
      <td><strong>Control/branch purity</strong></td>
      <td>Are branches done via <code class="language-plaintext highlighter-rouge">%.*s</code> formatting-time selection?</td>
    </tr>
    <tr>
      <td><strong>Engine purity</strong></td>
      <td>Does <code class="language-plaintext highlighter-rouge">printf</code> act as the execution engine, not just renderer?</td>
    </tr>
    <tr>
      <td><strong>No C-script ROM</strong></td>
      <td>No hardcoded C-side logic or lookup tables?</td>
    </tr>
  </tbody>
</table>

<p>This rubric is an operational audit, not a formal proof of semantic purity.</p>

<p>Score 9-10: POP-pure. Score 6-8: hybrid. Below 6: POP-shaped at best. My manual estimate for both chess implementations? Around 4-5. Hybrid at best, and that’s being generous.</p>

<h2 id="act-3-scaling-down">Act 3: Scaling Down</h2>

<p>Chess was clearly too ambitious for strict POP. I took a step back. What about tic-tac-toe itself, not copying Carlini’s, but having the agent write one from scratch in the POP style?</p>

<p>Still difficult. The agents could produce <em>working</em> tic-tac-toe programs, but achieving POP purity required iteration after iteration. Each version got a little closer: first basic display, then input handling, then playability with comparisons (hybrid mode), and finally (after much steering) a version using only bitwise operations (no comparisons!) to detect wins, draws, and quit. That last version is genuinely impressive: it implements win detection via 8-way AND/OR checks over nibble bitmasks, all without a single <code class="language-plaintext highlighter-rouge">&lt;</code>, <code class="language-plaintext highlighter-rouge">&gt;</code>, or <code class="language-plaintext highlighter-rouge">==</code>. But it took a lot of human guidance to get there.</p>

<p>The checklist evolved along the way. I refined it into a set of <strong>antipatterns and violations</strong>: per-iteration helper calls, C-side semantic operators, host-driven state snapshots, C-side expression evaluation where the format string should do the work. And here’s where it got interesting: the coding agent could <em>operationalize</em> this checklist as an automated scoring script. The same agent that struggled to write POP-pure code could write a tool to <em>evaluate</em> POP purity. Easier to be a critic than an artist, apparently.</p>

<h2 id="act-4-the-compiler-idea">Act 4: The Compiler Idea</h2>

<p>At some point during the back-and-forth, I had a realization: if coding agents can’t reliably <em>intuit</em> how to write POP, maybe they can build a <em>deterministic translator</em>: a compiler from a restricted C subset to POP. Clear rules, no creativity required in the output. You write your logic in a constrained source language, and the compiler mechanically transforms it into <code class="language-plaintext highlighter-rouge">printf</code>-driven code.</p>

<p>Codex ran with this idea and produced <code class="language-plaintext highlighter-rouge">c2pop.py</code>, now roughly a ~2,700-line Python compiler. The input language (called “CPOP subset”) supports a tape (<code class="language-plaintext highlighter-rouge">d[N]</code>), one canonical loop (<code class="language-plaintext highlighter-rouge">while (d[0]) { ... }</code>), assignments, reads, prints, and conditionals. The compiler offers four backends with different tradeoffs (not a strict purity ladder):</p>

<ul>
  <li><strong>simple</strong>: straightforward lowering; often hybrid when expressions are still evaluated in C</li>
  <li><strong>micro</strong>: single-call printf lowering for selected boolean forms (experimental)</li>
  <li><strong>phase</strong>: multi-phase dispatcher, one source statement per phase (explicitly hybrid)</li>
  <li><strong>vm</strong>: VM backend with <code class="language-plaintext highlighter-rouge">%hhn</code>-driven phase state and format-pointer updates; highest-purity path for supported forms</li>
</ul>

<p>Each generated file gets an automatic purity score and a “cheat audit” listing any POP violations. For supported programs, the vm-pure vm backend can report 10/10 POP-pure output, especially when host-snapshot input is avoided.</p>

<p>The tradeoff? The generated POP code is <em>very</em> long. A 20-line CPOP source can produce hundreds of lines of format string gymnastics. In practice, strict generated examples are strongest on tic-tac-toe/snake-scale programs; for chess-scale behavior, what remains POP-pure is closer to a state displayer driven by a POP byte-VM than a full engine: you lose the AI, the legal move generation, all the things that make chess interesting. The scope reduction required for POP purity is dramatic.</p>

<h2 id="a-confession">A Confession</h2>

<p>For the Codex branch documented here, the core iterations happened quickly: from March 3, 2026 to March 5, 2026. Throughout this post, I’m quite demanding about what should count as “real” POP, what’s acceptable and what’s not. But I should be honest: I am <em>not</em> a POP expert. I can spot violations, I can articulate what feels wrong, but I often have no concrete idea how to fix the issues myself. I’m a critic without a solution, relying on coding agents to figure out the “how” while I insist on the “what”. That tension runs through the entire experiment. This is also very much a work in progress, or perhaps a work in “lambo”: I’m reflecting on where I am, where the coding agents are, and what’s still missing. It’s genuinely unfinished.</p>

<h2 id="can-you-do-better">Can You Do Better?</h2>

<p>I’m pretty sure someone can. Either from scratch (without a coding agent, just raw POP expertise and patience) or <em>with</em> a coding agent but with better prompting, better domain knowledge, or a smarter iterative strategy than mine. I would be genuinely interested to know: what level of detail and instructions would it take to realize the vision of a POP-pure chess engine? How much POP expertise does a human need to bring? How much can the agent figure out on its own if guided differently?</p>

<p>I’m expecting someone will do it. If you do, please let me know.</p>

<h2 id="whats-still-open-a-lot">What’s Still Open (A Lot)</h2>

<p>This is ongoing work (really: unfinished work), and many directions remain:</p>

<ul>
  <li><strong>POP skills</strong>: Can we teach coding agents POP as a first-class skill, perhaps through few-shot examples or fine-tuning?</li>
  <li><strong>POP harness</strong>: A systematic test suite for POP compliance, beyond the current checklist</li>
  <li><strong>A better compiler</strong>: <code class="language-plaintext highlighter-rouge">c2pop.py</code> works but the output is verbose; can we optimize the generated format strings?</li>
  <li><strong>Formalizing POP</strong>: What <em>is</em> POP, precisely? What’s the grammar of valid POP programs? What subset of computation can POP express efficiently? There’s a real PL theory question hiding here.</li>
  <li><strong>POP for other games</strong>: Scaling beyond tic-tac-toe toward more complex programs while maintaining purity</li>
  <li><strong>Other sources of information</strong>: There is a Brainfuck interpreter implemented in POP https://github.com/HexHive/printbf. Maybe a coding agent could learn POP by studying that code, or by learning Brainfuck first and then translating to POP.</li>
</ul>

<p>Repositories:</p>
<ul>
  <li>Claude Code version: <a href="https://github.com/acherm/printf-chess">https://github.com/acherm/printf-chess</a></li>
  <li>Codex version (with compiler, benchmarks, examples): <a href="https://github.com/acherm/printf-chess-codex">https://github.com/acherm/printf-chess-codex</a></li>
</ul>

<h2 id="why-pop-matters-as-a-benchmark">Why POP Matters as a Benchmark</h2>

<p>Beyond the fun of it, I think POP is a <em>fantastic</em> benchmark for coding agents, and here is one of the central messages of this post.</p>

<p>Most coding benchmarks test whether an agent can produce correct, working code. POP tests something deeper: can the agent understand a <em>non-trivial programming paradigm</em> from a single example, grasp its underlying principles (not just its surface structure), and creatively apply them to new problems?</p>

<p>The results are humbling. Coding agents in 2026 can <a href="https://blog.mathieuacher.com/FromScratchChessEnginesPolyglot/">build chess engines from scratch in 12 languages</a>, including <a href="https://blog.mathieuacher.com/TeXCCChessEngine/">LaTeX</a>, COBOL, and Brainfuck. But when asked to encode logic <em>inside a format string</em>, they default to what they know: conventional C with a POP-shaped wrapper. They can mimic the structure without grasping the essence. Getting from “it looks like POP” to “it <em>is</em> POP” required extensive human guidance, a formalized checklist, and ultimately a compiler to do the translation mechanically.</p>

<p>That gap between surface imitation and deep understanding is exactly what makes POP a useful probe. It tests creativity, adaptability, and mastery of programming languages and compilers in a “strange” zone where standard training data won’t help much. And the fact that the journey led to automated scoring tools, a formal purity rubric, and a C-to-POP compiler is, in its own way, a success story. Just not the one I expected to write.</p>

<p>As I said: this is unfinished. I don’t know yet whether a POP-pure chess engine is achievable with current coding agents, or whether it requires a fundamentally different approach. But the exploration itself has been worth it, and I’ll keep pushing. Hopefully someone else will too.</p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026printfchess</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{Can Coding Agents Master Printf-Oriented Programming?}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{mar}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/PrintfOrientedProgrammingCodingAgents/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/PrintfOrientedProgrammingCodingAgents/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="LLM" /><category term="coding agents" /><category term="C" /><category term="printf" /><category term="software engineering" /><category term="generative AI" /><category term="llm4code" /><category term="Claude Code" /><category term="Codex" /><category term="chess" /><category term="chess engine" /><category term="compilers" /><summary type="html"><![CDATA[Can coding agents understand and apply a programming paradigm they’ve (almost certainly) never seen in training data? Not a standard design pattern, not something from a textbook, but an esoteric hack from an obfuscated C contest: Printf-Oriented Programming (aka POP). The idea is that printf is Turing complete, but can coding agents leverage that fact to write real programs in printf? I tried. The answer is: it’s complicated, fascinating, and ultimately led me somewhere I didn’t expect.]]></summary></entry><entry><title type="html">TeXCCChess: How Coding Agents Wrote a Chess Engine in Pure TeX</title><link href="http://blog.mathieuacher.com/TeXCCChessEngine/" rel="alternate" type="text/html" title="TeXCCChess: How Coding Agents Wrote a Chess Engine in Pure TeX" /><published>2026-02-24T09:00:00+00:00</published><updated>2026-02-24T09:00:00+00:00</updated><id>http://blog.mathieuacher.com/TeXCCChessEngine</id><content type="html" xml:base="http://blog.mathieuacher.com/TeXCCChessEngine/"><![CDATA[<p>What happens when you ask a 2026 coding agent like Claude Code to build a chess engine from scratch (with no plan, no architecture document, no step-by-step guidance) in a language that was never designed for this purpose? Building a chess engine is a non-trivial software engineering challenge: it involves board representation, move generation with dozens of special rules (castling, en passant, promotion), recursive tree search with pruning, evaluation heuristics, as well as a way to assess engine correctness and performance, including Elo rating. Doing it from scratch, with minimal human guidance, is a serious test of what coding agents can do today. Doing it in LaTeX’s macro language, which has no arrays, no functions with return values, no convenient local variables or stack frames, and no built-in support for complex data structures or algorithms? More than that, as far as I can tell, it has never been done before (I could not find any existing TeX chess engine on CTAN, GitHub, or TeX.SE). Yet, the coding agent built a functional chess engine in pure TeX that runs on <code class="language-plaintext highlighter-rouge">pdflatex</code> and reaches around 1280 Elo (the level of a casual tournament player). This post dives deep into how this engine, called TeXCCChess, works, the TeX-specific challenges encountered during development. You can play against it in <a href="https://www.overleaf.com/docs?snip_uri=https%3A%2F%2Fgithub.com%2Facherm%2Fagentic-chessengine-latex-TeXCCChess%2Freleases%2Fdownload%2Fv1.0%2FTeXCCChess.zip&amp;engine=pdflatex&amp;main_document=chess-game.tex">Overleaf</a> (see demo https://youtu.be/ngHMozcyfeY) or your local TeX installation https://youtu.be/Tg4r_bu0ANY, while the source code is available on GitHub https://github.com/acherm/agentic-chessengine-latex-TeXCCChess/</p>

<h2 id="motivation">Motivation</h2>

<p>Among all the engines produced in my experiment of <a href="https://blog.mathieuacher.com/FromScratchChessEnginesPolyglot/">asking coding agents to build chess engines from scratch across 12 programming languages</a>, TeXCCChess is perhaps the most surprising and delightful. It is a chess engine written entirely in TeX (the macro language behind LaTeX) and it runs on <code class="language-plaintext highlighter-rouge">pdflatex</code>. 
Yes, LaTeX is usually used for writing reports or  scientific papers, not at all for writing chess engines. Between, why TeX?
The honest answer: because it shouldn’t work and has never been done before. TeX has no arrays, no functions with return values, no convenient local variables or stack frames, no integers bigger than 2^{31}-1, no bitwise operations. Macro expansion can recurse, but you get no call stack and deep recursion quickly hits engine limits. What TeX does have is a Turing-complete macro expansion engine and, with e-TeX extensions (used by modern pdfTeX), up to 32,768 integer registers called <code class="language-plaintext highlighter-rouge">\count</code>. That turns out to be <em>just barely enough</em> to implement a chess engine.
I could not find any prior chess engine in TeX (I searched CTAN, GitHub, and TeX Stack Exchange). There exist LaTeX packages for <em>rendering</em> chess positions and games (the excellent <code class="language-plaintext highlighter-rouge">chessboard</code> and <code class="language-plaintext highlighter-rouge">xskak</code> packages), but nothing that actually <em>plays chess</em>. TeXCCChess appears to be the first.</p>

<p>If this is indeed the first full TeX chess engine, it is very unlikely the model memorized one verbatim. That makes TeXCCChess a useful counterpoint to the plausible critique that “coding agents just regurgitate training data”. Of course, the model may have seen discussions about chess programming in TeX, or small macro-expansion tricks, but a full working engine is a different artifact. (We will see in other posts that for engines in more mainstream languages, the pure memorization hypothesis is also questionable given the diversity of architectures and features, but here it is even more so).</p>

<p>TeXCCChess was built by <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, Anthropic’s agentic coding tool, powered by Claude Opus 4.6. The “CC” in the name stands for Claude Code (and if you squint, an allusion to the Chaos Computer Club). A second variant was independently built by <a href="https://github.com/openai/codex">Codex CLI</a> (powered by GPT-5.2), but it is out of the scope of this post (I will cover it in a future post, along with engines in other languages).</p>

<p>The challenge I gave Claude Code was deliberately vague: <em>I want to build a chess engine in LaTeX… at the end, I want to test this chess engine and assess its Elo rating, typically by playing games against chess engines of “similar” levels.</em> I did not specify the architecture, the search algorithm, or the data structures. The agent made all those decisions itself, discovered the TeX pitfalls the hard way, and iteratively debugged its own code across multiple sessions.</p>

<h2 id="the-architecture-at-a-glance">The Architecture at a Glance</h2>

<p>The engine is ~2,100 lines of pure TeX code in a single file (<code class="language-plaintext highlighter-rouge">chess-engine.tex</code>). Everything happens during <code class="language-plaintext highlighter-rouge">pdflatex</code> compilation. Here is the high-level architecture:</p>

<ul>
  <li><strong>Board representation:</strong> 64 TeX <code class="language-plaintext highlighter-rouge">\count</code> registers (<code class="language-plaintext highlighter-rouge">\count200</code> through <code class="language-plaintext highlighter-rouge">\count263</code>)</li>
  <li><strong>Piece encoding:</strong> integers from -6 to +6 (positive = white, negative = black, 0 = empty)</li>
  <li><strong>Move generation:</strong> pseudo-legal generation + legality filtering via make/unmake</li>
  <li><strong>Search:</strong> depth-3 <a href="https://chessprogramming.org/Negamax">negamax</a> with <a href="https://chessprogramming.org/Alpha-Beta">alpha-beta pruning</a> + <a href="https://chessprogramming.org/Quiescence_Search">quiescence search</a></li>
  <li><strong>Evaluation:</strong> material counting + <a href="https://chessprogramming.org/Piece-Square_Tables">piece-square tables</a> (<a href="https://chessprogramming.org/Simplified_Evaluation_Function">Simplified Eval Function</a>)</li>
  <li><strong>UCI support:</strong> a thin Python wrapper bridges <code class="language-plaintext highlighter-rouge">pdflatex</code> to the <a href="https://chessprogramming.org/UCI">UCI</a> protocol</li>
</ul>

<p>What emerges from this design is something resembling a tiny virtual machine built on top of TeX’s macro expansion engine. The <code class="language-plaintext highlighter-rouge">\count</code> registers serve as RAM (with dedicated address ranges for the board (200-263), scratch computation (188-194), and the search call stack (10000+)). The <code class="language-plaintext highlighter-rouge">\csname</code> lookup tables act as a read-only ROM for precomputed data (file/rank mappings, piece-square tables, material values). Token lists (<code class="language-plaintext highlighter-rouge">\movelist</code>, <code class="language-plaintext highlighter-rouge">\legalmovelist</code>) serve as dynamically allocated buffers. Macros like <code class="language-plaintext highlighter-rouge">\makemove</code>/<code class="language-plaintext highlighter-rouge">\unmakemove</code> and <code class="language-plaintext highlighter-rouge">\pushstate</code>/<code class="language-plaintext highlighter-rouge">\popstate</code> are the instruction set. TeX’s <code class="language-plaintext highlighter-rouge">\ifnum</code> and <code class="language-plaintext highlighter-rouge">\loop</code> primitives provide the control flow. The whole thing is a register machine with no stack frames, no heap, and no garbage collector (just flat integer registers and name-based indirection). pdflatex is, in effect, the CPU executing this VM.</p>

<p>The following diagram (generated by Codex during a code exploration session) shows how the components of this tiny VM connect (from the TeX macros through memory registers to the UCI bridge):</p>

<p><img src="/assets/texcc-architecture.png" alt="TeXCCChess architecture: from TeX macros through memory registers to UCI bridge" />
(<a href="/assets/texcc-architecture.mmd">Mermaid source</a>)</p>

<p>And here is the full game flow (from the moment you call <code class="language-plaintext highlighter-rouge">\playmove{e2e4}</code> to the engine’s response). It shows move parsing, pseudo-legal generation with piece-specific dispatchers, legality filtering via make/unmake + attack detection, and the search tree (depth-3 negamax cascading through searchB, searchC, and quiescence):</p>

<p><img src="/assets/texcc-gameflow.png" alt="TeXCCChess game flow: from playmove through move generation, legality filtering, and depth-3 search" />
(<a href="/assets/texcc-gameflow.mmd">Mermaid source</a>)</p>

<h2 id="board-representation-64-count-registers">Board Representation: 64 Count Registers</h2>

<p>In most languages, you’d use an array. In TeX, you use <code class="language-plaintext highlighter-rouge">\count</code> registers. The board occupies registers 200 through 263, one per square:</p>

<div class="language-tex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">% Square index: (rank-1)*8 + file, where file a=1..h=8, rank 1..8</span>
<span class="c">% So a1=1, b1=2, ..., h1=8, a2=9, ..., h8=64</span>
<span class="c">% Stored in \count registers 200..263</span>

<span class="k">\def\setsq</span>#1#2<span class="p">{</span><span class="k">\global\count\numexpr</span>199+#1<span class="k">\relax</span>=#2<span class="k">\relax</span><span class="p">}</span>
<span class="k">\def\getsq</span>#1<span class="p">{</span><span class="k">\the\count\numexpr</span>199+#1<span class="k">\relax</span><span class="p">}</span>
</code></pre></div></div>

<p>Pieces are encoded as signed integers:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Value</th>
      <th>Piece</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">1</td>
      <td>White Pawn</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td>White Knight</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td>White Bishop</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td>White Rook</td>
    </tr>
    <tr>
      <td style="text-align: right">5</td>
      <td>White Queen</td>
    </tr>
    <tr>
      <td style="text-align: right">6</td>
      <td>White King</td>
    </tr>
    <tr>
      <td style="text-align: right">-1..-6</td>
      <td>Black equivalents</td>
    </tr>
    <tr>
      <td style="text-align: right">0</td>
      <td>Empty square</td>
    </tr>
  </tbody>
</table>

<p>Setting up the starting position is just a series of register assignments:</p>

<div class="language-tex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">\def\initboard</span><span class="p">{</span><span class="c">%</span>
  <span class="c">% Clear all squares</span>
  <span class="k">\count</span>190=1<span class="k">\relax</span>
  <span class="k">\loop\ifnum\count</span>190&lt;65
    <span class="k">\setsq</span><span class="p">{</span><span class="k">\the\count</span>190<span class="p">}{</span>0<span class="p">}</span><span class="c">%</span>
    <span class="k">\advance\count</span>190 by 1
  <span class="k">\repeat</span>
  <span class="c">% White pieces (rank 1)</span>
  <span class="k">\setsq</span><span class="p">{</span>1<span class="p">}{</span>4<span class="p">}</span><span class="c">%  a1 = wR</span>
  <span class="k">\setsq</span><span class="p">{</span>2<span class="p">}{</span>2<span class="p">}</span><span class="c">%  b1 = wN</span>
  <span class="k">\setsq</span><span class="p">{</span>3<span class="p">}{</span>3<span class="p">}</span><span class="c">%  c1 = wB</span>
  <span class="k">\setsq</span><span class="p">{</span>4<span class="p">}{</span>5<span class="p">}</span><span class="c">%  d1 = wQ</span>
  <span class="k">\setsq</span><span class="p">{</span>5<span class="p">}{</span>6<span class="p">}</span><span class="c">%  e1 = wK</span>
  ...
<span class="p">}</span>
</code></pre></div></div>

<p>A critical design decision is how to extract the file and rank from a square index. In most languages, you’d write <code class="language-plaintext highlighter-rouge">file = (sq - 1) % 8 + 1</code> and <code class="language-plaintext highlighter-rouge">rank = (sq - 1) / 8 + 1</code>. But TeX’s <code class="language-plaintext highlighter-rouge">\numexpr</code> division rounds instead of truncating: <code class="language-plaintext highlighter-rouge">63/8</code> gives 8 (rounding 7.875 up), not 7. This was the source of one of the first nasty bugs. The fix: precompute lookup tables at load time using <code class="language-plaintext highlighter-rouge">\divide</code> (which truncates) and store results in <code class="language-plaintext highlighter-rouge">\csname</code> control sequences:</p>

<div class="language-tex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">\count</span>190=1<span class="k">\relax</span>
<span class="k">\loop\ifnum\count</span>190&lt;65
  <span class="k">\count</span>191=<span class="k">\count</span>190<span class="k">\relax</span> <span class="k">\advance\count</span>191 by -1<span class="k">\relax</span> <span class="c">% 0-based index</span>
  <span class="k">\count</span>192=<span class="k">\count</span>191<span class="k">\relax</span> <span class="k">\divide\count</span>192 by 8<span class="k">\relax</span>   <span class="c">% rank 0-based (TRUNCATING)</span>
  ...
  <span class="k">\advance\count</span>192 by 1<span class="k">\relax</span> <span class="c">% rank 1-based (1..8)</span>
  <span class="k">\advance\count</span>194 by 1<span class="k">\relax</span> <span class="c">% file 1-based (1..8)</span>
  <span class="k">\expandafter\edef\csname</span> sq@file@<span class="k">\the\count</span>190<span class="k">\endcsname</span><span class="p">{</span><span class="k">\the\count</span>194<span class="p">}</span><span class="c">%</span>
  <span class="k">\expandafter\edef\csname</span> sq@rank@<span class="k">\the\count</span>190<span class="k">\endcsname</span><span class="p">{</span><span class="k">\the\count</span>192<span class="p">}</span><span class="c">%</span>
  <span class="k">\advance\count</span>190 by 1<span class="k">\relax</span>
<span class="k">\repeat</span>
</code></pre></div></div>

<p>This pattern (precompute into <code class="language-plaintext highlighter-rouge">\csname</code> tables at load time, look up by name expansion at runtime) is used throughout the engine. It is the TeX equivalent of a hash map.</p>

<h2 id="move-generation">Move Generation</h2>

<p>Move generation follows the standard pseudo-legal approach: generate all possible moves, then filter out those that leave the king in check.</p>

<p>Each piece type has its own generator macro. For sliding pieces (bishop, rook, queen), ray-casting is done via recursive macro expansion along each direction, stopping when hitting a piece or the edge of the board. For knights, all 8 L-shaped jumps are checked with boundary validation. Pawns have the most complex logic: single pushes, double pushes from the starting rank, diagonal captures, en passant, and promotion.</p>

<p>Moves are stored in a TeX token list as <code class="language-plaintext highlighter-rouge">\moveentry{from}{to}{flags}</code>, where flags encode special moves:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Flag</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">0</td>
      <td>Normal move</td>
    </tr>
    <tr>
      <td style="text-align: right">1</td>
      <td>En passant</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td>Castling</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td>Promotion (always to queen)</td>
    </tr>
  </tbody>
</table>

<p>Legal move filtering works by making each pseudo-legal move on the board, checking if the king is attacked, then unmaking the move. Castling gets special treatment: three squares must be checked (the king’s origin, transit square, and destination) since the king cannot castle out of, through, or into check:</p>

<div class="language-tex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">\def\fl</span>@testcastling#1#2#3<span class="p">{</span><span class="c">%</span>
  <span class="c">% King must not be in check now</span>
  <span class="k">\findkingsquare</span><span class="p">{</span><span class="k">\the\sidetomove</span><span class="p">}</span><span class="c">%</span>
  <span class="k">\issquareattacked</span><span class="p">{</span><span class="k">\the\kingsquare</span><span class="p">}{</span><span class="k">\the\fl</span>@oppcolor<span class="p">}</span><span class="c">%</span>
  <span class="k">\ifnum\attackresult</span>=1 <span class="k">\fl</span>@legal=0<span class="k">\relax</span> <span class="k">\fi</span>
  <span class="k">\ifnum\fl</span>@legal=1
    <span class="c">% Transit square must not be attacked</span>
    <span class="k">\ifnum</span>#2&gt;#1 <span class="k">\fl</span>@transit=<span class="k">\numexpr</span>#1+1<span class="k">\relax</span>
    <span class="k">\else</span> <span class="k">\fl</span>@transit=<span class="k">\numexpr</span>#1-1<span class="k">\relax</span> <span class="k">\fi</span>
    <span class="k">\issquareattacked</span><span class="p">{</span><span class="k">\the\fl</span>@transit<span class="p">}{</span><span class="k">\the\fl</span>@oppcolor<span class="p">}</span><span class="c">%</span>
    <span class="k">\ifnum\attackresult</span>=1 <span class="k">\fl</span>@legal=0<span class="k">\relax</span> <span class="k">\fi</span>
  <span class="k">\fi</span>
  ...
<span class="p">}</span>
</code></pre></div></div>

<h2 id="the-state-stack-problem">The State Stack Problem</h2>

<p>A chess engine needs to make a move, search deeper, then unmake the move. In a normal language, local variables or a call stack handle this naturally. TeX has grouping-based scoping, but the engine’s design uses global state throughout (every <code class="language-plaintext highlighter-rouge">\count</code> register, every <code class="language-plaintext highlighter-rouge">\newcount</code> variable, every <code class="language-plaintext highlighter-rouge">\newif</code> flag is global).</p>

<p>The solution: a hand-rolled state stack using high-numbered <code class="language-plaintext highlighter-rouge">\count</code> registers (starting at <code class="language-plaintext highlighter-rouge">\count10000</code>) to avoid collisions with the <code class="language-plaintext highlighter-rouge">\newcount</code> allocator. Each search depth gets 9 register slots to save and restore the full game state:</p>

<div class="language-tex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">% Depth 0: \count10000-\count10008</span>
<span class="c">% Depth 1: \count10009-\count10017</span>
<span class="c">% Depth 2: \count10018-\count10026</span>
<span class="c">% Depth 3: \count10027-\count10035</span>

<span class="k">\def\pushstate</span>#1<span class="p">{</span><span class="c">%</span>
  <span class="k">\count\numexpr</span>10000+9*#1+0<span class="k">\relax</span>=<span class="k">\save</span>@captured<span class="k">\relax</span>
  <span class="k">\count\numexpr</span>10000+9*#1+1<span class="k">\relax</span>=<span class="k">\save</span>@castleWK<span class="k">\relax</span>
  <span class="k">\count\numexpr</span>10000+9*#1+2<span class="k">\relax</span>=<span class="k">\save</span>@castleWQ<span class="k">\relax</span>
  ...
<span class="p">}</span>
</code></pre></div></div>

<p>The ordering is critical and was a source of subtle bugs: <code class="language-plaintext highlighter-rouge">\pushstate</code> must be called <em>after</em> <code class="language-plaintext highlighter-rouge">\makemove</code> (which populates the <code class="language-plaintext highlighter-rouge">\save@*</code> registers) but <em>before</em> <code class="language-plaintext highlighter-rouge">\updategamestate</code> (which modifies them). Reverse for unmake: <code class="language-plaintext highlighter-rouge">\popstate</code> first, then <code class="language-plaintext highlighter-rouge">\unmakemove</code>. Getting this wrong produces moves that look almost right but occasionally corrupt castling rights or en passant state (the kind of bug that shows up once every 50 games).</p>

<h2 id="search-depth-3-negamax-with-alpha-beta">Search: Depth-3 Negamax with Alpha-Beta</h2>

<p>TeX can recurse via macro expansion, but there is no call stack, no local variables, and deep recursion risks hitting engine limits. So the search uses three explicit loop levels, one for each depth:</p>

<ul>
  <li><strong>loopA</strong> (depth 0): iterates over our candidate moves</li>
  <li><strong>loopB</strong> (depth 1): iterates over the opponent’s replies</li>
  <li><strong>loopC</strong> (depth 2): iterates over our counter-replies, capped at 12 moves for speed</li>
</ul>

<p>Each depth level stores its moves in separate indexed <code class="language-plaintext highlighter-rouge">\csname</code> slots (<code class="language-plaintext highlighter-rouge">mm@0@mf@N</code>, <code class="language-plaintext highlighter-rouge">mm@1@mf@N</code>, <code class="language-plaintext highlighter-rouge">mm@2@mf@N</code>) because the inner search clobbers the global move-tracking variables. After returning from an inner search, the current move must be re-read from its csname storage:</p>

<div class="language-tex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">\def\mm</span>@loopA<span class="p">{</span><span class="c">%</span>
  <span class="k">\ifnum\mm</span>@idx&gt;<span class="k">\mm</span>@total<span class="k">\else</span>
    <span class="k">\mm</span>@curfrom=<span class="k">\csname</span> mm@0@mf@<span class="k">\the\mm</span>@idx<span class="k">\endcsname\relax</span>
    ...
    <span class="k">\mm</span>@searchB  <span class="c">% inner search clobbers mm@curfrom etc.</span>
    <span class="k">\mm</span>@score=-<span class="k">\mm</span>@retval<span class="k">\relax</span>
    <span class="c">% Re-read move (inner search clobbers curfrom/curto/curflags)</span>
    <span class="k">\mm</span>@curfrom=<span class="k">\csname</span> mm@0@mf@<span class="k">\the\mm</span>@idx<span class="k">\endcsname\relax</span>
    <span class="k">\mm</span>@curto=<span class="k">\csname</span> mm@0@mt@<span class="k">\the\mm</span>@idx<span class="k">\endcsname\relax</span>
    ...
<span class="p">}</span>
</code></pre></div></div>

<p>Alpha-beta pruning is implemented using <code class="language-plaintext highlighter-rouge">\ifnum</code> comparisons. The <code class="language-plaintext highlighter-rouge">&gt;=</code> operator (for cutoff: <code class="language-plaintext highlighter-rouge">alpha &gt;= beta</code>) requires a TeX idiom since there is no <code class="language-plaintext highlighter-rouge">\ifge</code> primitive:</p>

<div class="language-tex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">\ifnum\mm</span>@alphaB&lt;<span class="k">\mm</span>@betaB<span class="k">\relax\else</span>
  <span class="k">\mm</span>@cutoffBtrue   <span class="c">% alpha &gt;= beta, prune</span>
<span class="k">\fi</span>
</code></pre></div></div>

<p>Each depth level has its own cutoff flag (<code class="language-plaintext highlighter-rouge">\ifmm@cutoffB</code>, <code class="language-plaintext highlighter-rouge">\ifmm@cutoffC</code>, <code class="language-plaintext highlighter-rouge">\ifmm@qcutoff</code>), which are TeX booleans declared with <code class="language-plaintext highlighter-rouge">\newif</code> at the top level (declaring them inside a macro causes a “already defined” error on the second call).</p>

<h3 id="move-ordering">Move Ordering</h3>

<p>Good alpha-beta depends on examining the best moves first. The engine uses a 4-pass <a href="https://chessprogramming.org/MVV-LVA">MVV-LVA</a> (Most Valuable Victim - Least Valuable Attacker) ordering:</p>

<ol>
  <li>Promotions + captures of queens/rooks</li>
  <li>Captures of bishops/knights</li>
  <li>Captures of pawns + en passant</li>
  <li>Quiet moves</li>
</ol>

<p>This is implemented by iterating the legal move list four times, each time extracting moves matching the current priority level. At depth C (the deepest full-width level), the move list is capped at 12 (since captures are sorted first, all captures are always searched even under the cap).</p>

<h3 id="quiescence-search">Quiescence Search</h3>

<p>At the leaves of the depth-3 search, a quiescence search extends the analysis for captures only. This prevents the <a href="https://chessprogramming.org/Horizon_Effect">horizon effect</a> (e.g., stopping evaluation right after we hang a piece but before the opponent recaptures):</p>

<div class="language-tex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">\def\mm</span>@quiesce<span class="p">{</span><span class="c">%</span>
  <span class="k">\evaluate</span>
  <span class="k">\mm</span>@standpat=<span class="k">\evalscore\relax</span>
  <span class="k">\ifnum\mm</span>@standpat&lt;<span class="k">\mm</span>@qbeta<span class="k">\relax</span>
    <span class="k">\ifnum\mm</span>@standpat&gt;<span class="k">\mm</span>@qalpha<span class="k">\relax</span>
      <span class="k">\mm</span>@qalpha=<span class="k">\mm</span>@standpat<span class="k">\relax</span>
    <span class="k">\fi</span>
    <span class="k">\generatelegal</span>
    <span class="k">\mm</span>@copycapturesonly
    ...
  <span class="k">\else</span>
    <span class="k">\global\mm</span>@retval=<span class="k">\mm</span>@qbeta<span class="k">\relax</span>  <span class="c">% stand-pat cutoff</span>
  <span class="k">\fi</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="evaluation">Evaluation</h2>

<p>The evaluation function scans all 64 squares and sums material values plus piece-square table (PST) bonuses. The PSTs follow the well-known <a href="https://chessprogramming.org/Simplified_Evaluation_Function">Simplified Evaluation Function</a> from the <a href="https://chessprogramming.org/">Chess Programming Wiki</a>: pawns are rewarded for advancing toward the center, knights prefer central squares, kings prefer the corners in the middlegame, etc.</p>

<div class="language-tex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">\expandafter\def\csname</span> piecemat@1<span class="k">\endcsname</span><span class="p">{</span>100<span class="p">}</span><span class="c">%  pawn</span>
<span class="k">\expandafter\def\csname</span> piecemat@2<span class="k">\endcsname</span><span class="p">{</span>320<span class="p">}</span><span class="c">%  knight</span>
<span class="k">\expandafter\def\csname</span> piecemat@3<span class="k">\endcsname</span><span class="p">{</span>330<span class="p">}</span><span class="c">%  bishop</span>
<span class="k">\expandafter\def\csname</span> piecemat@4<span class="k">\endcsname</span><span class="p">{</span>500<span class="p">}</span><span class="c">%  rook</span>
<span class="k">\expandafter\def\csname</span> piecemat@5<span class="k">\endcsname</span><span class="p">{</span>900<span class="p">}</span><span class="c">%  queen</span>
<span class="k">\expandafter\def\csname</span> piecemat@6<span class="k">\endcsname</span><span class="p">{</span>20000<span class="p">}</span><span class="c">% king</span>
</code></pre></div></div>

<p>For black pieces, the PST is accessed via a mirror table (<code class="language-plaintext highlighter-rouge">sq@mirror@N</code>) that flips the board vertically, so the same white-perspective tables work for both sides.</p>

<p>The evaluation is computed from scratch on every call (no incremental updates). This is slow, but incrementality would require tracking piece-list data structures that are painful to manage in TeX’s global-state model.</p>

<h2 id="tex-specific-pitfalls">TeX-Specific Pitfalls</h2>

<p>Building a chess engine in TeX surfaced a collection of language-specific traps that Claude Code had to discover and work around:</p>

<p><code class="language-plaintext highlighter-rouge">\numexpr</code> division rounds, it does not truncate. <code class="language-plaintext highlighter-rouge">\numexpr 63/8\relax</code> gives 8, not 7. This broke coordinate extraction (file/rank from square index) until the fix: use <code class="language-plaintext highlighter-rouge">\divide</code> with a register (which truncates), or precompute into lookup tables.</p>

<p>Digits in a normal control sequence name are not part of the name. <code class="language-plaintext highlighter-rouge">\ray@t1</code> is parsed as <code class="language-plaintext highlighter-rouge">\ray@t</code> followed by the digit <code class="language-plaintext highlighter-rouge">1</code>, not a single control sequence <code class="language-plaintext highlighter-rouge">\ray@t1</code>. (You <em>can</em> include digits via <code class="language-plaintext highlighter-rouge">\csname ray@t1\endcsname</code>, but <code class="language-plaintext highlighter-rouge">\def</code> doesn’t work that way.) Solution: use letters instead of digits (<code class="language-plaintext highlighter-rouge">\ray@ta</code>, <code class="language-plaintext highlighter-rouge">\ray@tb</code>).</p>

<p>The <code class="language-plaintext highlighter-rouge">@</code> character in macro names requires <code class="language-plaintext highlighter-rouge">\makeatletter</code>. All internal macros use <code class="language-plaintext highlighter-rouge">@</code> in their names (e.g., <code class="language-plaintext highlighter-rouge">\eval@sq</code>, <code class="language-plaintext highlighter-rouge">\mm@alpha</code>). Without <code class="language-plaintext highlighter-rouge">\makeatletter</code>, TeX treats <code class="language-plaintext highlighter-rouge">@</code> as a regular character and the macros silently fail.</p>

<p><code class="language-plaintext highlighter-rouge">\newif</code> inside macros fails on the second call. Since <code class="language-plaintext highlighter-rouge">\newif</code> creates a global flag, calling it again raises “already defined”. All boolean flags must be declared once at the global level.</p>

<p>Register collision is the TeX equivalent of variable shadowing bugs. Since everything is global, a macro that uses <code class="language-plaintext highlighter-rouge">\count190</code> as a loop counter will silently corrupt any outer macro also using <code class="language-plaintext highlighter-rouge">\count190</code>. The engine reserves specific register ranges for specific purposes: 200-263 for the board, 188-194 for scratch math, 10000+ for the search state stack.</p>

<h2 id="playing-against-texccchess">Playing Against TeXCCChess</h2>

<p>The source code, instructions, and all tooling are available on GitHub: <a href="https://github.com/acherm/agentic-chessengine-latex-TeXCCChess"><strong>acherm/agentic-chessengine-latex-TeXCCChess</strong></a>. You can play interactively by editing a <code class="language-plaintext highlighter-rouge">.tex</code> file and recompiling with <code class="language-plaintext highlighter-rouge">pdflatex</code> (works locally and on <a href="https://www.overleaf.com/docs?snip_uri=https%3A%2F%2Fgithub.com%2Facherm%2Fagentic-chessengine-latex-TeXCCChess%2Freleases%2Fdownload%2Fv1.0%2FTeXCCChess.zip&amp;engine=pdflatex&amp;main_document=chess-game.tex">Overleaf</a>), or run automated Elo tournaments against Stockfish via the UCI wrapper. The README covers everything: setup, interactive play, UCI mode, and tournament scripts.</p>

<p>Here is a demo of an interactive game against TeXCCChess in Overleaf (note: depth &gt; 0 may take too much time for Overleaf’s limits, so the engine is set to depth default=0 for this demo):</p>

<iframe width="560" height="315" src="https://www.youtube.com/embed/ngHMozcyfeY" frameborder="0" allowfullscreen=""></iframe>

<p>And here is a demo of an interactive game against TeXCCChess in local (here the engine is set to depth=3 negamax + quiescence):</p>

<iframe width="560" height="315" src="https://www.youtube.com/embed/Tg4r_bu0ANY" frameborder="0" allowfullscreen=""></iframe>

<h2 id="strength-and-elo">Strength and Elo</h2>

<p>Estimated strength: ~1280 Elo (95% CI: ~1225–1345, measured against Stockfish at 120+1 time control, CCRL 40/4 scale).</p>

<p>All matches used Stockfish with <code class="language-plaintext highlighter-rouge">UCI_LimitStrength=true</code> at the 120+1s time control, which is the time control Stockfish’s <code class="language-plaintext highlighter-rouge">UCI_Elo</code> scale was calibrated against (anchored to <a href="https://www.computerchess.org.uk/ccrl/404/">CCRL 40/4</a>). Test conditions: no opening book, single-threaded, on an Apple M3 Max. All game records (PGN) are available on <a href="https://github.com/acherm/agentic-chessengine-latex-TeXCCChess">GitHub</a>.</p>

<h3 id="evaluation-details">Evaluation details</h3>

<p>A 100-game baseline against Stockfish set to 1320 Elo gave 45 wins, 7 draws, 48 losses (48.5% score, estimated Elo ~1310). An extended evaluation (3 x 50 games against Stockfish at 1320, 1400, and 1500 Elo) produced a combined estimate of ~1260 Elo. A small number of games ended due to UCI protocol issues (illegal moves); after removing those, the estimate stabilizes at ~1285 Elo (95% CI: ~1225–1345).</p>

<p>Overall, the engine performs around 1280–1300 Elo at 120+1. For reference, this is roughly the level of a casual tournament player who has studied some openings and does not hang pieces in obvious ways. The engine loses convincingly to Stockfish above 1500.</p>

<p>The main limitations are:</p>
<ul>
  <li>Search depth: capped at 3 plies + quiescence. Deeper search would require either much faster execution (highly challenging in TeX) or a time budget of minutes per move.</li>
  <li>No opening book: the engine starts from scratch every game.</li>
  <li>Simplified evaluation: material + PSTs only; no pawn structure, king safety, or mobility terms.</li>
  <li>Always promotes to queen: no under-promotion.</li>
</ul>

<h2 id="the-development-process-5-sessions-10-days">The Development Process: 5 Sessions, 10 Days</h2>

<p>The entire development of TeXCCChess is traceable through 5 Claude Code sessions spanning 10 days (Feb 13-23, 2026). It started with a single sentence: <em>“I want to implement a chess engine in LaTeX… and have the ability to play with this engine.”</em> Here is what happened, session by session.</p>

<h3 id="session-1-the-plan">Session 1: The Plan</h3>

<p>Claude Code entered plan mode and designed a 17-step implementation across 4 files. No code was written yet, just the architecture: <code class="language-plaintext highlighter-rouge">\count</code> registers for the board, signed integers for pieces, token lists for moves, a test suite to validate correctness. 19 API calls.</p>

<h3 id="session-2-building-the-random-move-engine">Session 2: Building the Random-Move Engine</h3>

<p>The agent implemented the full plan in one session: board representation, move generation for all piece types (including castling, en passant, promotion), legal move filtering, attack detection, game-end detection, and a random move picker. 122 API calls.</p>

<p>The first compilation of the test suite: 3 PASS, 6 FAIL. The culprit: <code class="language-plaintext highlighter-rouge">sqfile(64)</code> returned 0 instead of 8. The <code class="language-plaintext highlighter-rouge">\numexpr</code> rounding bug (<code class="language-plaintext highlighter-rouge">63/8</code> gives 8 in TeX, not 7) had silently corrupted every file/rank extraction. The agent rewrote coordinate math using precomputed lookup tables. After the fix: all 23 tests pass.</p>

<p>A notable design decision: the agent chose hash-based RNG seeding for Overleaf compatibility. Rather than writing state to auxiliary files (which Overleaf doesn’t support well), the engine hashes all prior user moves to create a deterministic seed. This means recompiling the same <code class="language-plaintext highlighter-rouge">.tex</code> file always produces the same game.</p>

<p>I confirmed: <em>“working fine!”</em> on Overleaf after testing.</p>

<h3 id="session-3-uci--elo-infrastructure">Session 3: UCI &amp; Elo Infrastructure</h3>

<p>To measure Elo, the engine needed to speak the UCI protocol. The agent created <code class="language-plaintext highlighter-rouge">chess-uci.py</code> (the Python wrapper), <code class="language-plaintext highlighter-rouge">run-elo-test.sh</code> (tournament orchestration), and <code class="language-plaintext highlighter-rouge">pgn2latex.py</code> (PGN-to-LaTeX converter for game visualization). 140 API calls.</p>

<p>A tricky bug: <code class="language-plaintext highlighter-rouge">cutechess-cli</code> outputs Standard Algebraic Notation (SAN: <code class="language-plaintext highlighter-rouge">Nf3</code>, <code class="language-plaintext highlighter-rouge">O-O</code>, <code class="language-plaintext highlighter-rouge">exd5</code>) but the PGN parser only handled coordinate notation (<code class="language-plaintext highlighter-rouge">g1f3</code>). The agent had to build a full SAN resolver (disambiguating moves by file or rank, handling castling variants (<code class="language-plaintext highlighter-rouge">O-O</code> vs <code class="language-plaintext highlighter-rouge">0-0</code>), stripping check/mate annotations).</p>

<h3 id="session-4-depth-2-minimax">Session 4: Depth-2 Minimax</h3>

<p>The random-move engine was replaced by a depth-2 minimax with material evaluation. 157 API calls.</p>

<p>This session exposed the most insidious bug of the project: register collision. LaTeX’s <code class="language-plaintext highlighter-rouge">\newcount</code> allocator had already reached register 360+, so the state stack at <code class="language-plaintext highlighter-rouge">\count300-317</code> overlapped with LaTeX-internal registers. The symptom was subtle: the minimax search appeared to work but produced wrong moves. The agent traced it to a corrupted board value (square 17 showing value 8 instead of the expected piece), identified the collision, and moved the entire state stack to <code class="language-plaintext highlighter-rouge">\count10000+</code>.</p>

<p>First Elo test: 10 games vs Stockfish 800, result: 1 win, 0 draws, 9 losses (~500-600 Elo). The agent noted: <em>“depth-2 only takes 0.16 seconds, there’s a massive time budget remaining.”</em></p>

<h3 id="session-5-the-big-push-psts-alpha-beta-depth-3-quiescence">Session 5: The Big Push (PSTs, Alpha-Beta, Depth-3, Quiescence)</h3>

<p>This was the largest and most dramatic session: 2,650 lines of conversation, 212 API calls, 19 edits to <code class="language-plaintext highlighter-rouge">chess-engine.tex</code>, 8 test suite runs, 5 tournament attempts.</p>

<p>The session hit two API errors early on: <em>“Claude’s response exceeded the 32000 output token maximum”</em> (the agent tried to write the entire PST + search implementation in one go). Recovery: <em>“I’ll break this into smaller steps.”</em></p>

<p>The Elo progression within this single session tells the story:</p>

<table>
  <thead>
    <tr>
      <th>Stage</th>
      <th>Result vs Stockfish 800</th>
      <th>Est. Elo</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Initial depth-3 (no cap)</td>
      <td>Timeout on move 3, games crash</td>
      <td>—</td>
    </tr>
    <tr>
      <td>After alpha-beta cutoff fix (<code class="language-plaintext highlighter-rouge">&gt;=</code> instead of <code class="language-plaintext highlighter-rouge">&gt;</code>)</td>
      <td>Still too slow</td>
      <td>—</td>
    </tr>
    <tr>
      <td>Depth-2 + quiescence only</td>
      <td>0W/2D/8L</td>
      <td>~420</td>
    </tr>
    <tr>
      <td>Depth-3 + AB + quiescence, capped at 12 moves</td>
      <td><strong>5W/1D/4L</strong></td>
      <td><strong>~835</strong></td>
    </tr>
  </tbody>
</table>

<p>The initial depth-3 implementation was a disaster: move 3 took 30 seconds in complex positions, hitting the UCI timeout. Games ended with <em>“illegal move: 0000”</em> (the timeout fallback). The agent spent over 30 turns debugging (fixing alpha-beta cutoffs (the TeX idiom for <code class="language-plaintext highlighter-rouge">&gt;=</code> is <code class="language-plaintext highlighter-rouge">\ifnum X &lt; Y \relax\else</code>, since there is no <code class="language-plaintext highlighter-rouge">\ifge</code> primitive), then pragmatically capping depth-C at 12 moves). Since MVV-LVA sorts captures first, all tactical moves are always searched even under the cap. Timing dropped to 0.5-3.5s per move.</p>

<p>Then a measurement bug: the agent set <code class="language-plaintext highlighter-rouge">UCI_Elo: 800</code> for Stockfish, but recent Stockfish versions enforce a minimum <code class="language-plaintext highlighter-rouge">UCI_Elo</code> around 1300-1350 (the exact floor depends on version and build) and silently clamp values below it. The “5.5/10 vs Stockfish 800” result was actually against a much stronger opponent. After correcting the configuration (verified by inspecting Stockfish’s <code class="language-plaintext highlighter-rouge">uci</code> option output), a preliminary 100-game tournament vs Stockfish 1320 confirmed the engine was in the ~1280 Elo range. A more rigorous evaluation using the Stockfish UCI_Elo calibration time control (120+1s) is reported in the <a href="#strength-and-elo">Strength and Elo</a> section above.</p>

<h3 id="effort-summary">Effort Summary</h3>

<table>
  <thead>
    <tr>
      <th>Session</th>
      <th>Goal</th>
      <th>API Calls</th>
      <th>% of total cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Architecture plan</td>
      <td>19</td>
      <td>2%</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Random-move engine</td>
      <td>122</td>
      <td>24%</td>
    </tr>
    <tr>
      <td>3</td>
      <td>UCI + Elo infra</td>
      <td>140</td>
      <td>22%</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Depth-2 minimax</td>
      <td>157</td>
      <td>14%</td>
    </tr>
    <tr>
      <td>5</td>
      <td>PST + AB + D3 + quiescence</td>
      <td>212</td>
      <td>38%</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td> </td>
      <td><strong>~650</strong></td>
      <td><strong>100%</strong></td>
    </tr>
  </tbody>
</table>

<p>In total: 5 sessions, ~650 API calls, ~53 pdflatex compilations, ~22 test suite runs. The engine grew from 0 to 1,342 lines (random moves) to 2,093 lines (depth-3 + quiescence), and from ~300 Elo to ~1280 Elo. Session 5 (the “big push” to depth-3 with quiescence) consumed the most resources (38% of the total), reflecting the difficulty of getting search, pruning, and move ordering right in TeX. The initial implementation (session 2) was the second largest at 24%, which makes sense: building a full rule-compliant chess engine from scratch is the foundational effort.</p>

<h3 id="elo-progression">Elo Progression</h3>

<table>
  <thead>
    <tr>
      <th>Milestone</th>
      <th>Elo</th>
      <th>Lines of TeX</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Random move picker</td>
      <td>~300</td>
      <td>1,342</td>
    </tr>
    <tr>
      <td>Depth-2 minimax + material eval</td>
      <td>~550</td>
      <td>~1,500</td>
    </tr>
    <tr>
      <td>Depth-3 + alpha-beta + quiescence + PSTs</td>
      <td><strong>~1280</strong></td>
      <td>2,093</td>
    </tr>
  </tbody>
</table>

<p>~1000 Elo gained from better search and evaluation (a massive improvement from pure TeX macro optimization, all discovered and implemented by the coding agent).</p>

<h2 id="what-makes-texccchess-remarkable">What Makes TeXCCChess Remarkable</h2>

<ol>
  <li>It exists. As far as I can tell, there is no prior chess engine in TeX. A coding agent synthesized one from scratch, with no known example to draw from.</li>
  <li>It plays legal chess (with high confidence). Castling, en passant, promotion, check detection, stalemate, and the 50-move rule are implemented and validated with a 23-case test suite (though not all rules have exhaustive coverage). Threefold repetition and insufficient-material draws are not yet implemented. Beyond unit tests, the engine completed hundreds of tournament games against Stockfish; in rare occasions, an illegal move or protocol error occurred, though it is unclear whether these stem from the TeX engine logic or from the UCI wrapper. This gives reasonable confidence that the core move generation and rule handling are robust, if not formally verified.</li>
  <li>It thinks. Three plies of lookahead with alpha-beta pruning and quiescence search, using only TeX macro expansion and integer registers.</li>
  <li>It is entirely self-contained. The core engine is a single <code class="language-plaintext highlighter-rouge">.tex</code> file. No shell-escape, no LuaTeX, no external computation.</li>
  <li>It produces a typeset PDF. You play by editing a <code class="language-plaintext highlighter-rouge">.tex</code> file and compiling with <code class="language-plaintext highlighter-rouge">pdflatex</code>. The output is a beautifully typeset chess document (because, after all, it’s still TeX running inside the LaTeX ecosystem).</li>
  <li>It is neither a translation nor a copy of an existing chess engine. There is no existing TeX chess engine to copy from, and you cannot mechanically translate a C or Java engine into TeX (no arrays, no conventional recursion with a call stack, no convenient local variables). The underlying <em>algorithms</em> are well-known (alpha-beta, quiescence, MVV-LVA, PSTs); if there is a “translation”, it happens at this abstraction level. The creativity lies in finding TeX-native ways to encode them: register-based state stacks, csname lookup tables, explicit loop unrolling for search depth.</li>
</ol>

<p>The complete source code and Elo assessment infrastructure are available at <a href="https://github.com/acherm/agentic-chessengine-latex-TeXCCChess/">github.com/acherm/agentic-chessengine-latex-TeXCCChess</a>.</p>

<p><em>This is part of a <a href="https://blog.mathieuacher.com/FromScratchChessEnginesPolyglot/">series on AI-generated chess engines across programming languages</a>. The next post will cover another engine from the polyglot experiment. Stay tuned.</em></p>

<p><em>Feel free to contact me: mathieu.acher@irisa.fr</em></p>

<p><em>(new!) I realized that some of my blog post entries are sometimes cited in academic works, so why not using the following bibtex entry?</em></p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">acher2026texccchess</span><span class="p">,</span>
  <span class="na">author</span> <span class="p">=</span> <span class="s">{Mathieu Acher}</span><span class="p">,</span>
  <span class="na">title</span> <span class="p">=</span> <span class="s">{TeXCCChess: How Coding Agents Wrote a Chess Engine in Pure TeX}</span><span class="p">,</span>
  <span class="na">year</span> <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span> <span class="p">=</span> <span class="s">{feb}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/TeXCCChessEngine/}}</span><span class="p">,</span>
  <span class="na">note</span> <span class="p">=</span> <span class="s">{\url{https://blog.mathieuacher.com/TeXCCChessEngine/}}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="chess" /><category term="chess engine" /><category term="LLM" /><category term="coding agents" /><category term="LaTeX" /><category term="TeX" /><category term="pdfLaTeX" /><category term="Elo" /><category term="software engineering" /><category term="generative AI" /><category term="llm4code" /><category term="Claude Code" /><summary type="html"><![CDATA[What happens when you ask a 2026 coding agent like Claude Code to build a chess engine from scratch (with no plan, no architecture document, no step-by-step guidance) in a language that was never designed for this purpose? Building a chess engine is a non-trivial software engineering challenge: it involves board representation, move generation with dozens of special rules (castling, en passant, promotion), recursive tree search with pruning, evaluation heuristics, as well as a way to assess engine correctness and performance, including Elo rating. Doing it from scratch, with minimal human guidance, is a serious test of what coding agents can do today. Doing it in LaTeX’s macro language, which has no arrays, no functions with return values, no convenient local variables or stack frames, and no built-in support for complex data structures or algorithms? More than that, as far as I can tell, it has never been done before (I could not find any existing TeX chess engine on CTAN, GitHub, or TeX.SE). Yet, the coding agent built a functional chess engine in pure TeX that runs on pdflatex and reaches around 1280 Elo (the level of a casual tournament player). This post dives deep into how this engine, called TeXCCChess, works, the TeX-specific challenges encountered during development. You can play against it in Overleaf (see demo https://youtu.be/ngHMozcyfeY) or your local TeX installation https://youtu.be/Tg4r_bu0ANY, while the source code is available on GitHub https://github.com/acherm/agentic-chessengine-latex-TeXCCChess/]]></summary></entry></feed>