<?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: kentog751</title>
    <description>The latest articles on DEV Community by kentog751 (@kentog751).</description>
    <link>https://dev.to/kentog751</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%2F4068181%2F4bcea506-9dee-4bd4-bef5-ce1927d95d54.png</url>
      <title>DEV Community: kentog751</title>
      <link>https://dev.to/kentog751</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kentog751"/>
    <language>en</language>
    <item>
      <title>The penny invariant: correctness guarantees in a game that teaches a child about money</title>
      <dc:creator>kentog751</dc:creator>
      <pubDate>Tue, 11 Aug 2026 02:58:17 +0000</pubDate>
      <link>https://dev.to/kentog751/the-penny-invariant-correctness-guarantees-in-a-game-that-teaches-a-child-about-money-50ig</link>
      <guid>https://dev.to/kentog751/the-penny-invariant-correctness-guarantees-in-a-game-that-teaches-a-child-about-money-50ig</guid>
      <description>&lt;p&gt;There is a bug class that only exists in software for children. The generator produces a question, the question has no answer, and the player cannot tell. A six-year-old has no mental model of your round generator. They have a mental model of themselves. So they try coins, nothing works, and they arrive at the only explanation available to them, which is that they are bad at this. A game that shows a child an impossible question has failed at the only thing it was for, and it has failed invisibly, because nobody files that bug report.&lt;/p&gt;

&lt;p&gt;I built a browser game called &lt;a href="https://www.freegamesonline.com/games/coin-cafe" rel="noopener noreferrer"&gt;Coin Cafe&lt;/a&gt; where you serve customers and count out the coins. Eight stages, eight customers per stage, US denominations, about 12,000 lines of plain ES modules with no build step. Most of that is drawing. The part worth writing about is the 1,300 lines in &lt;code&gt;money.js&lt;/code&gt; that make it structurally impossible to ask a question that cannot be answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money is integer cents, and only integer cents
&lt;/h2&gt;

&lt;p&gt;A money game is the worst possible place to discover floating point, not because the errors are large, but because the comparison you care about is exact equality against a number the child computed in their head.&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CENTS&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;freeze&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;penny&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="na"&gt;nickel&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="na"&gt;dime&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="na"&gt;quarter&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="na"&gt;dollar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&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;That is the whole model. &lt;code&gt;47&lt;/code&gt; means forty-seven cents. No dollars anywhere, no decimal strings, no &lt;code&gt;toFixed&lt;/code&gt; round trips.&lt;/p&gt;

&lt;p&gt;Try the alternative. A nickel and a penny on the mat for a 6 cent order, then three dimes and two pennies for a 32 cent order, both in dollars:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.05 + 0.01                       =&amp;gt;  0.060000000000000005
0.10 + 0.10 + 0.10 + 0.01 + 0.01  =&amp;gt;  0.32000000000000006
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are correct answers. Both fail &lt;code&gt;total === target&lt;/code&gt;. The child is told they are wrong, on a screen designed to be gentle, for a reason that does not exist. You can paper over it with an epsilon, but that means picking a tolerance on a quantity that is exactly representable as an integer.&lt;/p&gt;

&lt;p&gt;The rule is enforced at one seam. Denominations are always key strings, never values, and nothing converts a value back into a key. &lt;code&gt;denomOf()&lt;/code&gt; throws rather than degrade:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;money.denomOf: got the number &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; where a denomination key was expected. &lt;/span&gt;&lt;span class="dl"&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;Money is always stored as a key string like &lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;quarter&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;; use CENTS[key] when you need &lt;/span&gt;&lt;span class="dl"&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;its value. Nothing in Coin Cafe ever converts a value back into a key.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A loud crash at integration time is cheaper than a tray that silently totals zero. I shipped the silent version of this bug in an earlier game, where one module stored a colour key and another used it as a hex string. It survived weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The penny invariant
&lt;/h2&gt;

