<?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: Nightdrive Labs</title>
    <description>The latest articles on DEV Community by Nightdrive Labs (@nightdrivelabs).</description>
    <link>https://dev.to/nightdrivelabs</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%2F4144826%2F849a5770-d2cc-4842-afb1-accce0ff95a6.png</url>
      <title>DEV Community: Nightdrive Labs</title>
      <link>https://dev.to/nightdrivelabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nightdrivelabs"/>
    <language>en</language>
    <item>
      <title>A game portal rejected my game with one line - "The WASD keys aren't working". They were right, and all my tests were green.</title>
      <dc:creator>Nightdrive Labs</dc:creator>
      <pubDate>Sat, 03 Oct 2026 00:53:12 +0000</pubDate>
      <link>https://dev.to/nightdrivelabs/a-game-portal-rejected-my-game-with-one-line-the-wasd-keys-arent-working-they-were-right-and-3p11</link>
      <guid>https://dev.to/nightdrivelabs/a-game-portal-rejected-my-game-with-one-line-the-wasd-keys-arent-working-they-were-right-and-3p11</guid>
      <description>&lt;p&gt;My browser game &lt;a href="https://playgama.ai/play/ddg7todvr2" rel="noopener noreferrer"&gt;Void Parry&lt;/a&gt; went to a portal's moderation with dozens of automated browser tests passing. The answer came back a few days later:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The WASD keys aren't working in the game.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was the whole review. On my machine the keys worked. In every test the keys worked. On the portal's page they did nothing. It took one real key press on the real page to see why, and the cause was a line I wrote on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pointerdown&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// no text selection, no touch scrolling, no long-press menu&lt;/span&gt;
  &lt;span class="nf"&gt;handlePress&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;preventDefault()&lt;/code&gt; on &lt;code&gt;pointerdown&lt;/code&gt; is common in canvas games. It also cancels the default action of the press - and one of those defaults is &lt;strong&gt;moving keyboard focus to what you clicked&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On my own site that never mattered: the game is the top-level page, the document already has focus, keys arrive.&lt;/p&gt;

&lt;p&gt;A portal does not load your game as the top-level page. It puts it in an &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; (one portal I checked nests it two frames deep). An iframe only receives key events while it has focus, and it normally gets focus when the player clicks inside it. My handler cancelled exactly that. Pointer events kept arriving - mouse aim and buttons worked, which hid the problem - but &lt;code&gt;keydown&lt;/code&gt; went to the portal's page forever.&lt;/p&gt;

&lt;p&gt;You can see it in two lines from the devtools console of the game frame:&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasFocus&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// false, even right after clicking the game&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The first fix was not enough
&lt;/h2&gt;

&lt;p&gt;The obvious repair is to take focus yourself:&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;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pointerdown&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;handlePress&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I verified it on the live page - click, press W A S D, all four arrive - and sent the build back.&lt;/p&gt;

&lt;p&gt;Hours later I walked through what the reviewer had actually done, and it was not what I had tested. A new player in my game lands straight in the first boss fight. The mouse only aims; you never have to click. So the reviewer opened the game, pressed W, A, S, D, saw nothing move, and wrote the review. &lt;strong&gt;No click ever happened.&lt;/strong&gt; My fix repaired a path the reporter never took.&lt;/p&gt;

&lt;p&gt;A frame is allowed to focus itself without a click, so the real fix has three parts:&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                   &lt;span class="c1"&gt;// when the game loads&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startFight&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&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="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pointermove&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;    &lt;span class="c1"&gt;// the player is over the game&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasFocus&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&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;Plus &lt;code&gt;window.focus()&lt;/code&gt; after an ad closes, because an ad overlay takes the keyboard and does not hand it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test I should have had
&lt;/h2&gt;

