<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lucian (LKB)</title>
    <description>The latest articles on DEV Community by Lucian (LKB) (@lucian_lkb_1f009d).</description>
    <link>https://dev.to/lucian_lkb_1f009d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4026097%2Feb9247a0-cd26-41b9-9613-ddcb120a9d66.jpg</url>
      <title>DEV Community: Lucian (LKB)</title>
      <link>https://dev.to/lucian_lkb_1f009d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lucian_lkb_1f009d"/>
    <language>en</language>
    <item>
      <title>I built 12 chaos &amp; physics simulations that run entirely in the browser (no backend, no libraries)</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Sat, 22 Aug 2026 12:05:13 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/i-built-12-chaos-physics-simulations-that-run-entirely-in-the-browser-no-backend-no-libraries-1750</link>
      <guid>https://dev.to/lucian_lkb_1f009d/i-built-12-chaos-physics-simulations-that-run-entirely-in-the-browser-no-backend-no-libraries-1750</guid>
      <description>&lt;p&gt;Over the last few weeks I built a small rack of physics simulations — 12 of them — that all run client-side in a single HTML file each. No backend, no WebGL frameworks, no build step you can't read. Just &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;, &lt;code&gt;requestAnimationFrame&lt;/code&gt;, and the actual equations.&lt;/p&gt;

&lt;p&gt;Here's the whole rack if you want to click around first: &lt;strong&gt;&lt;a href="https://lkforge.com/tools/physics/" rel="noopener noreferrer"&gt;lkforge.com/tools/physics&lt;/a&gt;&lt;/strong&gt;. Below I'll walk through the two I'm happiest with — reaction-diffusion and the Lorenz attractor — because they show two very different flavors of "simple local rule → surprising global behavior."&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Reaction-diffusion: Turing patterns from two numbers per cell
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://lkforge.com/tools/physics/reaction-diffusion/" rel="noopener noreferrer"&gt;reaction-diffusion lab&lt;/a&gt; runs the &lt;strong&gt;Gray-Scott model&lt;/strong&gt;: two virtual chemicals &lt;code&gt;U&lt;/code&gt; and &lt;code&gt;V&lt;/code&gt; sit on a grid, diffuse at different rates, and react via &lt;code&gt;U + 2V → 3V&lt;/code&gt;. &lt;code&gt;U&lt;/code&gt; is fed in; &lt;code&gt;V&lt;/code&gt; is killed off. That's the entire model, and it reproduces spots, stripes, mazes, dividing cells and coral — the same short-range-activation / long-range-inhibition idea Alan Turing proposed in 1952 to explain animal coat patterns.&lt;/p&gt;

&lt;p&gt;The core update, on a toroidal grid with a 9-point Laplacian:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;uu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;vv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;lapU&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
           &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;NW&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;NE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;SW&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;SE&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;uu&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;lapV&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
           &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;NW&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;NE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;SW&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;SE&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;vv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;uvv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;uu&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;vv&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;vv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;un&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;uu&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Du&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;lapU&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;uvv&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;uu&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// U: diffuse, react away, feed&lt;/span&gt;
&lt;span class="nx"&gt;vn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vv&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Dv&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;lapV&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;uvv&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;vv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// V: diffuse, react in, kill&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The feed/kill pair &lt;code&gt;(f, k)&lt;/code&gt; is everything.&lt;/strong&gt; Small changes switch the whole regime — coral at &lt;code&gt;(0.0545, 0.062)&lt;/code&gt;, mitosis near &lt;code&gt;(0.046, 0.063)&lt;/code&gt;, spots at &lt;code&gt;(0.030, 0.062)&lt;/code&gt;. I ship these as presets so people don't land on a dead grid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seed with a &lt;em&gt;noisy patch&lt;/em&gt;, not a solid disk.&lt;/strong&gt; A solid blob tends to bloom once and then die back to uniform grey. Random speckle lets the pattern nucleate across an area and reach a stable steady state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rendering is a &lt;code&gt;createImageData&lt;/code&gt; grid mapped through a small color lookup table, drawn to an offscreen canvas and scaled up with &lt;code&gt;drawImage&lt;/code&gt; — cheap enough to run a 200×200 grid at 60fps with ~10 solver steps per frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Lorenz attractor: determinism without predictability
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://lkforge.com/tools/physics/lorenz-attractor/" rel="noopener noreferrer"&gt;Lorenz lab&lt;/a&gt; integrates the 1963 Lorenz equations with fourth-order Runge-Kutta and draws the trajectory as a rotating, fading 3-D trail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;lorenzDeriv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;SIG&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rho&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;BETA&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fun part is the &lt;strong&gt;butterfly effect made visible&lt;/strong&gt;: start a second point &lt;code&gt;1e-3&lt;/code&gt; away from the first, integrate both under the identical rule, and show the separation climbing. The two paths track together, then peel apart onto opposite wings of the attractor — neither ever leaving the shape, neither ever repeating. That's the whole point Edward Lorenz made when a rounded weather-model input (0.506127 → 0.506) sent his forecast somewhere unrecognizable.&lt;/p&gt;

&lt;p&gt;A couple of implementation notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RK4, not Euler.&lt;/strong&gt; Euler visibly distorts the attractor as the path stretches and folds; RK4 with a small &lt;code&gt;dt&lt;/code&gt; keeps it honest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orthographic projection + one yaw/pitch rotation&lt;/strong&gt; is enough — no full 3-D pipeline. Drag updates yaw/pitch; an auto-spin adds a constant yaw increment per frame.&lt;/li&gt;
&lt;li&gt;The same engine also renders Rössler and Aizawa attractors by swapping the derivative function.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why single-file, client-side?
&lt;/h2&gt;

&lt;p&gt;Three reasons that turned out to matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Longevity.&lt;/strong&gt; No server means nothing to keep alive; these will still run in five years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddable.&lt;/strong&gt; Because each sim mounts on one canvas id and wires to controls by id, the exact same script drives the tool page and an &lt;code&gt;/embed/&lt;/code&gt; widget — drop it into a blog post with one iframe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readable.&lt;/strong&gt; Anyone can view-source and see the actual physics, which is kind of the point for teaching material.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to poke at the rest — falling sand, a double pendulum, the double-slit experiment, charged particles in a magnetic field, Conway's Game of Life — they're all here: &lt;strong&gt;&lt;a href="https://lkforge.com/tools/physics/" rel="noopener noreferrer"&gt;lkforge.com/tools/physics&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of the models in the comments.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>canvas</category>
      <category>webdev</category>
      <category>science</category>
    </item>
    <item>
      <title>Watch a Game AI Think: Minimax and Alpha-Beta, in a Browser Tab</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Wed, 19 Aug 2026 20:42:51 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/watch-a-game-ai-think-minimax-and-alpha-beta-in-a-browser-tab-3onj</link>
      <guid>https://dev.to/lucian_lkb_1f009d/watch-a-game-ai-think-minimax-and-alpha-beta-in-a-browser-tab-3onj</guid>
      <description>&lt;p&gt;Every "AI" opponent in a board game — Tic-Tac-Toe, Connect 4, Checkers, Othello, Chess — tends to run the same idea: search the game tree, assume the opponent plays their best, and pick the move with the best guaranteed outcome. That idea is &lt;strong&gt;minimax&lt;/strong&gt;, and &lt;strong&gt;alpha-beta pruning&lt;/strong&gt; is what makes it fast enough to run in a browser tab with no backend.&lt;/p&gt;

&lt;p&gt;I built an interactive version where you can step through minimax on a real board and toggle alpha-beta on to watch it skip work: &lt;strong&gt;&lt;a href="https://lkforge.com/blog/minimax-alpha-beta-explained/" rel="noopener noreferrer"&gt;play with it here&lt;/a&gt;&lt;/strong&gt;. This post is the written companion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimax in one function
&lt;/h2&gt;