&lt;p&gt;Here is the guarantee, and it is one line of data rather than one line of logic. Every stage's coin tray contains a penny.&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;index&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Morning Shift&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;coins&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;penny&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="s1"&gt;nickel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;priceMin&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;priceMax&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;paid&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;cap&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="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is stage 0. Stage 4 is &lt;code&gt;['penny', 'nickel', 'dime']&lt;/code&gt;, stage 7 is all five. All eight rows start with &lt;code&gt;penny&lt;/code&gt;, including the three separate round shapes that stage 7 rolls between.&lt;/p&gt;

&lt;p&gt;With a penny in the tray, every integer-cent target from 1 upward is buildable. No combination of stage settings, price ranges, menus or difficulty bands can produce a round with no answer, because that failure mode does not exist in the space. You cannot configure your way into it.&lt;/p&gt;

&lt;p&gt;Compare the shape everyone reaches for first: generate a round, run a solver, reroll if the solver fails. That works until somebody widens a price range, or drops the penny from a stage to make it look cleaner, and a fraction of rounds start falling through the retry loop into whatever the fallback is. The loop hides that regression instead of surfacing it. A structural guarantee cannot be tuned into a bug by someone who was not thinking about solvability, because there is nothing to tune.&lt;/p&gt;

&lt;p&gt;One related fact is worth stating out loud. Greedy decomposition, largest coin first, is optimal for every tray this game ships: I checked it against a dynamic-programming minimum for every amount from 1 to 175 cents across all four trays, and greedy matches on all 700. That is a property of these specific denomination sets, not of greedy algorithms, so re-check it if you add a denomination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate and check, with the check as an assertion
&lt;/h2&gt;

&lt;p&gt;The invariant makes bad rounds impossible. The verifier makes sure of it anyway.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;verifyRound(spec)&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; for a sound round, or a plain English string naming what is wrong. It checks that every money field is an integer, that item prices sum to the total, that a change round's target really is paid minus price, and then it re-solves the round from scratch:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tray&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;penny&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tray has no penny, so solvability is not guaranteed&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;minimalCoins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tray&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="nf"&gt;countCoins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;target &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; is not buildable from this tray&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mc&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="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minCoins&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;minCoins is &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minCoins&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; but the answer needs &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;mc&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mc&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;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;needs &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;mc&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; coins, over stage &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; cap of &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is where it sits: the last line of the candidate builder, before the spec is handed back.&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;verifyRound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;null&lt;/code&gt; means the caller keeps looking. &lt;code&gt;makeRound()&lt;/code&gt; tries 40 random draws inside the customer's difficulty band, then sweeps every candidate value in the band and then the whole stage range across every shape, and only then falls back to one of eight hardcoded known-good rounds. It cannot hang and it cannot hand back something unsound, because every path out of it goes through the same verifier.&lt;/p&gt;

&lt;p&gt;I ran the shipped code over 64,000 generated rounds, every stage, every customer position: zero verifier failures, and the hardcoded fallback was reached zero times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Difficulty that ramps inside the session, not just between stages
&lt;/h2&gt;

&lt;p&gt;Eight customers per shift. If all eight draw uniformly from the stage's price range, the shift has no shape and customer one can be harder than customer eight. So the range is cut into thirds and each customer draws from their own third:&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;bandFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerIndex&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="nx"&gt;SHIFT_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;b&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;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;customerIndex&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&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="nx"&gt;b&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;With eight customers that gives 3 / 3 / 2. In stage 0 the observed target ranges come out at 1 to 8, 9 to 16, and 17 to 25 cents. Prices also never repeat inside a shift: the chosen total is added to a used set inside &lt;code&gt;makeRound()&lt;/code&gt; itself, so a caller cannot forget to do it.&lt;/p&gt;

&lt;p&gt;The subtle part is which number the band applies to. For a pay round it is the price. For a change round it is the change:&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;difficultyRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&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;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lo&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;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paid&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceMax&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;hi&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;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paid&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="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paid&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceMin&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;lo&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)];&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;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceMin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceMax&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;Band on the price in a change stage and the ramp inverts. A 3 cent item paid with a quarter means 22 cents of change, the hardest round in the stage, handed to customer number one. The child builds the change, so the change is what the difficulty is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The coin cap, and why 24 cents is a bad first question
&lt;/h2&gt;