&lt;p&gt;All my tests loaded &lt;code&gt;index.html&lt;/code&gt; directly. The fix for that is small: a host page with the game in an iframe, and a test that never clicks.&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;host.html&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;&amp;lt;iframe src="index.html" style="width:1280px;height:720px"&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;host2.html&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;&amp;lt;iframe src="host.html" style="width:1280px;height:720px"&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&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;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;host.html&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="s2"&gt;host2.html&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;// one and two frames deep&lt;/span&gt;
  &lt;span class="k"&gt;await&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;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&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;game&lt;/span&gt; &lt;span class="o"&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;frames&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/index&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;html/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&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;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&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="nx"&gt;e&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;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;));&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;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KeyW&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="s2"&gt;KeyA&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="s2"&gt;KeyS&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="s2"&gt;KeyD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// no click first&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&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;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KeyW,KeyA,KeyS,KeyD&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;It has three cases now: no click at all, the portal page has focus and the player only moves the mouse, and the portal page has focus and the player clicks. The old build fails the first two. Then I ran the same check against the live portal page, because a test page I wrote can share my blind spots.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;I went back to a second portal that had rejected the game twice, the last time for "overall quality", opened the build they had reviewed in their own preview tool, clicked in the game and pressed the keys. Nothing arrived there either. Two reviews of a keyboard game whose keyboard did not work on their site. I do not know how much of "overall quality" that was. I do know the reviewers could not move.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test the way the player meets the product.&lt;/strong&gt; If it ships inside someone else's frame, test it inside a frame. If a new player never clicks, the test must not click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;preventDefault()&lt;/code&gt; on pointer events cancels focus too.&lt;/strong&gt; Keep it if you need it, and take focus yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Fixed" means the reporter's exact path passes&lt;/strong&gt;, not a path next to it. Write that path down before you touch the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Green tests describe your test page.&lt;/strong&gt; One manual key press on the real page found in a minute what dozens of tests could not see.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The game and its tests were written with an AI coding assistant; the bug, the review text and the fix are from the real build.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Let the player build the maze - a 576-cell flow field for a tower defense map</title>
      <dc:creator>Nightdrive Labs</dc:creator>
      <pubDate>Fri, 02 Oct 2026 07:22:57 +0000</pubDate>
      <link>https://dev.to/nightdrivelabs/let-the-player-build-the-maze-a-576-cell-flow-field-for-a-tower-defense-map-242b</link>
      <guid>https://dev.to/nightdrivelabs/let-the-player-build-the-maze-a-576-cell-flow-field-for-a-tower-defense-map-242b</guid>
      <description>&lt;p&gt;Most tower defense maps give enemies a fixed lane. In one map of &lt;a href="https://playgama.ai/play/ddg7todvr2" rel="noopener noreferrer"&gt;Void Parry&lt;/a&gt;'s tower-defense mode, "Null Grid", there is no lane at all: an open 32x18 grid, an entry on the left, a core on the right, and every tower you place becomes a wall. You build the level. A long route gives your towers more time to shoot, but every wall costs cash you are not spending on damage.&lt;/p&gt;

&lt;p&gt;It needs three small pieces of code, and none of them is A*.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One distance field, not one path per enemy
&lt;/h2&gt;