&lt;p&gt;Score a finished position from the AI's point of view: &lt;code&gt;+1&lt;/code&gt; if the AI wins, &lt;code&gt;-1&lt;/code&gt; if you win, &lt;code&gt;0&lt;/code&gt; for a draw. Then walk the tree of possible futures. On the AI's turn it takes the &lt;strong&gt;max&lt;/strong&gt; of its options; on your turn it assumes you take the &lt;strong&gt;min&lt;/strong&gt; (the worst outcome for the AI). That alternation is the whole algorithm.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;minimax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isMax&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;terminal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;isMax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;inf&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;child&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moves&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;AI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s turn
      best = max(best, minimax(child, false))
    return best
  else:
    best = +inf
    for child in node.moves:    # your turn
      best = min(best, minimax(child, true))
    return best
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Alpha-beta: same answer, fewer nodes
&lt;/h2&gt;

&lt;p&gt;Searching every branch is wasteful. Once you've found a reply that already refutes a move, you don't need to look at that move's other branches — they can't change the decision. Two running bounds carry that knowledge down the tree: &lt;code&gt;alpha&lt;/code&gt; (the best MAX can already guarantee) and &lt;code&gt;beta&lt;/code&gt; (the best MIN can already guarantee). When they cross, you stop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isMax&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;terminal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;isMax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;inf&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;child&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moves&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;   &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;prune&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;inf&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;child&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moves&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="nx"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;   &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;prune&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pruning never changes the value at the root — only how many nodes you touch to find it. On a small Tic-Tac-Toe position with three empty squares, the full tree is 14 nodes and alpha-beta visits 10 of them. On the full-depth opening move it's dramatic: &lt;strong&gt;549,945 nodes drop to 36,528 — a 93% cut&lt;/strong&gt; — which is what lets a provably-unbeatable Tic-Tac-Toe move resolve in about 0.3 ms client-side. (&lt;a href="https://lkforge.com/blog/benchmarking-game-ai/" rel="noopener noreferrer"&gt;The measured benchmarks are here.&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just search deeper" gets expensive
&lt;/h2&gt;