&lt;p&gt;Every stage carries a &lt;code&gt;cap&lt;/code&gt;: the maximum number of coins the minimal answer may use. Stage 0 is 6.&lt;/p&gt;

&lt;p&gt;Stage 0 offers pennies and nickels, prices 1 to 25 cents. Arithmetically, 24 cents is fine. Four nickels and four pennies. It is also a terrible first screen for a six-year-old, because eight taps of near-identical metal is a dexterity exercise rather than a counting one, and the child will lose count around the sixth penny and conclude that counting is the thing they are bad at.&lt;/p&gt;

&lt;p&gt;The cap removes it. Of the 25 possible prices in stage 0, exactly three exceed six coins: 19 and 23 need seven, 24 needs eight. They are never generated. The cap is checked twice, in the candidate builder and again in the verifier, and the rule scales up: stage 2 caps at 9, and across 12,800 sampled stage 2 rounds the largest minimal answer observed was exactly 9.&lt;/p&gt;

&lt;p&gt;Nobody notices the absence of a bad question. That is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  A wrong answer has to teach, not score
&lt;/h2&gt;

&lt;p&gt;An incorrect serve does not shake the screen, does not deduct points and does not end the round. It opens a full-screen number line.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;explain()&lt;/code&gt; in &lt;code&gt;money.js&lt;/code&gt; writes every sentence on that screen. The renderer in &lt;code&gt;numberline.js&lt;/code&gt; composes none of them, because two files writing copy is two voices. Each coin the child placed becomes a labelled hop along the line, re-sorted largest first, because that re-sort is the lesson. Every landing point prints its running total, so &lt;code&gt;0 · 25 · 35 · 40 · 41&lt;/code&gt; is visible as a sequence. Under it sits the equation, &lt;code&gt;25 + 10 + 5 + 1 = 41¢&lt;/code&gt;, an amber band covering the distance still to travel, and one instruction:&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="nx"&gt;headline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gap&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; short.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;gapLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gap&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; to go&lt;/span&gt;&lt;span class="dl"&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;need&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sortDesc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;minimalCoins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tray&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;advice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Add &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gap&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; more. Try &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;coinPhrase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;need&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the child overshot, a subset sum over the coins actually on the mat finds the exact set to remove, so the advice becomes "Take off a nickel and two pennies", naming coins physically in front of them, rather than "try again". The words wrong, no, failed, incorrect and oops appear in no string this file produces. Being short is described as a distance to travel.&lt;/p&gt;

&lt;p&gt;Two decisions follow. The button out of the screen is called Fix it, and it returns you to the same round with your coins exactly where you left them:&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="nf"&gt;closeCheck&lt;/span&gt;&lt;span class="p"&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_phase&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serving&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number line is worth nothing if you cannot immediately act on it. A new round would be a punishment dressed as a reset.&lt;/p&gt;

&lt;p&gt;And that round can still be worth a star. Stars are 3 for a first-try minimal answer, 2 for a first-try correct answer, 1 for a correct answer after a check. Never 0 for a customer you served. Points work the same way: an incorrect serve drops the round from the 100 tier to the 50 tier, a forgone bonus rather than a deduction. Nothing in the scoring path subtracts. The self-check button, which opens the same number line before you serve, costs nothing at all, because self-checking is the behaviour you want and charging for it is a trap the child cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;The argument-order tolerance was a mistake. &lt;code&gt;explain()&lt;/code&gt; accepts &lt;code&gt;(mat, round)&lt;/code&gt; or &lt;code&gt;(round, mat)&lt;/code&gt; and sniffs which is which; &lt;code&gt;makeRound()&lt;/code&gt;'s options parameter accepts a Set, an array, or an object with several spellings. That was written to survive parallel work on modules meeting in the middle, and it works, but it is the exact opposite of what &lt;code&gt;denomOf()&lt;/code&gt; does two hundred lines earlier. One seam throws loudly on a shape mismatch and the other quietly repairs it. The loud one has been worth more.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;verifyRound()&lt;/code&gt; is exported and documented as an assertion seam, and nothing outside &lt;code&gt;money.js&lt;/code&gt; calls it. There is no committed test file exercising it either. The 64,000-round sweep above is real and ran against the shipping code, but it lives in my shell history rather than in the repository, so the guarantee is currently enforced by a comment and my memory. That is the weakest link in everything described here.&lt;/p&gt;