&lt;p&gt;Instead of searching a path for each enemy, I compute one distance field from the exit: every free cell stores how far it is from the core. Enemies then just walk "downhill".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mazeField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Float64Array&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="nx"&gt;GC&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;GR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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;Float64Array&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="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;done&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;Uint8Array&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="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;]&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(;;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// plain O(n^2) Dijkstra: 576 cells, and it only runs when the maze changes&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;u&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="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&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;i&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;done&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&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;u&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;done&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="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="c1"&gt;// relax the 8 neighbours (diagonals cost 1.41) ...&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;No priority queue, no heap. With 576 cells the naive version runs in well under a millisecond, and it only runs when a tower is built or sold. I left a comment in the code with the ceiling: a bigger grid would need a real heap.&lt;/p&gt;

&lt;p&gt;One detail matters for how it &lt;em&gt;looks&lt;/em&gt;: a diagonal step is only allowed if both cells it squeezes between are free. Without that rule, enemies slip through the corner between two towers, and the player reads it as a bug. The same rule also holds in the walker.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Walking downhill
&lt;/h2&gt;

&lt;p&gt;Each enemy looks at its 8 neighbours and steps to the one with the lowest &lt;code&gt;distance + step cost&lt;/code&gt;. That turns the field into a list of points, and I merge straight runs so the path has few corners. The game already moves enemies along point lists on every other map, so the maze map reuses the normal movement code.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Never let the player seal the route
&lt;/h2&gt;

&lt;p&gt;The fun breaks if the player can close the maze completely. So before a tower is placed, a quick breadth-first search runs from the exit with the new cell marked as blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bl&lt;/span&gt; &lt;span class="o"&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;blocked&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;bl&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mazeOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exitCell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;entryCell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;cellsOfGroundEnemies&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nx"&gt;bl&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ok&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;block&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;It must still reach the entry &lt;strong&gt;and every enemy already on the grid&lt;/strong&gt;. Otherwise you could trap a group of enemies in a pocket. Placing a tower on top of an enemy is refused too. Each refusal returns a reason, so the game can say &lt;em&gt;why&lt;/em&gt; ("that would seal the route") instead of silently doing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-routing enemies that are already walking
&lt;/h2&gt;

&lt;p&gt;When a tower goes down, the field is rebuilt once. Then every enemy on the grid gets a new path, starting from its current cell. Enemies still outside the field keep the shared entry path. Flying enemies ignore all of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Showing the route before you commit
&lt;/h2&gt;

&lt;p&gt;The first version only showed the new route after you placed a tower, and planning a maze blind is no fun. Now, while you hover a free cell, the game blocks that cell for a moment, rebuilds the lane, and draws it as a dashed yellow line. It only recomputes when the hovered cell (or the maze) changes, so it costs nothing while the pointer rests. A test checks that the preview equals the real route after building.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Budget the walls.&lt;/strong&gt; Cheap "barrier" blocks exist so a maze doesn't eat your whole tower budget. Tuning their price is most of the balance work on this map.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole feature is about 60 lines of pathfinding. The field approach scales from one enemy to hundreds for the same cost, and "rebuild on change" keeps it simple.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The game and this code were written with an AI coding assistant; the snippets are from the real build.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>One build flag, two games - how I shipped a Halloween spin-off from the same codebase</title>
      <dc:creator>Nightdrive Labs</dc:creator>
      <pubDate>Thu, 01 Oct 2026 15:38:53 +0000</pubDate>
      <link>https://dev.to/nightdrivelabs/one-build-flag-two-games-how-i-shipped-a-halloween-spin-off-from-the-same-codebase-332k</link>
      <guid>https://dev.to/nightdrivelabs/one-build-flag-two-games-how-i-shipped-a-halloween-spin-off-from-the-same-codebase-332k</guid>
      <description>&lt;p&gt;Playgama is running a weekly challenge called "A Little Spooky": cozy, slightly eerie browser games, no jump scares. I already had a bullet-reflecting boss rush, Void Parry, and a week is not enough to write a second engine. So the spin-off, &lt;a href="https://playgama.ai/play/qwpaherohe" rel="noopener noreferrer"&gt;Hollow Night&lt;/a&gt;, is the same code with one flag switched on.&lt;/p&gt;

&lt;p&gt;Here is what that looked like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flag
&lt;/h2&gt;

&lt;p&gt;The whole switch is one global, set by the build script before the game loads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;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;VP_EVENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hollow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"bosses_hollow.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and one line at the top of the game:&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;HN&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;VP_EVENT&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hollow&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 that differs hangs off &lt;code&gt;HN&lt;/code&gt;: the title, the boss list, the arena backgrounds, the music table, the save key and which menus exist. Hollow Night has five bosses and a shop. The main game's other modes are simply not in the zip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Swap data, not code paths
&lt;/h2&gt;

&lt;p&gt;The rule I kept: &lt;code&gt;HN&lt;/code&gt; may pick &lt;em&gt;which data&lt;/em&gt; the engine uses, but it should not add new behaviour.&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;BG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;HN&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="s2"&gt;art/hollow/hollow1_wood.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;art/bg1_nebula.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&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 five new bosses (a lantern wisp, a candle spider, a scarecrow, a grave moth and a smiling moon) re-use bullet pattern families that the main game's tests already cover: spirals, rains, fans, telegraphed lines. Each gets its own palette and one signature move. That way a bot that can beat the main game can also play the spin-off, and I did not need a second test suite.&lt;/p&gt;

&lt;p&gt;The boss drawings are a separate file with the same draw API as the main game's. The spin-off's file registers itself under the same name (&lt;code&gt;window.VP_VEC = window.VP_HOLLOW&lt;/code&gt;), so the engine's &lt;code&gt;VP_VEC.draw(...)&lt;/code&gt; call does not know which file answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build is a short shell script
&lt;/h2&gt;

&lt;p&gt;No bundler. The script copies the files the spin-off needs into a temp folder, rewrites &lt;code&gt;index.html&lt;/code&gt; with &lt;code&gt;sed&lt;/code&gt;, and zips it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s|&amp;lt;script src="bosses_vec.js"&amp;gt;&amp;lt;/script&amp;gt;|&amp;lt;script&amp;gt;window.VP_EVENT = "hollow";&amp;lt;/script&amp;gt;\n&amp;lt;script src="bosses_hollow.js"&amp;gt;&amp;lt;/script&amp;gt;|'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s|&amp;lt;title&amp;gt;Void Parry&amp;lt;/title&amp;gt;|&amp;lt;title&amp;gt;Hollow Night&amp;lt;/title&amp;gt;|'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/index.html"&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'VP_EVENT = "hollow"'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/index.html"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"bosses_vec"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/index.html"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line matters more than it looks. &lt;code&gt;sed&lt;/code&gt; fails silently when its pattern no longer matches. If someone edits the script tag in the main &lt;code&gt;index.html&lt;/code&gt;, the build would happily produce a zip of the wrong game. The &lt;code&gt;grep&lt;/code&gt; after it turns that into a failed build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing that must not be shared: the save
&lt;/h2&gt;

&lt;p&gt;Both builds read and write &lt;code&gt;localStorage&lt;/code&gt;. On the portal each game gets its own origin, so shared key names would be harmless there. On a test site where both games sit under one origin, the spin-off would load a save with fifteen cleared bosses and menus it does not have.&lt;/p&gt;

&lt;p&gt;So the flag also picks the key prefix: the spin-off's saves all start with &lt;code&gt;hn_&lt;/code&gt;. When one codebase becomes two products, the save belongs to the product, not to the engine.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One flag, read once, at the top.&lt;/strong&gt; If you can grep for it, you can count how different the two games are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the variant choose data, not logic.&lt;/strong&gt; New content rides on tested systems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert after every &lt;code&gt;sed&lt;/code&gt;.&lt;/strong&gt; A text-replace build needs a check that the replace happened.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Separate saves from day one.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hollow Night is free to play in the browser: &lt;a href="https://playgama.ai/play/qwpaherohe" rel="noopener noreferrer"&gt;playgama.ai/play/qwpaherohe&lt;/a&gt;. Five spirits, fog, lanterns, and one button that sends their bullets back.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Made with AI (Claude Code) and published through #PlaygamaMCP; the art is drawn in code, not generated images. #playgama&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>My game's tutorial said "press R now" - and pressing R did nothing. A bot test found why.</title>
      <dc:creator>Nightdrive Labs</dc:creator>
      <pubDate>Wed, 30 Sep 2026 15:35:38 +0000</pubDate>
      <link>https://dev.to/nightdrivelabs/my-games-tutorial-said-press-r-now-and-pressing-r-did-nothing-a-bot-test-found-why-3pdl</link>
      <guid>https://dev.to/nightdrivelabs/my-games-tutorial-said-press-r-now-and-pressing-r-did-nothing-a-bot-test-found-why-3pdl</guid>
      <description>&lt;p&gt;My browser game &lt;a href="https://playgama.ai/play/ddg7todvr2" rel="noopener noreferrer"&gt;Void Parry&lt;/a&gt; has one core verb: you reflect enemy bullets back at a boss. The first fight teaches it. A slow white bullet flies at you, time slows down, a ring pulses around your character, and a keycap says &lt;strong&gt;R&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The lesson looked fine when I played it, so I thought it worked. Then I wrote a test that plays fight 1 like a new player. It presses R the moment the prompt appears. Often the lesson did not end until its 25-second timeout.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that didn't line up
&lt;/h2&gt;

&lt;p&gt;Four values, each fine on its own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thing&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt appears when the bullet is&lt;/td&gt;
&lt;td&gt;115 px away&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lesson bullet speed during bullet time&lt;/td&gt;
&lt;td&gt;150 px/s × 0.35 ≈ 52 px/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reflect bubble lifetime&lt;/td&gt;
&lt;td&gt;0.28 s of game time ≈ 0.8 s real time in bullet time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reflect reach&lt;/td&gt;
&lt;td&gt;28 px + bullet radius ≈ 39 px&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Press when the prompt appears and the bullet still has 76 px to travel. That takes about 1.5 s real time, and the bubble lasts 0.8 s. &lt;strong&gt;The bubble was gone before the bullet arrived.&lt;/strong&gt; Then the cooldown kicked in (0.9 s of game time, about 2.6 s real time in bullet time), so a second press did nothing either.&lt;/p&gt;

&lt;p&gt;A new player learns exactly the wrong lesson here: "the button doesn't work."&lt;/p&gt;

&lt;p&gt;Bullet time made it worse, not better. Slowing the world down also slowed my timers down. The prompt fired early by design ("give them time to react"), and the early press missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix is two lines. During the lesson only, the reflect lasts three times longer and the cooldown drops to 0.25 s:&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;lesson&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trainingNow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shieldT&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SH_T&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lesson&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;3&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;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shieldCd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lesson&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SH_CD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a press on the prompt always catches the bullet. The lesson ends 3-5 s into the game instead of timing out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that caught it
&lt;/h2&gt;

&lt;p&gt;The test drives the real game in headless Chrome with Playwright. It uses real key presses, not by calling the game's functions:&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;// fresh player: empty localStorage, fight 1 starts on load&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;near&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;P&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;__vp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;P&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;__vp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eb&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;hypot&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="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;P&lt;/span&gt;&lt;span class="p"&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;e&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;P&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="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;P&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vx&lt;/span&gt; &lt;span class="o"&gt;+&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="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;P&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="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vy&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="c1"&gt;// approaching&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;near&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;lessonBulletOnScreen&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KeyR&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;It then asserts three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the lesson ends before 20 s;&lt;/li&gt;
&lt;li&gt;fight 1 is won within 35-150 s;&lt;/li&gt;
&lt;li&gt;there are at least 8 bullets on screen after the lesson.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last check exists because an audit had called my first fight "nearly empty".&lt;/p&gt;

&lt;p&gt;The same test also showed the fight was too hard for a clumsy bot (four full deaths), so I tuned it with numbers instead of by feel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test the tutorial at the moment the prompt appears.&lt;/strong&gt; Timing windows, slow motion and cooldowns multiply. Each can be right on its own and still be wrong together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bullet time slows your own timers too.&lt;/strong&gt; If you slow the world, check every duration that runs on game time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bot doesn't need to be good to be useful.&lt;/strong&gt; A 70%-accurate bot that moves at random found a bug that my own playtesting walked past.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The game and this test were written with an AI coding assistant; the bug and the numbers are from the real build.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>testing</category>
      <category>ux</category>
    </item>
    <item>
      <title>A game portal told me AI art gets rejected, so I redrew 15 bosses in canvas code</title>
      <dc:creator>Nightdrive Labs</dc:creator>
      <pubDate>Tue, 29 Sep 2026 15:06:35 +0000</pubDate>
      <link>https://dev.to/nightdrivelabs/a-game-portal-told-me-ai-art-gets-rejected-so-i-redrew-15-bosses-in-canvas-code-5c2l</link>
      <guid>https://dev.to/nightdrivelabs/a-game-portal-told-me-ai-art-gets-rejected-so-i-redrew-15-bosses-in-canvas-code-5c2l</guid>
      <description>&lt;p&gt;My browser game &lt;a href="https://playgama.ai/play/ddg7todvr2" rel="noopener noreferrer"&gt;Void Parry&lt;/a&gt; is a neon boss rush: 15 bosses, you reflect their bullets back with a shield. The bosses, backgrounds and key art were painted with an image model and cut out. It looked fine in screenshots.&lt;/p&gt;

&lt;p&gt;Then I ran the portal's certification tool. One question was blunt: &lt;em&gt;"Is this game built entirely with generative AI?"&lt;/em&gt; I answered yes, because it was. The next screen:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AI for code/logic is welcome, but titles with raw AI graphics are rarely approved.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Their written requirements say why: games with AI graphics perform more than 2x worse on their metrics than games without. Players can tell, and they bounce.&lt;/p&gt;

&lt;p&gt;So I replaced every generated image in the game with art drawn by code. It took one evening, and the game looks better for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The style: neon line art, drawn every frame
&lt;/h2&gt;

&lt;p&gt;A neon boss rush suits the Geometry Wars / Just Shapes &amp;amp; Beats look: dark shapes, bright outlines, glow. That style is easy to draw with the Canvas 2D API and hard to get wrong:&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;// one path, stroked four times: wide faint glow, tighter glow, the neon line, a white-hot core&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;neon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;col&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#0a0616&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lighter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;22&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;4d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;2.6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;source-over&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&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="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffffffd9&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.34&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&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;No &lt;code&gt;shadowBlur&lt;/code&gt;: it is the slow part of canvas glow. Stacked strokes with &lt;code&gt;"lighter"&lt;/code&gt; blending look the same and cost almost nothing. The slowest of the 15 bosses draws in 0.33 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bosses that look back at you
&lt;/h2&gt;

&lt;p&gt;Because each boss is code, it can react. Every draw call gets a small state object:&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;VP_VEC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bossIndex&lt;/span&gt;&lt;span class="p"&gt;,&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;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;lx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// direction to the player, -1..1: eyes and cores follow you&lt;/span&gt;
  &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// 0..1 while winding up an attack: glows flare, jaws open&lt;/span&gt;
  &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// flashes the outline white when you land a hit&lt;/span&gt;
  &lt;span class="nx"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// 1-3: later phases add rings, cracks and more parts&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The painted sprites could only breathe, lean and move a glow over their eye. Now the Void Eye's pupil tracks you, the Star Leviathan's jaw opens before it fires, and the final boss's chains snap in phase 3. Game feel went up, not just legality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backgrounds and covers: procedural, rendered once
&lt;/h2&gt;

&lt;p&gt;Backgrounds don't need to animate, so they are procedural scenes (seeded stars, stacked radial-gradient nebulae, one neon set piece per world: a black hole, a ringed planet, a lava range) rendered once in a headless browser to JPGs. One lesson: dim the set pieces to about 50%. The platforms are neon lines too, and a bright mountain outline behind them reads as something you can stand on.&lt;/p&gt;

&lt;p&gt;The key art, store covers and app icon come from one composer page: background + a boss + the hero raising the shield + the title, exported at every size a store asks for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting out
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use AI for code and game math. That's welcome everywhere I've looked.&lt;/li&gt;
&lt;li&gt;If your graphics come out of an image model, expect portals, and players, to hold it against you.&lt;/li&gt;
&lt;li&gt;A strong, simple style you can draw in code beats detailed art that looks generic. It also stays consistent: all 15 bosses now look like they belong to one game, which the painted set never did.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The code for the game was written with an AI coding assistant; every visual in it is now drawn by that code.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>canvas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cheap bloom for a Canvas 2D game in 8 lines (no WebGL)</title>
      <dc:creator>Nightdrive Labs</dc:creator>
      <pubDate>Mon, 28 Sep 2026 22:27:15 +0000</pubDate>
      <link>https://dev.to/nightdrivelabs/cheap-bloom-for-a-canvas-2d-game-in-8-lines-no-webgl-4jah</link>
      <guid>https://dev.to/nightdrivelabs/cheap-bloom-for-a-canvas-2d-game-in-8-lines-no-webgl-4jah</guid>
      <description>&lt;p&gt;Neon games look flat without bloom: the soft light that bleeds around bright things. The usual advice is "switch to WebGL and write a post-process shader". For my Canvas 2D boss-rush game I wanted the glow without porting the renderer, so I did it with the 2D context itself. The whole pass is 8 lines and costs almost nothing (numbers at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;Bloom is three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bright pass&lt;/strong&gt;: keep only the bright pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blur&lt;/strong&gt; them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add&lt;/strong&gt; the blurred image back on top of the frame.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Canvas 2D can do all three. &lt;code&gt;ctx.filter&lt;/code&gt; takes CSS filter functions (&lt;code&gt;brightness&lt;/code&gt;, &lt;code&gt;contrast&lt;/code&gt;, &lt;code&gt;blur&lt;/code&gt;), and &lt;code&gt;globalCompositeOperation = "lighter"&lt;/code&gt; is additive blending. The trick that makes it cheap is doing steps 1-2 on a &lt;strong&gt;quarter-resolution&lt;/strong&gt; canvas: blur is the expensive part, and a 480x270 buffer has 1/16 of the pixels. A blurred image doesn't need resolution anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&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;let&lt;/span&gt; &lt;span class="nx"&gt;bloomC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bloomX&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyBloom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// call after the world is drawn, before the HUD&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bw&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;cv&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;&amp;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="nx"&gt;bh&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;cv&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;&amp;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="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;bloomC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;bloomC&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="s2"&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;bloomX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bloomC&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="s2"&gt;2d&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;bloomC&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;bw&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;bloomC&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="nx"&gt;bh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;bloomC&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;bw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bloomC&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="nx"&gt;bh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;bloomX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;copy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                  &lt;span class="c1"&gt;// replace, don't accumulate&lt;/span&gt;
  &lt;span class="nx"&gt;bloomX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;brightness(0.75) contrast(2.6) blur(3px)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;// bright pass + blur in one go&lt;/span&gt;
  &lt;span class="nx"&gt;bloomX&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;cv&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="nx"&gt;bw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;bloomX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTransform&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="mi"&gt;0&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lighter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalAlpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// additive, at 40%&lt;/span&gt;
  &lt;span class="nx"&gt;cx&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;bloomC&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="nx"&gt;cv&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="nx"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&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;code&gt;brightness(0.75) contrast(2.6)&lt;/code&gt; is the bright pass: contrast pushes mid-greys down to black and bright colours up, so only the neon survives. &lt;code&gt;blur(3px)&lt;/code&gt; at quarter resolution is roughly a 12 px blur at full size. Upscaling the small buffer with &lt;code&gt;drawImage&lt;/code&gt; adds a bilinear blur for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that bit me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Draw order.&lt;/strong&gt; Apply bloom after the game world and &lt;strong&gt;before&lt;/strong&gt; the HUD. Bloomed text is unreadable, and white UI panels turn into a fog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every browser supports &lt;code&gt;ctx.filter&lt;/code&gt;.&lt;/strong&gt; Safari added it much later than Chrome and Firefox, and a browser without it ignores the property silently: no error, the filter just does nothing, and you add an unblurred copy of the whole frame on top. Everything goes washed out. So test that the feature works instead of checking that the property exists:&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;FILTER_OK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(()&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;try&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;a&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="s2"&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;b&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="s2"&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;a&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;a&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="nx"&gt;b&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;b&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;9&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;ag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&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="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;bg&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="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="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;ag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&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;1&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="c1"&gt;// one white pixel&lt;/span&gt;
  &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blur(2px)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bg&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;a&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;return&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&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="mi"&gt;4&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;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&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;&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="c1"&gt;// did it spread two pixels left?&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="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="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;Give players a switch.&lt;/strong&gt; Bloom plus screen flashes is a lot for some people. I put "Glow (bloom)" next to "Reduce flashes" in the settings, and I turn it off automatically when the game's low-quality mode kicks in (frame time above 22 ms for ~2 seconds).&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;With 4x CPU throttling in DevTools the game still ran at 122-145 fps with bloom on, so the cost is negligible next to the game logic. Bullets, parry rings and boss cores went from "coloured dots" to "light". For a small game that's the best visual gain per line of code I've found.&lt;/p&gt;

&lt;p&gt;If you've done post-processing in Canvas 2D some other way (for example &lt;code&gt;getImageData&lt;/code&gt; loops or OffscreenCanvas in a worker), I'd like to hear how it compared.&lt;/p&gt;

&lt;p&gt;You can see the bloom in action in the game it was written for: &lt;a href="https://playgama.ai/play/ddg7todvr2" rel="noopener noreferrer"&gt;Void Parry&lt;/a&gt;, a free neon boss-rush in the browser (PC and phone).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance; the code runs in my game.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>canvas</category>
    </item>
    <item>
      <title>How to highlight and annotate a PDF in your browser (without uploading it)</title>
      <dc:creator>Nightdrive Labs</dc:creator>
      <pubDate>Mon, 28 Sep 2026 17:19:47 +0000</pubDate>
      <link>https://dev.to/nightdrivelabs/how-to-highlight-and-annotate-a-pdf-in-your-browser-without-uploading-it-319f</link>
      <guid>https://dev.to/nightdrivelabs/how-to-highlight-and-annotate-a-pdf-in-your-browser-without-uploading-it-319f</guid>
      <description>&lt;p&gt;Most "online PDF annotators" upload your file to their servers first. For contracts, research papers or anything private you probably don't want that. Here are the options that keep the PDF on your own computer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: the browser's built-in PDF viewer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Firefox&lt;/strong&gt; has a built-in highlighter: open the PDF, click the highlighter icon in the toolbar, select text, then save with the download button. Your highlights are written into the saved copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge&lt;/strong&gt; has &lt;em&gt;Highlight&lt;/em&gt; and &lt;em&gt;Draw&lt;/em&gt; in its PDF toolbar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome&lt;/strong&gt;'s viewer has had a basic drawing mode in some versions, but no text highlighter you can count on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The catch: the highlights live only in the file you save. Close the tab without saving and they are gone, and nothing keeps track of which PDFs you annotated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: a highlighter extension that handles web pages &lt;em&gt;and&lt;/em&gt; PDFs
&lt;/h2&gt;

&lt;p&gt;I built &lt;strong&gt;Glowmark&lt;/strong&gt; for exactly this: one place for highlights on web pages and in PDFs, stored locally.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install Glowmark (&lt;a href="https://nightdrivewalls.surge.sh/glowmark/" rel="noopener noreferrer"&gt;Firefox&lt;/a&gt;; Chrome, Edge and Opera listings are in review).&lt;/li&gt;
&lt;li&gt;Right-click a PDF link and choose &lt;strong&gt;Open PDF in Glowmark&lt;/strong&gt;, or use &lt;strong&gt;Open a PDF…&lt;/strong&gt; in its side panel for a file on your computer.&lt;/li&gt;
&lt;li&gt;Pick the highlighter, pen or text tool and mark up the PDF.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The annotated copy is saved automatically in your browser and reopens next time; you can still download it. The PDF never leaves your computer, because Glowmark renders it with Mozilla's open-source PDF.js inside the extension. Web page highlighting is free; PDF highlighting is part of Glowmark Pro (7-day free trial).&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: desktop apps
&lt;/h2&gt;

&lt;p&gt;If you annotate PDFs all day, a desktop reader (Okular, Foxit, PDF-XChange, Preview on macOS) is worth it. For quick highlights while you browse, a browser tool is faster.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I'm the developer of Glowmark. Written with AI assistance, tested by hand.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>pdf</category>
      <category>firefox</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Export web highlights to Obsidian or Notion as Markdown</title>
      <dc:creator>Nightdrive Labs</dc:creator>
      <pubDate>Sun, 27 Sep 2026 17:32:14 +0000</pubDate>
      <link>https://dev.to/nightdrivelabs/export-web-highlights-to-obsidian-or-notion-as-markdown-cpi</link>
      <guid>https://dev.to/nightdrivelabs/export-web-highlights-to-obsidian-or-notion-as-markdown-cpi</guid>
      <description>&lt;p&gt;If you read on the web and keep notes in Obsidian, Notion or Logseq, copying quotes by hand breaks your flow and loses the source link. A highlighter that exports Markdown turns a page into a note in one click.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Highlight text on any page with a highlighter extension (I use my own, &lt;strong&gt;Glowmark&lt;/strong&gt;).&lt;/li&gt;
&lt;li&gt;Click a highlight to add your own note.&lt;/li&gt;
&lt;li&gt;Open the side panel → &lt;strong&gt;Copy as Markdown&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Paste into a new note.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Output
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# How note-taking changes memory&lt;/span&gt;
https://example.com/article
&lt;span class="gt"&gt;
&amp;gt; Students who wrote notes by hand remembered concepts better a week later.&lt;/span&gt;

Check the sample size before citing this.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Title as heading, source link, each highlight as a quote with your note under it. Obsidian and Notion render it as quote blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the highlights survive page changes
&lt;/h2&gt;

&lt;p&gt;Glowmark stores each highlight as a text quote plus some prefix/suffix context (like the W3C text-quote selector), not as DOM offsets, so highlights re-anchor when a site changes its layout. Everything stays in the browser; nothing is uploaded.&lt;/p&gt;

&lt;p&gt;More: &lt;a href="https://nightdrivewalls.surge.sh/glowmark/" rel="noopener noreferrer"&gt;https://nightdrivewalls.surge.sh/glowmark/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance; the extension is mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>obsidian</category>
      <category>markdown</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to recover text you lost in a web form (and never lose it again)</title>
      <dc:creator>Nightdrive Labs</dc:creator>
      <pubDate>Sat, 26 Sep 2026 20:31:49 +0000</pubDate>
      <link>https://dev.to/nightdrivelabs/how-to-recover-text-you-lost-in-a-web-form-and-never-lose-it-again-3lp5</link>
      <guid>https://dev.to/nightdrivelabs/how-to-recover-text-you-lost-in-a-web-form-and-never-lose-it-again-3lp5</guid>
      <description>&lt;p&gt;You wrote a long reply or support ticket, pressed submit, and got "session expired" or a blank page. Before you retype it, try these, fastest first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Press Back once&lt;/strong&gt; (&lt;code&gt;Alt+←&lt;/code&gt;). Pages restored from the back/forward cache often still have your text in the box. Copy it somewhere safe immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reopen the closed tab&lt;/strong&gt; (&lt;code&gt;Ctrl+Shift+T&lt;/code&gt; / &lt;code&gt;Cmd+Shift+T&lt;/code&gt;). Some sites restore drafts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the site's drafts.&lt;/strong&gt; Gmail, Outlook, Reddit, WordPress, Discourse and many help desks autosave server-side drafts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undo in the empty box&lt;/strong&gt; (&lt;code&gt;Ctrl+Z&lt;/code&gt;) if a script cleared it without reloading the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clipboard history&lt;/strong&gt; (&lt;code&gt;Win+V&lt;/code&gt; on Windows) if you ever copied the text.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The real fix: keep a copy while you type
&lt;/h2&gt;

&lt;p&gt;Form-recovery extensions like Typio and Lazarus used to do this, but they stopped working when Chrome moved to Manifest V3. I built a replacement called &lt;strong&gt;Typeback&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;saves text boxes, text areas and rich editors (contenteditable) about a second after you stop typing&lt;/li&gt;
&lt;li&gt;right-click the empty field → "Restore last draft here" (or &lt;code&gt;Alt+Shift+Z&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;100% local (IndexedDB), skips password/card fields and private windows, per-site pause&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few implementation notes for extension devs: one draft per field per page visit (overwritten while typing, so no keystroke spam), restore via &lt;code&gt;document.execCommand("insertText")&lt;/code&gt; so undo history and React/Vue state stay intact, and with &lt;code&gt;all_frames&lt;/code&gt; only the focused frame answers the "which field?" message.&lt;/p&gt;

&lt;p&gt;Details and a test page: &lt;a href="https://nightdrivewalls.surge.sh/typeback/" rel="noopener noreferrer"&gt;https://nightdrivewalls.surge.sh/typeback/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance; the extension and its tests are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>browser</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