&lt;p&gt;A search that looks &lt;em&gt;d&lt;/em&gt; moves ahead visits roughly &lt;code&gt;b^d&lt;/code&gt; nodes, where &lt;em&gt;b&lt;/em&gt; is the branching factor — how many moves you typically have. That number explodes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tic-Tac-Toe: b ≈ 4&lt;/li&gt;
&lt;li&gt;Connect 4: b ≈ 4 (max 7 columns)&lt;/li&gt;
&lt;li&gt;Checkers: b ≈ 2.8&lt;/li&gt;
&lt;li&gt;Othello: b ≈ 10&lt;/li&gt;
&lt;li&gt;Chess: b ≈ 35&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Looking just 8 moves ahead in chess is on the order of &lt;strong&gt;35^8 ≈ 2.3 trillion positions&lt;/strong&gt;. Alpha-beta — plus move ordering, transposition tables, quiescence and friends — is how a search reaches useful depth without visiting all of them. (Branching factors are approximate published averages, à la Allis 1994, for illustration.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The same idea, five different games
&lt;/h2&gt;

&lt;p&gt;Every opponent is this algorithm with a different board, a different way of scoring a position, and different tricks to search deeper without searching everything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Game&lt;/th&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;Branching (approx.)&lt;/th&gt;
&lt;th&gt;Search tricks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tic-Tac-Toe&lt;/td&gt;
&lt;td&gt;3×3&lt;/td&gt;
&lt;td&gt;≤ 9 (~4)&lt;/td&gt;
&lt;td&gt;Minimax + alpha-beta, full depth on 3×3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect 4&lt;/td&gt;
&lt;td&gt;7×6&lt;/td&gt;
&lt;td&gt;≤ 7 (~4)&lt;/td&gt;
&lt;td&gt;Bitboard negamax + alpha-beta + transposition table + iterative deepening&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkers&lt;/td&gt;
&lt;td&gt;8×8&lt;/td&gt;
&lt;td&gt;~2.8&lt;/td&gt;
&lt;td&gt;Iterative-deepening negamax + alpha-beta + capture quiescence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Othello&lt;/td&gt;
&lt;td&gt;8×8&lt;/td&gt;
&lt;td&gt;~10&lt;/td&gt;
&lt;td&gt;Iterative-deepening negamax + alpha-beta + exact endgame solve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chess&lt;/td&gt;
&lt;td&gt;8×8&lt;/td&gt;
&lt;td&gt;~35&lt;/td&gt;
&lt;td&gt;Negamax + alpha-beta + null-move + quiescence + check extensions + move ordering&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Read the real code
&lt;/h2&gt;

&lt;p&gt;The pseudocode above is the shape. Here's a real, unminified engine that runs one of these opponents in the browser — iterative-deepening negamax with alpha-beta pruning and capture-aware quiescence, about 230 lines of vanilla JS: &lt;strong&gt;&lt;a href="https://gist.github.com/lucian-devops/c9bf7f1f5d3c84a14e767ea67a277fc2" rel="noopener noreferrer"&gt;the checkers engine on GitHub Gist&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Everything runs client-side, zero dependencies. If you'd rather &lt;em&gt;watch&lt;/em&gt; the tree animate and prune than read about it, the interactive version is here: &lt;strong&gt;&lt;a href="https://lkforge.com/blog/minimax-alpha-beta-explained/" rel="noopener noreferrer"&gt;Watch a Game AI Think →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What is one ply of search actually worth? I benchmarked 4 game engines</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:32:00 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/what-is-one-ply-of-search-actually-worth-i-benchmarked-4-game-engines-15gk</link>
      <guid>https://dev.to/lucian_lkb_1f009d/what-is-one-ply-of-search-actually-worth-i-benchmarked-4-game-engines-15gk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Syndicated from the original on &lt;strong&gt;&lt;a href="https://lkforge.com/blog/search-depth-scaling/" rel="noopener noreferrer"&gt;lkforge.com&lt;/a&gt;&lt;/strong&gt;. The engines are live in your browser at &lt;a href="https://lkforge.com/games/" rel="noopener noreferrer"&gt;lkforge.com/games&lt;/a&gt;; the full harness is &lt;a href="https://gist.github.com/lucian-devops/e852f11a92669201e124ffff378ab3d7" rel="noopener noreferrer"&gt;in this gist&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My browser board-game opponents — Connect 4, Checkers, Othello and Chess — all think the same way: &lt;strong&gt;negamax with alpha-beta pruning&lt;/strong&gt;, looking a fixed number of moves ahead. The single knob that makes them stronger is search depth. So how much is one more ply actually worth? I ran &lt;strong&gt;2,400 headless self-play games&lt;/strong&gt; to find out, and the honest answer is: &lt;em&gt;it depends enormously on which ply.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The counterintuitive result
&lt;/h2&gt;

&lt;p&gt;I expected a tidy law — "each ply is worth some steady fraction of the last." I got nothing of the sort.&lt;/p&gt;

&lt;p&gt;The very &lt;strong&gt;first&lt;/strong&gt; extra ply is worth a fortune — up to &lt;strong&gt;+953 Elo&lt;/strong&gt; in Connect 4 — because a depth-1 engine barely beats random. After that the gains fall off a cliff &lt;em&gt;and stop behaving&lt;/em&gt;: each further ply adds a small, game-specific, &lt;strong&gt;non-monotonic&lt;/strong&gt; amount. There is no single clean curve.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Depths tested&lt;/th&gt;
&lt;th&gt;First ply (1→2)&lt;/th&gt;
&lt;th&gt;Each later ply&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connect 4&lt;/td&gt;
&lt;td&gt;1–7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+953&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+9 to +55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Othello&lt;/td&gt;
&lt;td&gt;1–6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+610&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+107 to +229&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkers&lt;/td&gt;
&lt;td&gt;1–7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+546&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+49 to +100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chess&lt;/td&gt;
&lt;td&gt;1–4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+321&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+225 to +315&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In 3 of the 4 games, the first ply is the single biggest gain — by a mile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why depth, not milliseconds
&lt;/h2&gt;

&lt;p&gt;The live game gives its AI a time budget so the page stays responsive. But a time budget is the wrong unit for a benchmark: "half a second of thinking" buys a fast laptop far more nodes than a phone, so a strength number tied to milliseconds says more about the reader's hardware than the engine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fixed search depth&lt;/strong&gt; is the hardware-independent knob. Pin the depth and the same seed produces the same games on any machine — which is the whole point of publishing a number someone else can check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the first ply does all the work
&lt;/h2&gt;

&lt;p&gt;A depth-1 search only avoids one-move blunders and otherwise plays close to random. The jump to depth 2 turns a coin-flip opponent into a real one — so that ply captures most of the available strength in one step. After that, each additional ply is chasing rarer and rarer mistakes while costing exponentially more nodes, and how much it finds depends entirely on the game.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chess is the exception that proves the point.&lt;/strong&gt; Its first two plies are almost equal (&lt;strong&gt;+321&lt;/strong&gt; then &lt;strong&gt;+315&lt;/strong&gt;) rather than front-loaded, because its branching factor is so large that even a depth-2 search is still missing a great deal — there's plenty left for the next ply to find.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connect 4 is the opposite extreme:&lt;/strong&gt; a small, near-solved game where, once past the blunder-avoiding first ply, extra depth barely moves the result (+9 to +55) until the search can see a whole forced line. Its later plies also &lt;strong&gt;zig-zag&lt;/strong&gt; — a real parity effect, because whether the search horizon ends on your move or the opponent's changes what a shallow search sees, so odd and even depths behave differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Each engine's shipped &lt;code&gt;search()&lt;/code&gt; is deterministic, so every game starts from a &lt;strong&gt;random opening&lt;/strong&gt; (a handful of random legal moves), then the two depth-limited engines play it out.&lt;/li&gt;
&lt;li&gt;Every opening is played &lt;strong&gt;twice with the sides swapped&lt;/strong&gt;, so any imbalance in the random start cancels exactly. Draws score half a point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;120 colour-balanced games per adjacent depth pair.&lt;/strong&gt; Depth — not milliseconds — is the knob, so numbers are hardware-independent and reproduce from a seed (mulberry32, master seed 20260819).&lt;/li&gt;
&lt;li&gt;Elo per step = &lt;code&gt;400 · log10(p / (1 − p))&lt;/code&gt; from the colour-balanced score &lt;code&gt;p&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(Draw rates run high in Checkers and Chess, so read those Elo gaps as approximate. The figures are internal — each engine against weaker copies of itself, not against human ranks or a program like Stockfish.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The contrast with MCTS
&lt;/h2&gt;

&lt;p&gt;The companion study benchmarks my Go engine, which uses &lt;strong&gt;Monte Carlo Tree Search&lt;/strong&gt; instead of negamax. There, more search bought a smooth, &lt;em&gt;even growing&lt;/em&gt; return — each doubling of playouts added more Elo than the last, no diminishing returns out to 1,600 playouts.&lt;/p&gt;

&lt;p&gt;Depth-limited negamax has no such curve: it's &lt;strong&gt;front-loaded and game-specific&lt;/strong&gt;. Same goal — pick the strongest move — two search paradigms, two completely different shapes of return on compute. That's the fun of measuring instead of assuming.&lt;/p&gt;




&lt;p&gt;Full harness, four vendored engine snapshots, and reproduce steps: &lt;strong&gt;&lt;a href="https://gist.github.com/lucian-devops/e852f11a92669201e124ffff378ab3d7" rel="noopener noreferrer"&gt;gist&lt;/a&gt;&lt;/strong&gt; · original writeup with charts: &lt;strong&gt;&lt;a href="https://lkforge.com/blog/search-depth-scaling/" rel="noopener noreferrer"&gt;lkforge.com/blog/search-depth-scaling&lt;/a&gt;&lt;/strong&gt; · the Go/MCTS companion: &lt;strong&gt;&lt;a href="https://lkforge.com/blog/go-mcts-scaling/" rel="noopener noreferrer"&gt;Does thinking twice as long make a Go AI twice as good?&lt;/a&gt;&lt;/strong&gt; · play the engines: &lt;strong&gt;&lt;a href="https://lkforge.com/games/" rel="noopener noreferrer"&gt;lkforge.com/games&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>ai</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The World Clock Time-Zone Landscape: what 162 places reveal about time zones</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Sun, 16 Aug 2026 18:21:25 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/the-world-clock-time-zone-landscape-what-162-places-reveal-about-time-zones-1a4m</link>
      <guid>https://dev.to/lucian_lkb_1f009d/the-world-clock-time-zone-landscape-what-162-places-reveal-about-time-zones-1a4m</guid>
      <description>&lt;p&gt;Time zones look like a tidy grid of whole hours. They aren't. I read the standard UTC offset of all 162 cities, countries and regions on our &lt;a href="https://lkforge.com/tools/worldclock/" rel="noopener noreferrer"&gt;World Clock&lt;/a&gt; straight from the IANA database (via &lt;code&gt;Intl&lt;/code&gt;) — and the real shape is lumpy, with quarter-hour outliers and a near-even split over whether clocks move at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The quirk, in one line:&lt;/strong&gt; Kathmandu keeps its clocks 5 hours 45 minutes ahead of UTC — the only &lt;code&gt;:45&lt;/code&gt; offset on the board, and one of 11 places out of 162 that don't sit on a whole hour. Nearly half the rest never move their clocks at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The clocks that don't sit on the hour
&lt;/h2&gt;

&lt;p&gt;Most of the world rounds to a whole hour from UTC. A handful don't:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offset&lt;/th&gt;
&lt;th&gt;Places&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UTC+3:30&lt;/td&gt;
&lt;td&gt;Tehran (Iran)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC+4:30&lt;/td&gt;
&lt;td&gt;Kabul (Afghanistan)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC+5:30&lt;/td&gt;
&lt;td&gt;India — New Delhi, Mumbai, Kolkata, Bengaluru, Hyderabad&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC+5:45&lt;/td&gt;
&lt;td&gt;Kathmandu (Nepal)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC+9:30&lt;/td&gt;
&lt;td&gt;Adelaide, Darwin (Australia)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Half-hour and quarter-hour offsets are a reminder that a time zone is a political decision, not an astronomical one — which is exactly why date code should read the IANA database rather than dividing longitude by 15.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nearly half never change their clocks
&lt;/h2&gt;

&lt;p&gt;Daylight saving feels universal if you live in North America or Europe, but it isn't. Of the 162 places tracked, &lt;strong&gt;87 (54%) shift their clocks and 75 (46%) never do&lt;/strong&gt;. The whole of East Asia, the Gulf, most of Africa, India and much of South America keep one fixed offset year-round — Tokyo, Singapore, Dubai, Nairobi and New Delhi never spring forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the clocks crowd together
&lt;/h2&gt;

&lt;p&gt;Offsets aren't evenly populated. Four of them carry nearly half the board:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offset&lt;/th&gt;
&lt;th&gt;Places&lt;/th&gt;
&lt;th&gt;Who's there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UTC−5&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;US Eastern — New York, Toronto, Miami, Boston&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC+1&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;Central Europe — Paris, Berlin, Rome, Madrid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC−6&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;US Central — Chicago, Dallas, Mexico City&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC+2&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Eastern Europe &amp;amp; Africa — Athens, Cairo, Johannesburg&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full set spans &lt;strong&gt;22 hours&lt;/strong&gt;, from Honolulu at UTC−10 to New Zealand and Fiji at UTC+12.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Every number here is printed by one dependency-free Node script that reads each place's standard offset from the IANA database via &lt;code&gt;Intl&lt;/code&gt; for January and July 2025 (a place "observes DST" when the two differ):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ &lt;a href="https://gist.github.com/lucian-devops/1ca081dea1c52f13c04d5d1a55903acb" rel="noopener noreferrer"&gt;Run it yourself (public gist)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All figures are scoped to the 162 places the clock tracks, not every zone on Earth (globally there are other quarter-hour oddities, like the Chatham Islands at +12:45).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lkforge.com/blog/world-clock-time-zone-landscape/" rel="noopener noreferrer"&gt;lkforge.com&lt;/a&gt;, where the offset distribution is an interactive chart. Built alongside the free &lt;a href="https://lkforge.com/tools/worldclock/" rel="noopener noreferrer"&gt;LK Forge World Clock&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Is Friday the 13th really more common? I counted 400 years to find out</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Sun, 16 Aug 2026 18:20:40 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/is-friday-the-13th-really-more-common-i-counted-400-years-to-find-out-4ph6</link>
      <guid>https://dev.to/lucian_lkb_1f009d/is-friday-the-13th-really-more-common-i-counted-400-years-to-find-out-4ph6</guid>
      <description>&lt;p&gt;The superstition claims Friday the 13th is unlucky. It turns out it's also, very slightly, the &lt;em&gt;most likely&lt;/em&gt; — the 13th lands on a Friday more often than on any other weekday. I counted every month in a full 400-year Gregorian cycle (plain &lt;code&gt;Date&lt;/code&gt; UTC math) to see how big that edge really is.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The edge, in one line:&lt;/strong&gt; over 400 years the 13th falls on a Friday 688 times — more than any other weekday — but the rarest weekday still gets 684. The superstition has a real statistical edge; it's just a minuscule one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Which weekday the 13th prefers
&lt;/h2&gt;

&lt;p&gt;Counts across all 4,800 months of one cycle. The mean, if it were perfectly even, would be 685.7:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weekday&lt;/th&gt;
&lt;th&gt;Times the 13th lands here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Friday&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;688&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sunday&lt;/td&gt;
&lt;td&gt;687&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wednesday&lt;/td&gt;
&lt;td&gt;687&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monday&lt;/td&gt;
&lt;td&gt;685&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuesday&lt;/td&gt;
&lt;td&gt;685&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thursday&lt;/td&gt;
&lt;td&gt;684&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saturday&lt;/td&gt;
&lt;td&gt;684&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Friday wins — by about two months in 4,800. On a true zero-based axis the seven bars are almost identical, and that &lt;em&gt;is&lt;/em&gt; the story: the bias is real but tiny, an artefact of how the Gregorian leap rule favours some starting weekdays over others.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can't dodge it: every year has one
&lt;/h2&gt;

&lt;p&gt;However the weekdays fall, no year escapes a Friday the 13th — and no year has more than three. Across the cycle, &lt;strong&gt;171 years have exactly one, 170 have two, and 59 have three&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(For the record: the next Friday the 13ths are Nov 13 2026, Aug 13 2027, and Oct 13 2028.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The calendar is a 400-year loop
&lt;/h2&gt;

&lt;p&gt;Here's the fact that makes every count above exact rather than an estimate. A 400-year block holds &lt;strong&gt;97 leap years and 146,097 days&lt;/strong&gt; — and 146,097 divides by seven with nothing left over, giving &lt;strong&gt;exactly 20,871 weeks&lt;/strong&gt;. Because days and weeks line up perfectly at the 400-year mark, the entire calendar resets and repeats. Your birthday in 2426 falls on the same weekday it does in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  And some years get 53 weeks
&lt;/h2&gt;

&lt;p&gt;Most years span 52 ISO weeks, but &lt;strong&gt;71 of every 400 (about 17.8%) carry 53&lt;/strong&gt; — a year does when it starts on a Thursday, or on a Wednesday in a leap year. The next 53-week years are &lt;strong&gt;2026, 2032, 2037 and 2043&lt;/strong&gt;. That extra week is why payroll and planning calendars occasionally gain a period.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Every number is printed by one dependency-free Node script over one 400-year cycle (the calendar repeats, so the counts are exact, not sampled):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ &lt;a href="https://gist.github.com/lucian-devops/0b85a838f2acc430534cf72d01a70413" rel="noopener noreferrer"&gt;Run it yourself (public gist)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lkforge.com/blog/friday-the-13th-gregorian-calendar-patterns/" rel="noopener noreferrer"&gt;lkforge.com&lt;/a&gt;, where the weekday distribution is an interactive deviation chart. Built alongside the free &lt;a href="https://lkforge.com/tools/calendar/" rel="noopener noreferrer"&gt;LK Forge Calendar tools&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>math</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>US sales tax by state: California 7.25%, five states at 0%, average 5.11%</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Fri, 07 Aug 2026 22:00:57 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/us-sales-tax-by-state-california-725-five-states-at-0-average-511-265h</link>
      <guid>https://dev.to/lucian_lkb_1f009d/us-sales-tax-by-state-california-725-five-states-at-0-average-511-265h</guid>
      <description>&lt;p&gt;While building a sales-tax calculator I needed the statewide base rate for every US state. Once the table was in code, ranking it took three lines — and the result is a cleaner picture than most "sales tax by state" posts, because it keeps one distinction straight: &lt;strong&gt;these are state &lt;em&gt;base&lt;/em&gt; rates, not the combined rate at the register.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;Statewide base rate (%), 50 states + DC — the exact table the calculator ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// abbreviated; full 51-row table in the post&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="mf"&gt;7.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.63&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mf"&gt;6.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mf"&gt;5.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mf"&gt;4.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The findings — all just arithmetic
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;median&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// 51&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;            &lt;span class="c1"&gt;// 5.11&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;median&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                    &lt;span class="c1"&gt;// 6&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;California is highest at 7.25%.&lt;/strong&gt; Four states follow at 7% (Indiana, Mississippi, Rhode Island, Tennessee).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Five states charge no statewide sales tax&lt;/strong&gt; — the &lt;strong&gt;NOMAD&lt;/strong&gt; set: New Hampshire, Oregon, Montana, Alaska, Delaware.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Average 5.11%, median 6%.&lt;/strong&gt; Most states cluster right at 6%.&lt;/li&gt;
&lt;li&gt;On a $1,000 purchase that's a &lt;strong&gt;$72.50&lt;/strong&gt; swing between the top state and the no-tax five — before any local tax.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The catch everyone blurs
&lt;/h2&gt;

&lt;p&gt;These are &lt;strong&gt;base state rates&lt;/strong&gt;. Many cities and counties add their own sales tax on top, so the &lt;em&gt;combined&lt;/em&gt; rate at checkout is higher — in places like Tennessee and Louisiana, higher than California's. And Alaska has no &lt;em&gt;state&lt;/em&gt; sales tax but permits local ones, so "0%" isn't 0% everywhere in Alaska. A ranking that mixes base and combined rates quietly compares apples to oranges; this one doesn't.&lt;/p&gt;

&lt;p&gt;Full ranked table (all 51, with tax-on-$1,000) + chart: &lt;strong&gt;&lt;a href="https://lkforge.com/blog/sales-tax-by-state/" rel="noopener noreferrer"&gt;US Sales Tax by State →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every figure is arithmetic on that one rate table — reproducible with the snippet above.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>datascience</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A cup is not a cup: 9 baking staples, one volume, a 2.66x weight spread</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Fri, 07 Aug 2026 21:58:14 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/a-cup-is-not-a-cup-9-baking-staples-one-volume-a-266x-weight-spread-2a45</link>
      <guid>https://dev.to/lucian_lkb_1f009d/a-cup-is-not-a-cup-9-baking-staples-one-volume-a-266x-weight-spread-2a45</guid>
      <description>&lt;p&gt;A recipe says "1 cup of flour." My kitchen scale and yours will disagree about what that weighs — because a cup measures &lt;strong&gt;volume&lt;/strong&gt;, and ingredients don't share a density.&lt;/p&gt;

&lt;p&gt;I was building a cooking-unit converter and had to bake in real grams-per-cup densities. The moment they were sitting in one table, the spread jumped out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;Grams per US cup — the King Arthur Ingredient Weight Chart the converter ships (water is defined by volume, 1 cup = 236.588 mL ≈ 236.588 g):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gramsPerCup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Water&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;             &lt;span class="mf"&gt;236.588&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Butter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="mi"&gt;227&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Milk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;              &lt;span class="mi"&gt;227&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Brown sugar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="mi"&gt;213&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// packed&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Granulated sugar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;198&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rice (dry)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="mi"&gt;198&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;All-purpose flour&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Powdered sugar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="mi"&gt;113&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Oats&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;              &lt;span class="mi"&gt;89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The findings — all just arithmetic
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gramsPerCup&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;                 &lt;span class="c1"&gt;// 2.66  (water 237 g vs oats 89 g)&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;198&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// 65  — sugar vs flour&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="mi"&gt;198&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;113&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 75  — granulated vs powdered sugar&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2.66×&lt;/strong&gt; between the heaviest cup (water, ~237 g) and the lightest (oats, 89 g).&lt;/li&gt;
&lt;li&gt;A cup of &lt;strong&gt;granulated sugar is ~65% heavier&lt;/strong&gt; than a cup of flour — identical volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granulated vs powdered sugar: ~75% apart&lt;/strong&gt;, and both are just labelled "sugar."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "1 cup" can mean anywhere from &lt;strong&gt;89 g to 237 g&lt;/strong&gt; depending on what's in it. And that's before human error: scoop flour straight from the bag and you pack it — a "cup" can hit 150 g+, a 25% overshoot before you've done anything wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for code, not just baking
&lt;/h2&gt;

&lt;p&gt;Any app that converts recipe units has to carry &lt;strong&gt;per-ingredient density&lt;/strong&gt; or it's silently wrong. Volume→weight isn't one constant; it's a lookup table. (16 tbsp = 1 cup, so grams-per-tablespoon is just grams-per-cup ÷ 16 — no separate data needed.)&lt;/p&gt;

&lt;p&gt;Full ranked table + chart: &lt;strong&gt;&lt;a href="https://lkforge.com/blog/why-a-cup-isnt-a-cup/" rel="noopener noreferrer"&gt;A Cup Is Not a Cup →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every gram figure is the King Arthur chart the converter ships, and each claim above is reproducible with the snippet.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>datascience</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The BMI-to-body-fat gap is a constant 22.3 points at every BMI</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Fri, 07 Aug 2026 21:40:39 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/the-bmi-to-body-fat-gap-is-a-constant-223-points-at-every-bmi-leg</link>
      <guid>https://dev.to/lucian_lkb_1f009d/the-bmi-to-body-fat-gap-is-a-constant-223-points-at-every-bmi-leg</guid>
      <description>&lt;p&gt;BMI is one number: your weight divided by the square of your height. It has no term for age, no term for sex, no term for how much of your weight is muscle. So I got curious about a simple question while building a body-fat calculator:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If two people have the exact same BMI, how different can their body fat actually be?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer turned out cleaner than I expected — and it's a nice little demonstration of how a linear formula hides variance in plain sight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formula
&lt;/h2&gt;

&lt;p&gt;The standard way to turn a BMI into a body-fat estimate is the &lt;strong&gt;Deurenberg equation&lt;/strong&gt;. It's the "BMI method" our &lt;a href="https://lkforge.com/tools/health/body-fat-calculator/" rel="noopener noreferrer"&gt;Body Fat Calculator&lt;/a&gt; ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bodyFat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isMale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="mf"&gt;1.20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;bmi&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.23&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;10.8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isMale&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;5.4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four terms. Only the first one involves BMI at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four people, one BMI of 25
&lt;/h2&gt;

&lt;p&gt;BMI 25 is the line where "normal" becomes "overweight." Let's put four people right on it and run the formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;male&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Man, 20&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Woman, 20&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Man, 70&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Woman, 70&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;bodyFat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;male&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Man, 20    18.4%&lt;/span&gt;
&lt;span class="c1"&gt;// Woman, 20  29.2%&lt;/span&gt;
&lt;span class="c1"&gt;// Man, 70    29.9%&lt;/span&gt;
&lt;span class="c1"&gt;// Woman, 70  40.7%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same BMI. Same "overweight" label. Body fat from &lt;strong&gt;18.4% to 40.7%&lt;/strong&gt; — a &lt;strong&gt;22.3-point spread&lt;/strong&gt;. Run those through the ACE body-fat categories and two of them come out "Average" and two come out "Obese." One number, opposite verdicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I didn't expect: the gap never changes
&lt;/h2&gt;

&lt;p&gt;Here's the neat bit. Because the sex term (&lt;code&gt;-10.8&lt;/code&gt;) and the age term (&lt;code&gt;0.23 * age&lt;/code&gt;) are &lt;strong&gt;added on independent of BMI&lt;/strong&gt;, the distance between the leanest archetype (a 20-year-old man) and the highest (a 70-year-old woman) is a fixed amount at &lt;em&gt;every&lt;/em&gt; BMI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;low&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;bodyFat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// leanest read&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;high&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;bodyFat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// highest read&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gaps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bmi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;18.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bmi&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bmi&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;high&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;low&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmi&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// [ 22.3 ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One distinct value: &lt;strong&gt;22.3&lt;/strong&gt;. The two body-fat-vs-BMI lines are perfectly parallel — a 22.3-point ribbon that just slides upward as BMI climbs. Decomposed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sex:&lt;/strong&gt; a woman reads exactly &lt;strong&gt;10.8 points&lt;/strong&gt; higher than a man at the same BMI and age. It's a literal constant in the equation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Age:&lt;/strong&gt; &lt;strong&gt;0.23 points per year&lt;/strong&gt;, so 20 → 70 adds &lt;strong&gt;11.5 points&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;10.8 + 11.5 = &lt;strong&gt;22.3&lt;/strong&gt;, forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So BMI can tell you where you land on the weight-for-height scale. It structurally &lt;em&gt;cannot&lt;/em&gt; tell you which end of that 22.3-point band you're in — that information isn't in the inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat
&lt;/h2&gt;

&lt;p&gt;The Deurenberg estimate is a &lt;em&gt;rough&lt;/em&gt; BMI-based approximation, not a clinical measurement — the point here is how much a single BMI can hide, not a precise personal number. If you actually want a body-fat estimate, a tape-measure method (like the U.S. Navy girth formula) uses real body measurements and is meaningfully better. Both are in the calculator.&lt;/p&gt;

&lt;p&gt;Full write-up with the chart and the category math: &lt;strong&gt;&lt;a href="https://lkforge.com/blog/bmi-vs-body-fat/" rel="noopener noreferrer"&gt;Same BMI, Different Body Fat →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every number above is arithmetic on that one formula — copy the snippets and you'll get the same figures.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>datascience</category>
      <category>health</category>
      <category>showdev</category>
    </item>
    <item>
      <title>OCR and sign a PDF entirely in the browser — no upload, no server</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Fri, 07 Aug 2026 16:55:18 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/ocr-and-sign-a-pdf-entirely-in-the-browser-no-upload-no-server-2pjl</link>
      <guid>https://dev.to/lucian_lkb_1f009d/ocr-and-sign-a-pdf-entirely-in-the-browser-no-upload-no-server-2pjl</guid>
      <description>&lt;p&gt;Most "free online PDF" tools quietly upload your document to a server. For a signed contract or a scanned ID, that's the whole problem. So I built two tools that don't: an &lt;a href="https://lkforge.com/tools/pdf/ocr-pdf/" rel="noopener noreferrer"&gt;OCR PDF&lt;/a&gt; that turns a scanned PDF into a searchable one, and a &lt;a href="https://lkforge.com/tools/pdf/sign-pdf/" rel="noopener noreferrer"&gt;Sign PDF&lt;/a&gt; that adds your signature — both running &lt;strong&gt;100% in the browser&lt;/strong&gt;. Nothing is uploaded; close the tab and it's gone.&lt;/p&gt;

&lt;p&gt;Here's how each one works, plus the part that actually took the effort: getting WebAssembly OCR to run under a strict Content-Security-Policy with no CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  OCR: scanned PDF → searchable PDF, client-side
&lt;/h2&gt;

&lt;p&gt;A scanned PDF is just images. You can't select or search the text. "OCR" means recognizing the characters and — for a &lt;em&gt;searchable&lt;/em&gt; PDF — laying an invisible text layer over the page image so the document looks identical but the words are now selectable.&lt;/p&gt;

&lt;p&gt;The pipeline, entirely in the tab:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Render each page to a canvas with &lt;strong&gt;pdf.js&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Recognize the text with &lt;strong&gt;tesseract.js&lt;/strong&gt; (WebAssembly OCR).&lt;/li&gt;
&lt;li&gt;Rebuild a new PDF with &lt;strong&gt;pdf-lib&lt;/strong&gt;: draw the page image, then stamp each recognized word as invisible text at its bounding box.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The recognizer is a vendored worker — no CDN, English fast model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Tesseract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eng&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;workerPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VENDOR&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/worker.min.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;corePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;VENDOR&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tesseract-core-simd-lstm.wasm.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;langPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;VENDOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// holds eng.traineddata.gz&lt;/span&gt;
  &lt;span class="na"&gt;workerBlobURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;recognizing text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;setProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting bit is the &lt;strong&gt;invisible text layer&lt;/strong&gt;. tesseract gives you words with bounding boxes in canvas pixels; pdf-lib draws text in PDF points with the origin bottom-left. So for each word you map the box, size the font to the box height, and draw it with &lt;code&gt;opacity: 0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;Wpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Hpt&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Wpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Hpt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// the scan&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Wpt&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                 &lt;span class="c1"&gt;// px → pt&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Hpt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// flip Y, sit on the baseline&lt;/span&gt;
    &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// invisible, but selectable&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result looks exactly like the scan, but you can select, copy, and Ctrl-F the text — and I also dump the plain text into a box you can copy or download as &lt;code&gt;.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest limits&lt;/strong&gt; (all stated on the page too): it uses the English model, alignment is word-level (great for search, not a character-perfect copy), and accuracy tracks scan quality — clean, straight, high-contrast pages read best. A quick sanity check: feed it an image of &lt;code&gt;OCR Verification Test 2026&lt;/code&gt; and the output PDF's text layer comes back as exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: WASM under a strict CSP, no CDN
&lt;/h2&gt;

&lt;p&gt;The site runs a locked-down CSP — &lt;code&gt;default-src 'self'&lt;/code&gt;, &lt;code&gt;connect-src 'self'&lt;/code&gt;, no &lt;code&gt;unsafe-eval&lt;/code&gt;. That breaks the usual tesseract.js setup twice over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No CDN.&lt;/strong&gt; The worker, the WASM core, and the language data all have to be same-origin. So I vendor them (&lt;code&gt;tesseract.min.js&lt;/code&gt;, &lt;code&gt;worker.min.js&lt;/code&gt;, &lt;code&gt;tesseract-core-simd-lstm.wasm(.js)&lt;/code&gt;, and a gzipped &lt;code&gt;eng.traineddata.gz&lt;/code&gt; — ~8.8 MB total, served from my own origin).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WASM needs &lt;code&gt;wasm-unsafe-eval&lt;/code&gt;.&lt;/strong&gt; But I don't want that on the whole site — just the one route. The CSP is built per-request in a Cloudflare Worker, so I scope a flag to the OCR path (and, crucially, its vendored assets, because the Web Worker inherits its CSP from &lt;em&gt;its own&lt;/em&gt; script response):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ocr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tools/pdf/ocr-pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// page + /vendor/*&lt;/span&gt;
&lt;span class="c1"&gt;// in buildCsp():&lt;/span&gt;
&lt;span class="s2"&gt;`script-src 'self' 'nonce-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ocr&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; 'wasm-unsafe-eval'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;ocr&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;worker-src 'self' blob:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else on the site stays &lt;code&gt;wasm-unsafe-eval&lt;/code&gt;-free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gotcha that cost me an hour:&lt;/strong&gt; tesseract resolves &lt;code&gt;workerPath&lt;/code&gt;/&lt;code&gt;corePath&lt;/code&gt; against &lt;em&gt;its own script's&lt;/em&gt; location, not the page. A relative &lt;code&gt;./vendor/...&lt;/code&gt; fails with a silent, empty-message "unknown error." The fix is an absolute same-origin URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VENDOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tools/pdf/ocr-pdf/vendor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(If you've self-hosted ffmpeg.wasm before, this will feel familiar — same lesson.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Sign: draw, type, or upload — then flatten it in
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://lkforge.com/tools/pdf/sign-pdf/" rel="noopener noreferrer"&gt;signature tool&lt;/a&gt; is simpler but the same philosophy. You make a signature three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Draw&lt;/strong&gt; on a canvas with pointer events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type&lt;/strong&gt; your name in a script font (&lt;code&gt;ctx.fillText&lt;/code&gt; with a cursive stack).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload&lt;/strong&gt; an image (a transparent PNG looks best).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you pick, I trim the transparent margins, turn it into a PNG, let you drag/resize it onto a page preview (rendered by pdf.js), then stamp it in with pdf-lib — mapping the on-screen box to page coordinates the same way the OCR layer does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signatureBytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPages&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="nx"&gt;pageIndex&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;png&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fy&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// top-left screen box → bottom-left PDF&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fw&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fh&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Honest framing&lt;/strong&gt; (also on the page): this adds a &lt;em&gt;visible&lt;/em&gt; signature image — like signing a printout and scanning it. It is &lt;strong&gt;not&lt;/strong&gt; a certificate-based cryptographic e-signature with a verified identity. For most "just sign this and send it back" jobs, that's exactly what you want; for anything that needs legal non-repudiation, use a dedicated e-sign service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do it all client-side?
&lt;/h2&gt;

&lt;p&gt;Three reasons, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Privacy.&lt;/strong&gt; The file never leaves the tab. There's no upload to log, cache, or breach — a real difference for signed documents and scanned IDs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No infrastructure.&lt;/strong&gt; No servers, no queues, no per-file cost. The browser does the work; I just ship static assets and a thin Worker for headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's free and account-free&lt;/strong&gt;, because it's cheap to run when the user's device is the compute.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trade-off is honest: OCR is slower than a beefy server (it's your CPU + WASM), and there's an ~8.8 MB one-time download for the model. For a privacy-first tool, I'll take that.&lt;/p&gt;

&lt;p&gt;Both tools are live and free — no signup, no watermark, nothing uploaded:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OCR PDF&lt;/strong&gt; → &lt;a href="https://lkforge.com/tools/pdf/ocr-pdf/" rel="noopener noreferrer"&gt;https://lkforge.com/tools/pdf/ocr-pdf/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign PDF&lt;/strong&gt; → &lt;a href="https://lkforge.com/tools/pdf/sign-pdf/" rel="noopener noreferrer"&gt;https://lkforge.com/tools/pdf/sign-pdf/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They're part of a &lt;a href="https://lkforge.com/tools/pdf/" rel="noopener noreferrer"&gt;24-tool browser-only PDF suite&lt;/a&gt; (crop, merge, compress, watermark, page numbers, HTML→PDF, and more) — all built on the same "nothing leaves your device" rule.&lt;/p&gt;

&lt;p&gt;If you've fought tesseract.js under a strict CSP, I'd love to hear how you scoped it.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webassembly</category>
      <category>pdf</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I tried to beat my own Tic-Tac-Toe AI 1,270 ways. It never lost.</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Fri, 07 Aug 2026 05:30:40 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/i-tried-to-beat-my-own-tic-tac-toe-ai-1270-ways-it-never-lost-2pkf</link>
      <guid>https://dev.to/lucian_lkb_1f009d/i-tried-to-beat-my-own-tic-tac-toe-ai-1270-ways-it-never-lost-2pkf</guid>
      <description>&lt;p&gt;Everyone says a minimax Tic-Tac-Toe bot is "unbeatable." I wanted a number, not a vibe — so I benchmarked the exact engine that ships on &lt;a href="https://lkforge.com/games/tictactoe/" rel="noopener noreferrer"&gt;my game&lt;/a&gt;. The code is &lt;a href="https://github.com/lucian-devops/tictactoe-ai" rel="noopener noreferrer"&gt;open source&lt;/a&gt;; every figure below reproduces with &lt;code&gt;node benchmark.js&lt;/code&gt;, and there's a &lt;a href="https://lkforge.com/games/tictactoe/blog-how-the-ai-works/" rel="noopener noreferrer"&gt;full writeup with charts&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The proof isn't a sample — it's the whole game tree
&lt;/h2&gt;

&lt;p&gt;On 3×3, Hard mode's reply is deterministic (full depth-9 minimax + alpha-beta), so you can enumerate &lt;em&gt;every reachable game&lt;/em&gt;. Playing second, as it does in the game: 569 lines → 386 wins, 183 draws, &lt;strong&gt;0 losses&lt;/strong&gt;. Playing first: 73 lines → 71 wins, 2 draws, 0 losses. Across all &lt;strong&gt;642 lines&lt;/strong&gt; it never loses. On 3×3, "unbeatable" is literally the game tree, not a claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Alpha-beta earns its keep
&lt;/h2&gt;

&lt;p&gt;Choosing the opening move at full depth, plain minimax visits 549,945 positions; alpha-beta cuts that to 36,528 — a &lt;strong&gt;93.4% reduction&lt;/strong&gt; for an identical answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Does it break on bigger boards?
&lt;/h2&gt;

&lt;p&gt;The engine caps its search depth as boards grow (9 → 7 → 5 → 3). I expected that to open a crack. It didn't: across &lt;strong&gt;628 simulated games&lt;/strong&gt; up to 6×6, against random and greedy opponents, &lt;strong&gt;still 0 losses&lt;/strong&gt;. What changes isn't losing — it's that &lt;em&gt;forcing a win&lt;/em&gt; gets harder, so results drift toward draws (3×3 91.8% win, 4×4 72.5%, 5×5 96.7%, 6×6 100% vs random).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it holds up:&lt;/strong&gt; it always blocks an immediate threat before searching, and 4-in-a-row stays defensible within the depth cap. The interesting fragility is theoretical — a human who can plant a fork beyond the AI's horizon — which is exactly why the &lt;a href="https://lkforge.com/games/tictactoe/" rel="noopener noreferrer"&gt;full game&lt;/a&gt; offers 4×4 up to 10×10.&lt;/p&gt;

&lt;p&gt;Engine + benchmark: &lt;strong&gt;&lt;a href="https://github.com/lucian-devops/tictactoe-ai" rel="noopener noreferrer"&gt;https://github.com/lucian-devops/tictactoe-ai&lt;/a&gt;&lt;/strong&gt;. Play it: &lt;strong&gt;&lt;a href="https://lkforge.com/games/tictactoe/" rel="noopener noreferrer"&gt;https://lkforge.com/games/tictactoe/&lt;/a&gt;&lt;/strong&gt;. Full writeup: &lt;strong&gt;&lt;a href="https://lkforge.com/games/tictactoe/blog-how-the-ai-works/" rel="noopener noreferrer"&gt;https://lkforge.com/games/tictactoe/blog-how-the-ai-works/&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Three Sudoku solvers, one engine, 34,000 fewer guesses</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Thu, 06 Aug 2026 19:01:00 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/three-sudoku-solvers-one-engine-34000-fewer-guesses-4gph</link>
      <guid>https://dev.to/lucian_lkb_1f009d/three-sudoku-solvers-one-engine-34000-fewer-guesses-4gph</guid>
      <description>&lt;p&gt;The Sudoku generator that ships in &lt;a href="https://lkforge.com/games/sudoku/" rel="noopener noreferrer"&gt;my puzzle game&lt;/a&gt; is a plain randomised-backtracking solver. It works, but "it works" is a vibe, not a number. So I pointed three different solving strategies at the &lt;strong&gt;exact same engine&lt;/strong&gt; and counted how hard each one actually has to search.&lt;/p&gt;

&lt;p&gt;The engine and the benchmark are &lt;a href="https://github.com/lucian-devops/sudoku-solver" rel="noopener noreferrer"&gt;open source&lt;/a&gt;. Every node count below is deterministic — clone it, run &lt;code&gt;node benchmark.js&lt;/code&gt;, and you get the same figures I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A grid is a flat array of 81 cells (&lt;code&gt;0&lt;/code&gt; = empty). The corpus is &lt;strong&gt;40 puzzles — 10 each of Easy, Medium, Hard and Expert — generated by the shipped generator with fixed seeds&lt;/strong&gt;, so the exact same 40 puzzles rebuild every run.&lt;/p&gt;

&lt;p&gt;The metric I care about is &lt;strong&gt;search nodes&lt;/strong&gt;: how many cells the solver has to &lt;em&gt;guess&lt;/em&gt; at and recurse on. Wall-clock time depends on your laptop; node counts don't. (Times below are from my machine, rounded, for flavour only.)&lt;/p&gt;

&lt;p&gt;Three strategies, all solving the identical puzzles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Naive backtracking&lt;/strong&gt; — the algorithm the game actually ships. Fill the next empty cell, try 1–9, recurse, undo on failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MRV backtracking&lt;/strong&gt; — same idea, but always branch on the cell with the &lt;em&gt;fewest&lt;/em&gt; candidates first (a most-constrained-variable heuristic).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logic (constraint propagation)&lt;/strong&gt; — before ever guessing, repeatedly apply naked singles and hidden singles to deduce forced cells. This is the same kind of reasoning the game's AI-hint button explains, only run to exhaustion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before comparing anything, the harness asserts all three return the &lt;strong&gt;same&lt;/strong&gt; solution on all 40 puzzles. Mismatches: &lt;strong&gt;0&lt;/strong&gt;. So we're comparing correct solvers, not fast-but-wrong ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Average &lt;strong&gt;search nodes&lt;/strong&gt; per puzzle (&lt;code&gt;node benchmark.js&lt;/code&gt;, 10 puzzles/tier, seeded):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;difficulty&lt;/th&gt;
&lt;th&gt;naive backtracking&lt;/th&gt;
&lt;th&gt;MRV backtracking&lt;/th&gt;
&lt;th&gt;logic (propagation)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Easy&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;598&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard&lt;/td&gt;
&lt;td&gt;10,750&lt;/td&gt;
&lt;td&gt;380&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expert&lt;/td&gt;
&lt;td&gt;103,932&lt;/td&gt;
&lt;td&gt;238&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;all 40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28,835&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;177&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things jump out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Naive backtracking falls off a cliff on Expert&lt;/strong&gt; — ~104k search nodes on average (~9 ms), versus 59 on Easy. Fewer clues means longer, blinder guessing chains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MRV flattens that cliff.&lt;/strong&gt; Overall it explores ~163× fewer nodes than naive (28,835 → 177). Just always branching on the most-constrained cell keeps the whole search cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logic barely searches at all.&lt;/strong&gt; Easy and Medium puzzles solve in a &lt;strong&gt;single node&lt;/strong&gt; — zero guessing, pure deduction. Even Expert takes ~3. Overall that's &lt;code&gt;28,835 → 2&lt;/code&gt;, roughly &lt;strong&gt;14,000× fewer search nodes&lt;/strong&gt; than naive, and on Expert alone, &lt;code&gt;103,932 → 3&lt;/code&gt; — about &lt;strong&gt;34,000×&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest quirk: for MRV, Expert (238) actually edges out Hard (380). With only ~24 clues, Expert grids give constraint propagation more to bite on, and with 10 puzzles per tier there's real sample variance. I'm leaving it in rather than cherry-picking a monotonic table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the gap is so big
&lt;/h2&gt;

&lt;p&gt;Naive backtracking treats every empty cell as equally worth guessing, so on a sparse grid it wanders down enormous doomed branches before backing out. MRV never guesses on a cell with five options when one with two is available — most of the tree simply never gets built. And logic solving asks a different question entirely: not "which value do I try here?" but "which cell is &lt;em&gt;already forced&lt;/em&gt;?" On anything up to Hard, that question has an answer often enough that you never have to guess.&lt;/p&gt;

&lt;p&gt;That last point is also &lt;em&gt;why the game feels fair&lt;/em&gt;: the same singles the propagation solver uses to avoid guessing are the moves the hint engine points at, so every puzzle is solvable by reasoning, not luck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/lucian-devops/sudoku-solver
&lt;span class="nb"&gt;cd &lt;/span&gt;sudoku-solver
node benchmark.js        &lt;span class="c"&gt;# the table above (deterministic node counts)&lt;/span&gt;
node benchmark.js 50     &lt;span class="c"&gt;# 50 puzzles/tier if you want steadier averages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;engine.js&lt;/code&gt; is the actual generator/solver from the live game, extracted verbatim; the instrumented solver variants (node counters, MRV, propagation) live in &lt;code&gt;benchmark.js&lt;/code&gt; so the shipped module stays exactly what players get.&lt;/p&gt;

&lt;p&gt;If you want to see the shipped solver working on a real board, there's a full write-up with the same numbers here: &lt;strong&gt;&lt;a href="https://lkforge.com/games/sudoku/blog-solver-backtracking" rel="noopener noreferrer"&gt;How a Sudoku solver works →&lt;/a&gt;&lt;/strong&gt; — or just &lt;a href="https://lkforge.com/games/sudoku/" rel="noopener noreferrer"&gt;play a puzzle&lt;/a&gt; and hit the hint button to watch the propagation logic name each move.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Numbers computed from the real shipped engine, not a reimplementation. Corpus and counts are seeded and reproducible.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>coding</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How an AI actually beats 2048 (expectimax, not an LLM)</title>
      <dc:creator>Lucian (LKB)</dc:creator>
      <pubDate>Mon, 03 Aug 2026 11:54:45 +0000</pubDate>
      <link>https://dev.to/lucian_lkb_1f009d/how-an-ai-actually-beats-2048-expectimax-not-an-llm-pli</link>
      <guid>https://dev.to/lucian_lkb_1f009d/how-an-ai-actually-beats-2048-expectimax-not-an-llm-pli</guid>
      <description>&lt;p&gt;&lt;em&gt;Every number in this post comes from our own re-runnable 250-game self-play benchmark. Nothing here is estimated.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you've ever asked "are there any AI tools that can actually beat 2048?", the answer is yes — and the interesting part is &lt;strong&gt;which&lt;/strong&gt; kind of AI does it. It is not a language model. A 2048-solving AI is a small, deterministic search algorithm you can run in a browser tab with no network calls. Here is exactly how one works, and where it hits its ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not an LLM?
&lt;/h2&gt;

&lt;p&gt;You could paste the board into a chat model and ask for a move. It will often give you a plausible one — and sometimes an illegal or losing one — because a language model predicts the next tokens of &lt;em&gt;text&lt;/em&gt;, not the next move of a &lt;em&gt;game tree&lt;/em&gt;. Winning a game of chance is a search problem, and we already have an exact algorithm for it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Game-tree search&lt;/th&gt;
&lt;th&gt;A language model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How a move is chosen&lt;/td&gt;
&lt;td&gt;Search the tree, return a specific legal move&lt;/td&gt;
&lt;td&gt;Predict tokens; the "move" is whatever it writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Determinism&lt;/td&gt;
&lt;td&gt;Same board → same move, every time&lt;/td&gt;
&lt;td&gt;Sampling is probabilistic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;Sub-millisecond, on your device&lt;/td&gt;
&lt;td&gt;An API round-trip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost / offline&lt;/td&gt;
&lt;td&gt;Free, offline, no key&lt;/td&gt;
&lt;td&gt;Hosted or paid API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The core idea: expectimax
&lt;/h2&gt;

&lt;p&gt;2048 is a game against chance — you pick a direction, then the game drops a random tile (a &lt;strong&gt;2&lt;/strong&gt; with 90% probability, a &lt;strong&gt;4&lt;/strong&gt; with 10%) on a random empty square. The right tool is &lt;strong&gt;expectimax search&lt;/strong&gt;, a tree that alternates two layer types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On a &lt;strong&gt;max&lt;/strong&gt; layer the AI tries all four moves and keeps the best.&lt;/li&gt;
&lt;li&gt;On a &lt;strong&gt;chance&lt;/strong&gt; layer it considers every square a new tile could land on and &lt;em&gt;averages&lt;/em&gt; the outcomes, weighted by probability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By looking several layers deep, it picks the move whose &lt;em&gt;likely&lt;/em&gt; future is strongest — accounting for unlucky spawns instead of grabbing whatever looks good right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not minimax?&lt;/strong&gt; Minimax assumes an adversary playing the &lt;em&gt;worst&lt;/em&gt; tile against you. But 2048's tiles are random, not malicious. Averaging over outcomes (expectimax) models the real game; minimax would play far too defensively. This is the textbook split — minimax for chess, expectimax for games against nature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The heuristic: scoring a board
&lt;/h2&gt;

&lt;p&gt;Search needs a way to score a board it can't play all the way out. The whole evaluation is three terms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;positional&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;empties&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="mi"&gt;200000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;smoothness&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Positional (corner-snake)&lt;/strong&gt; — each square has a fixed rank; a tile's value is multiplied by &lt;code&gt;4^rank&lt;/code&gt;. Because the weights grow as powers of four, one big tile in the corner dominates everything, so the search is rewarded for stacking value toward that corner in snake order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty squares&lt;/strong&gt; — every blank cell is worth a flat &lt;code&gt;200000&lt;/code&gt;. Empty space is what keeps future moves legal, so a nearly-full board scores as almost worthless no matter how large its tiles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smoothness&lt;/strong&gt; — for each pair of neighbours, subtract &lt;code&gt;|log2(a) − log2(b)|&lt;/code&gt;. Mergeable neighbours cost almost nothing; a 2 next to a 512 is punished. Jagged boards score lower.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How well does it actually play?
&lt;/h2&gt;

&lt;p&gt;We ran the exact search code headless for &lt;strong&gt;250 full games&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Who / what&lt;/th&gt;
&lt;th&gt;Top tile&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Theoretical maximum&lt;/td&gt;
&lt;td&gt;131,072&lt;/td&gt;
&lt;td&gt;Absolute ceiling on a 4×4 board&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best research AI (2025)&lt;/td&gt;
&lt;td&gt;65,536&lt;/td&gt;
&lt;td&gt;Reached ~8.4% of games; median score ~820,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;This browser solver&lt;/td&gt;
&lt;td&gt;4,096&lt;/td&gt;
&lt;td&gt;~30% of games; reaches 2048 ~70% of the time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Most human players&lt;/td&gt;
&lt;td&gt;2,048&lt;/td&gt;
&lt;td&gt;The original win condition&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a simple expectimax solver clears the 2048 win condition in about &lt;strong&gt;70%&lt;/strong&gt; of games and pushes to 4096 in about &lt;strong&gt;30%&lt;/strong&gt; — and essentially never reaches 8192. The state-of-the-art research AIs (expectiminimax plus endgame tablebases) go two doublings higher, to 65,536, but even they hit that only ~8% of the time. The theoretical 131,072 ceiling is never reached in normal play.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / reproduce it
&lt;/h2&gt;

&lt;p&gt;You can watch this exact solver run by turning on &lt;strong&gt;Autoplay&lt;/strong&gt; in the free browser game at &lt;strong&gt;&lt;a href="https://lkforge.com/games/2048/" rel="noopener noreferrer"&gt;lkforge.com/games/2048&lt;/a&gt;&lt;/strong&gt; — no install, no account, runs entirely on your device. The full heuristic breakdown and the benchmark methodology are in the &lt;a href="https://lkforge.com/games/2048/blog-how-the-ai-solver-works" rel="noopener noreferrer"&gt;original write-up&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The engine is also &lt;strong&gt;open source (MIT)&lt;/strong&gt; if you want to read it, &lt;code&gt;require()&lt;/code&gt; it, or run the benchmark yourself: &lt;a href="https://github.com/lucian-devops/2048-ai-solver" rel="noopener noreferrer"&gt;github.com/lucian-devops/2048-ai-solver&lt;/a&gt; — and there's a &lt;a href="https://lucian-devops.github.io/2048-ai-solver/" rel="noopener noreferrer"&gt;live self-play demo&lt;/a&gt; that watches the AI play.&lt;/p&gt;

&lt;p&gt;If you want to build your own: implement expectimax with a depth of 3–5, use the three-term heuristic above, and run a few hundred self-play games to measure your own reach rates. The whole thing fits in a couple hundred lines of JavaScript.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gamedev</category>
      <category>javascript</category>
      <category>algorithms</category>
    </item>
  </channel>
</rss>