&lt;p&gt;The renderer also duplicates the model's normalisation. &lt;code&gt;numberline.js&lt;/code&gt; carries its own coin parser, its own cents table and its own palette fallback, all defensive copies of things &lt;code&gt;money.js&lt;/code&gt; and &lt;code&gt;art.js&lt;/code&gt; already own. Defensive copies drift, and one of them already disagrees about whether a half dollar exists.&lt;/p&gt;

&lt;p&gt;None of this is exotic. Pick an exact representation, encode the safety property in data rather than in a retry loop, and assert it on the way out. It matters more when the person who cannot detect the bug is six.&lt;/p&gt;

&lt;p&gt;The rest of the set is at &lt;a href="https://www.freegamesonline.com/games-for/teachers" rel="noopener noreferrer"&gt;freegamesonline.com/games-for/teachers&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Kenneth Hartog builds and runs freegamesonline.com, where the originals are plain ES modules with no build step.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>gamedev</category>
      <category>education</category>
      <category>webdev</category>
    </item>
    <item>
      <title>No build step, no bundler: 11 things that broke shipping Three.js into an iframe</title>
      <dc:creator>kentog751</dc:creator>
      <pubDate>Sat, 08 Aug 2026 00:33:15 +0000</pubDate>
      <link>https://dev.to/kentog751/no-build-step-no-bundler-11-things-that-broke-shipping-threejs-into-an-iframe-2292</link>
      <guid>https://dev.to/kentog751/no-build-step-no-bundler-11-things-that-broke-shipping-threejs-into-an-iframe-2292</guid>
      <description>&lt;p&gt;The constraint I gave myself was stupid and I would do it again. Two 3D games, real ones with physics and shadows and a mascot, that have to load inside an iframe on somebody else's page, on whatever laptop a school district bought in 2019, with no build step, no bundler, and no framework.&lt;/p&gt;

&lt;p&gt;So the shape is: one &lt;code&gt;index.html&lt;/code&gt;, seven plain ES modules the browser loads directly, and a vendored copy of Three.js (r185, two files, about 740KB) sitting in &lt;code&gt;lib/&lt;/code&gt; next to them. I install it with &lt;code&gt;npm install three --no-save&lt;/code&gt; and copy the build in, so Three never enters &lt;code&gt;package.json&lt;/code&gt; and the host never builds it. &lt;code&gt;&amp;lt;script type="module" src="/originals/petal-peaks/src/main.js"&amp;gt;&lt;/code&gt; and that is the entire toolchain.&lt;/p&gt;

&lt;p&gt;Both games shipped. Everything below is a thing that broke on the way. Most are not in the docs, and about half only exist because the games run embedded rather than on a page I control.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Probe WebGL on a canvas you throw away
&lt;/h2&gt;

&lt;p&gt;I want to pick renderer flags before I construct the renderer. Weak GPU means antialias off, &lt;code&gt;powerPreference: 'default'&lt;/code&gt;, a lower pixel ratio. To decide that, I have to look at the GL context first.&lt;/p&gt;

&lt;p&gt;The obvious move is to grab a context off the real canvas, read &lt;code&gt;WEBGL_debug_renderer_info&lt;/code&gt;, and hand the canvas to Three. That silently breaks everything. A canvas can only ever have one context. Once you call &lt;code&gt;getContext('webgl2')&lt;/code&gt; with default attributes, that context is bound forever, and &lt;code&gt;WebGLRenderer&lt;/code&gt; gets your default-attribute context back and quietly ignores every flag you just chose. No warning, no error. You find out because your Chromebook test frame looks identical before and after your "optimization."&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;boot()&lt;/code&gt; probes on a canvas that never touches the DOM:&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;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&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;gl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webgl2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webgl&lt;/span&gt;&lt;span class="dl"&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;gl&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;lose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WEBGL_lose_context&lt;/span&gt;&lt;span class="dl"&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;lose&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;lose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loseContext&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* show a friendly "your browser has 3D turned off" card */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;loseContext()&lt;/code&gt; matters more than it looks. Browsers cap live WebGL contexts, and the deeper capability probe in &lt;code&gt;perf.js&lt;/code&gt; burns a second one to test &lt;code&gt;failIfMajorPerformanceCaveat&lt;/code&gt;, which is the cheapest reliable way to detect a software rasterizer. Hand them back immediately or your actual game canvas is the one that fails to get a context.&lt;/p&gt;

&lt;p&gt;Only then does the real renderer get built, with the flags the probe earned:&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;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rendererSettingsFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quality&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;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renderer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WebGLRenderer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&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="nx"&gt;context&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;applyRendererSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;THREE&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;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Turn tone mapping off
&lt;/h2&gt;

&lt;p&gt;Every Three.js tutorial from the last few years tells you to set &lt;code&gt;ACESFilmicToneMapping&lt;/code&gt;. If your art comes out of Blender with physical materials, sure. Mine does not. My sky is a hand-written &lt;code&gt;ShaderMaterial&lt;/code&gt; full of hex colours I picked by eye. ACES took a bright pink-and-blue kids' palette and turned it into grey mush. Not "slightly desaturated." Mush.&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputColorSpace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SRGBColorSpace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toneMapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NoToneMapping&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other half of this is the trap that made me try ACES in the first place. A custom &lt;code&gt;ShaderMaterial&lt;/code&gt; gets no output encoding from Three at all. Write &lt;code&gt;gl_FragColor&lt;/code&gt; and it goes to the framebuffer raw, so every authored hex renders noticeably dark and your instinct is to reach for exposure to fix it. The actual fix is one line at the end of the fragment shader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="nb"&gt;gl_FragColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;vec4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vCol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;colorspace_fragment&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Include the colorspace chunk, deliberately do not include the tonemapping chunk, and the shader renders exactly the colours you typed. Both games run this way: &lt;a href="https://www.freegamesonline.com/games/petal-peaks" rel="noopener noreferrer"&gt;Petal Peaks&lt;/a&gt; has the sky, glow, and light-shaft shaders, and every one of them ends with that include.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;gl_PointSize&lt;/code&gt; is not pixels, it is whatever your shader says
&lt;/h2&gt;

&lt;p&gt;I built a GPU particle system as one &lt;code&gt;THREE.Points&lt;/code&gt; with a per-vertex &lt;code&gt;aSize&lt;/code&gt; attribute, and filled &lt;code&gt;aSize&lt;/code&gt; with numbers between 14 and 26, because in my head point size is pixels and 20px sparkles sounded right.&lt;/p&gt;

&lt;p&gt;The first burst covered the whole screen with one white blob. Here is the vertex shader, doing exactly what I told it to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;vec4&lt;/span&gt; &lt;span class="n"&gt;mv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modelViewMatrix&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;vec4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&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="nb"&gt;gl_Position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;projectionMatrix&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;mv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;pop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;smoothstep&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;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aLife&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;gl_PointSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aSize&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;uScale&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;mv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;z&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="mo"&gt;001&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with &lt;code&gt;uniforms: { uScale: { value: window.innerHeight * 0.5 } }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That divide by view depth makes &lt;code&gt;aSize&lt;/code&gt; a world-space diameter, so the sprite shrinks with distance like everything else in the scene, which is what you want. It also means the pixel size is &lt;code&gt;aSize * uScale / depth&lt;/code&gt;. The default embed is 640px tall, so &lt;code&gt;uScale&lt;/code&gt; is 320. A particle 12 units from the camera with &lt;code&gt;aSize = 20&lt;/code&gt; gets &lt;code&gt;20 * 320 / 12&lt;/code&gt;, which is 533 pixels of screen. Roughly the whole viewport, per particle.&lt;/p&gt;

&lt;p&gt;The real values are now fractions of a world unit, &lt;code&gt;0.26&lt;/code&gt; to &lt;code&gt;0.42&lt;/code&gt;, and the file carries a shouty comment so I do not do it again. This is the one bug here where the code was correct and my mental model of the units was the only broken part.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A chase camera means your character is always backlit
&lt;/h2&gt;

&lt;p&gt;This one is structural and I think it is the most useful thing here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.freegamesonline.com/games/petal-dash" rel="noopener noreferrer"&gt;Petal Dash&lt;/a&gt; is an endless runner with a fixed camera behind the player. The camera sits at &lt;code&gt;p.z - 6.4&lt;/code&gt;, the player runs toward &lt;code&gt;+z&lt;/code&gt;. So the surface the camera sees, for the entire game, is the character's back. Add one directional light for the sun and, unless that sun sits exactly behind the camera, every polygon facing the lens faces away from the light. &lt;code&gt;MeshLambertMaterial&lt;/code&gt; gives away-from-light surfaces ambient-only shading.&lt;/p&gt;

&lt;p&gt;I did the obvious thing and added an &lt;code&gt;AmbientLight&lt;/code&gt; fill. It did not help, and it cannot help, because ambient is flat: it raises every surface by the same constant, so the back of the character gets brighter but stays perfectly uniform. Uniform brightness with no gradient is what a grey silhouette looks like. I turned the ambient up until he was pale and still shapeless, then turned it back down.&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="nb"&gt;sun&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DirectionalLight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xfff6e0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;sun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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;12&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scene&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="nb"&gt;sun&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scene&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="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HemisphereLight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xbfe6ff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x8bd66a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HemisphereLight&lt;/code&gt; fixes it structurally rather than by tuning. It lights by how far a normal points up versus down, not by how far it points toward or away from a light direction. A vertical, camera-facing surface always sits in the middle of that gradient and always picks up some sky colour, so it cannot be left flat no matter where the camera is. Cool sky over warm ground bounce also keeps the shadow side coloured instead of grey, which is free art direction.&lt;/p&gt;

&lt;p&gt;You can also fix it with a second directional light pointing the same way as the camera. I would not. The direction of a Three.js directional light is &lt;code&gt;target.position - light.position&lt;/code&gt;, not "it shines outward from where I put it," and I got that backwards twice before switching approaches.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A flat plane renders as one flat colour, no matter how many polygons it has
&lt;/h2&gt;

&lt;p&gt;Related, and it took me embarrassingly long to see. The runner's ground is a flat plane. Under one directional light a flat plane has a constant normal, therefore constant N·L, therefore one uniform fill across the largest surface in the frame. Subdividing does nothing. It is the loudest programmer-art tell there is.&lt;/p&gt;

&lt;p&gt;The geometry has to stay flat because collision depends on it, so the shape gets baked into vertex colours at build time:&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;patch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.31&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.3&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="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;patch&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="nf"&gt;multiplyScalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;rand&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Broad low-frequency patches, per-vertex jitter, a worn path tint under each lane, a darker falloff at the outer edge for a contact-shadow read. The platformer does the same to its island tops with concentric rings inside the triangle fan, plus a warm lift on the sun-facing half computed from the sun's actual heading:&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;sunF&lt;/span&gt; &lt;span class="o"&gt;=&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;sunHX&lt;/span&gt; &lt;span class="o"&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;sunHZ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;capSpan&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;_capC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;capMid&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;sunF&lt;/span&gt; &lt;span class="o"&gt;&amp;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;_capC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;capWarm&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;min&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;sunF&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.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;_capC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;capShade&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;min&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="nx"&gt;sunF&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.26&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Costs about seven extra triangles per outline point, no extra draw call, and the surface is still perfectly flat for physics.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Additive light shafts need two fades, not one
&lt;/h2&gt;

&lt;p&gt;Volumetric-looking light shafts are cheap: a cone with additive blending. Except a raw additive cone reads as a pasted-on white wedge with razor edges down both sides, which players read as a rendering glitch rather than as light.&lt;/p&gt;

&lt;p&gt;Two independent fades are required and I only found the second one after the first one kept not being enough.&lt;/p&gt;

&lt;p&gt;The far end fades via vertex alpha baked into the geometry, so the beam dissolves instead of stopping at a hard rim. Additive blending plus black vertices equals invisible, which makes this free.&lt;/p&gt;

&lt;p&gt;The silhouette fades on view angle. Brightness through a real shaft is proportional to how much volume you are looking through, maximal dead centre and zero at the outline. That is &lt;code&gt;|dot(N, view)|&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalMatrix&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;vG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;mv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xyz&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;35&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;abs&lt;/code&gt; is load-bearing because the material is &lt;code&gt;DoubleSide&lt;/code&gt;. The same trick with &lt;code&gt;max(0.0, dot(...))&lt;/code&gt; turns the glow sphere around every pickup from a hard blob into something that stops stacking to solid white when a row of collectibles lines up in screen space.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. &lt;code&gt;body { touch-action: none }&lt;/code&gt; turns your iframe into a scroll trap
&lt;/h2&gt;

&lt;p&gt;This is the one that made me feel worst, because it breaks other people's pages, not mine.&lt;/p&gt;

&lt;p&gt;Every canvas game tutorial says put &lt;code&gt;touch-action: none&lt;/code&gt; on the body so the browser stops trying to scroll and pinch while you play. On your own full-page game, fine. Inside an iframe on someone's blog, you have just eaten every touch that lands on your rectangle. A reader scrolling the article puts a thumb on your game and the page stops moving. They cannot get past it. They leave.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nf"&gt;#scene&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;#touchControls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;touch-action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nc"&gt;.playing&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;touch-action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;overscroll-behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&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;Scoped to the surfaces that actually need gestures, plus a &lt;code&gt;.playing&lt;/code&gt; class the game only adds once a run has genuinely started. On the menu, the iframe is a well-behaved passenger and scrolls out of the way like an image.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Versioning the entry point does not version the graph
&lt;/h2&gt;

&lt;p&gt;This one shipped broken to production and I did not notice for a day.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;public/originals/**&lt;/code&gt; is served statically with a long edge and browser TTL, while the play page reads &lt;code&gt;index.html&lt;/code&gt; through a route handler. After a fix deploy those two disagreed for real: production served the new &lt;code&gt;index.html&lt;/code&gt; while the CDN and returning browsers served the old &lt;code&gt;src/main.js&lt;/code&gt;. The bug fix in that deploy simply did not run for players. The tell that it was cache and not a bad build was fetching the exact same URL with a junk query string, which returned the new file immediately.&lt;/p&gt;

&lt;p&gt;So I put a &lt;code&gt;?v=&lt;/code&gt; on the module URL, bumped it, deployed, and it was still broken for anyone who had visited before. Relative imports resolve against the module URL, but the query string does not propagate. &lt;code&gt;import { Game } from './game.js'&lt;/code&gt; inside &lt;code&gt;main.js?v=2&lt;/code&gt; requests &lt;code&gt;game.js&lt;/code&gt; with no version at all, hits the cache, and a returning visitor runs new &lt;code&gt;main.js&lt;/code&gt; against old &lt;code&gt;game.js&lt;/code&gt;. Half a release, which is the worst possible state.&lt;/p&gt;

&lt;p&gt;Every relative import has to carry the key too:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Game&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;COLORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SHAPE_LABEL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./game.js?v=7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GameAudio&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./audio.js?v=7&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;Bump all of them together, or you have invented a distributed systems problem inside one browser tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The menu rendered an empty void, and reading the code would never have told me
&lt;/h2&gt;

&lt;p&gt;The menus in both games are live dioramas: the character idles in the real world while the camera slowly orbits. In the runner, the world generator's &lt;code&gt;update(dt, playerZ, speed)&lt;/code&gt; only runs during play, by design, so idling does not burn distance or spawn obstacles. And &lt;code&gt;reset()&lt;/code&gt; clears everything.&lt;/p&gt;

&lt;p&gt;Clear everything, then never generate. The menu was a character standing in fogged nothing.&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One forced call with zero speed builds a full lookahead buffer of ground and scenery without starting the run. What matters is how I found it: by opening a browser and checking &lt;code&gt;scene.children.length&lt;/code&gt; and &lt;code&gt;groundTiles.length&lt;/code&gt; live. Reading the code would never have caught it, because every function in the chain is individually correct. The bug is the omission, and omissions only show up at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Flush edge-triggered input at every state transition
&lt;/h2&gt;

&lt;p&gt;Space is the jump key. Space is also the start key. Two separate &lt;code&gt;keydown&lt;/code&gt; listeners see the same event: &lt;code&gt;input.js&lt;/code&gt; queues a jump into a 0.12s buffer regardless of game state, and &lt;code&gt;main.js&lt;/code&gt;, registered second so it runs second on that same event, reads it as "start the run."&lt;/p&gt;

&lt;p&gt;The run then begins with a jump already sitting in the buffer, and the character hops on frame one of every keyboard-started run.&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;consumeJump&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;consumeLaneDir&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines at the top of the function that starts a run. The general rule: any input system with edge-triggered "queued until consumed" semantics needs an explicit flush at every state transition that could have been triggered by the same device. A fresh state does not imply fresh input.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. &lt;code&gt;PCFSoftShadowMap&lt;/code&gt; is gone
&lt;/h2&gt;

&lt;p&gt;Small one to finish. In r185 it is deprecated, it warns on every single load, and it silently falls back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My quality tiers name the shadow type as a string and index Three with it, so a tier is data rather than a branch:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;THREE&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;THREE&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="nx"&gt;shadowMapType&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;THREE&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="nx"&gt;shadowMapType&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;Low tier gets &lt;code&gt;BasicShadowMap&lt;/code&gt; and no shadows at all, mid and high get &lt;code&gt;PCFShadowMap&lt;/code&gt;. The upside is that &lt;code&gt;shadow.radius&lt;/code&gt; actually applies under PCF, so the soft edge you thought the "soft" map type was giving you is finally real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Vendoring Three and skipping the bundler was correct and I would do it again. The games are static files, they cache forever, there is no build to break, and I can open a file in a browser and hit reload. That beats tree-shaking a library I am already using most of.&lt;/p&gt;

&lt;p&gt;Three things I got wrong. I should have put the cache key in the imports on day one, on every game, instead of learning it in production on one game and back-porting. The two 3D games still ship bare imports, purely because they have not needed a fix deploy since. That is a loaded gun.&lt;/p&gt;

&lt;p&gt;I should have built the live-inspection habit earlier. Bugs 9 and 4 were both invisible to code review and obvious within thirty seconds of opening a console. I now assume anything about lighting, or about what is in the scene at a given moment, is unknowable from the source.&lt;/p&gt;

&lt;p&gt;And I should have written down the units. Half the pain in bug 3 and all of it in bug 5 came from assuming a number meant pixels when it meant world units, or that polygon count buys you shading when it buys you nothing under a constant normal. Those notes now sit in capitals at the point of use, which has already saved me twice on the &lt;a href="https://www.freegamesonline.com/originals" rel="noopener noreferrer"&gt;other hand-built games&lt;/a&gt; in the same repo.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Kenneth Hartog builds and runs freegamesonline.com, where the in-house games are hand-written and the 3D ones are Three.js with no build step.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>javascript</category>
      <category>webgl</category>
      <category>gamedev</category>
    </item>
  </channel>
</rss>
