<?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: Crane Zhou</title>
    <description>The latest articles on DEV Community by Crane Zhou (@fancy39_9841cbc02f99f729c).</description>
    <link>https://dev.to/fancy39_9841cbc02f99f729c</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%2F3397244%2Fa04c9fdd-ac9b-4ada-bdf9-38450b2679f7.jpg</url>
      <title>DEV Community: Crane Zhou</title>
      <link>https://dev.to/fancy39_9841cbc02f99f729c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fancy39_9841cbc02f99f729c"/>
    <language>en</language>
    <item>
      <title>A site that has to change at midnight: daily data with GitHub Actions and static hosting</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Sat, 14 Nov 2026 08:02:00 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/a-site-that-has-to-change-at-midnight-daily-data-with-github-actions-and-static-hosting-d0m</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/a-site-that-has-to-change-at-midnight-daily-data-with-github-actions-and-static-hosting-d0m</guid>
      <description>&lt;p&gt;I built a small fan site, &lt;a href="https://krillionanswerkey.com/" rel="noopener noreferrer"&gt;Krillion Answer Key&lt;/a&gt;, that lists the day's answers to a daily trivia game. The game publishes one answer sheet a day and only keeps the current day public. So the site has one job with a deadline: shortly after midnight US Eastern, fetch the new sheet, keep it, and republish. A day that is missed cannot be fetched later.&lt;/p&gt;

&lt;p&gt;The site is a static export (Next.js) on Cloudflare Workers static assets. There is no server and no database. Here is what the daily job looks like and what I learned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The data lives in the repo.&lt;/strong&gt; A script fetches the sheet and appends the day to one JSON file. Every page is generated from that file at build time: the home page is the newest day, each day and each prompt gets its own page, and a "yesterday" page appears once there are two days. Git history is the backup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Trim before you store.&lt;/strong&gt; The raw sheet is about 7.5 MB: more than 100,000 accepted answers across seven prompts, most of them in one tier. I keep the top answer, the small tiers in full, and an evenly spread sample of the large ones. A day ends up around 24 KB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Scheduled workflows start late, so schedule several.&lt;/strong&gt; GitHub's cron can start minutes late under load. The workflow runs a few times in the hour after the reset, and again later as a safety net. The script is idempotent: if today is already saved it exits without doing anything, and the build step is skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Daylight saving moves the deadline.&lt;/strong&gt; Midnight Eastern is 05:00 UTC in winter and 04:00 UTC in summer, and cron is in UTC. Rather than changing the schedule twice a year, the workflow runs after both hours and the script works out the date itself with &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; and the &lt;code&gt;America/New_York&lt;/code&gt; zone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Commit first, deploy second.&lt;/strong&gt; The job commits the new JSON, then builds and deploys. If the deploy fails, the data is already safe and the next run can redeploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Give CI the smallest key that works.&lt;/strong&gt; The deploy token can edit Workers scripts and the routes of one zone, and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Have a way to test the fetch without saving.&lt;/strong&gt; A &lt;code&gt;--probe&lt;/code&gt; flag fetches and reports the size of the sheet. I run it on manual dispatch, which is how I checked that GitHub's runners could reach the source at all.&lt;/p&gt;

&lt;p&gt;The fetch script and workflow are on GitHub: &lt;a href="https://github.com/fancydirty/krillion-answer-key" rel="noopener noreferrer"&gt;fancydirty/krillion-answer-key&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I run the site, which is an independent fan site and not affiliated with the game. This post was drafted with AI assistance from my own code and notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why "Optimized" GIFs Turn Into Haunted Sprite Sheets (And 4 Tiny Canvas Fixes for 2D Game Devs)</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Sat, 14 Nov 2026 02:43:16 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/why-optimized-gifs-turn-into-haunted-sprite-sheets-and-4-tiny-canvas-fixes-for-2d-game-devs-3960</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/why-optimized-gifs-turn-into-haunted-sprite-sheets-and-4-tiny-canvas-fixes-for-2d-game-devs-3960</guid>
      <description>&lt;p&gt;If you have ever built a sprite tool or dropped an "optimized" GIF into a custom game importer, you have met &lt;strong&gt;The Haunted Sprite Sheet&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frame 1 looks great.&lt;/li&gt;
&lt;li&gt;Frame 2 is just a floating pair of eyeballs and a sword tip.&lt;/li&gt;
&lt;li&gt;Frame 3 has three left legs stacked on top of each other like a Futurist painting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cm2tt8uh3yzucdtmbqv.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cm2tt8uh3yzucdtmbqv.gif" alt="Animated slime hopping smoothly" width="128" height="128"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then, right after you fix the GIF decoder, someone drops in an AI-generated character sheet with a solid lime-green background, uneven gutters, and a sword detached 4 pixels from the character's hand. Or they scale up a &lt;code&gt;32×32&lt;/code&gt; pixel slime and accidentally introduce 900 muddy anti-aliased gray pixels.&lt;/p&gt;

&lt;p&gt;When I built &lt;a href="https://giftospritesheet.com" rel="noopener noreferrer"&gt;GIF to Sprite Sheet&lt;/a&gt; — a zero-upload browser toolkit for converting, cutting, packing, and upscaling sprite sheets — almost all the real work came down to four surprisingly small algorithms on raw &lt;code&gt;ImageData&lt;/code&gt; arrays.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jvucygcnfcud8xjkvdq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jvucygcnfcud8xjkvdq.png" alt="GIF to Sprite Sheet converter interface showing grid layout and engine metrics" width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is every trap we hit, and the exact TypeScript that fixed it.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Reconstructing "Optimized" GIFs Without Ghost Limbs
&lt;/h2&gt;

&lt;p&gt;A lot of naive GIF-to-sheet scripts parse the binary stream with &lt;code&gt;gifuct-js&lt;/code&gt; and draw each frame's &lt;code&gt;patch&lt;/code&gt; straight onto its own cell.&lt;/p&gt;

&lt;p&gt;That breaks immediately on real-world GIFs, because encoders save bytes in two sneaky ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sub-frame patches:&lt;/strong&gt; Frame &lt;code&gt;i&lt;/code&gt; might only be a &lt;code&gt;14×9&lt;/code&gt; rectangle at &lt;code&gt;(x: 22, y: 8)&lt;/code&gt; where the character blinked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disposal methods (&lt;code&gt;disposalType&lt;/code&gt;):&lt;/strong&gt; Each frame tells the player what to do with the canvas &lt;em&gt;after&lt;/em&gt; showing it:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; / &lt;code&gt;1&lt;/code&gt; (&lt;strong&gt;Keep&lt;/strong&gt;): Leave these pixels on screen; frame &lt;code&gt;i + 1&lt;/code&gt; paints right over them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt; (&lt;strong&gt;Restore to background&lt;/strong&gt;): Clear &lt;em&gt;only&lt;/em&gt; this frame's &lt;code&gt;dims&lt;/code&gt; rectangle back to transparent before frame &lt;code&gt;i + 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;3&lt;/code&gt; (&lt;strong&gt;Restore to previous&lt;/strong&gt;): Snapshot the canvas &lt;em&gt;before&lt;/em&gt; painting frame &lt;code&gt;i&lt;/code&gt;, display frame &lt;code&gt;i&lt;/code&gt;, then roll back to that snapshot!&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ignore &lt;code&gt;disposalType&lt;/code&gt;, and &lt;code&gt;1&lt;/code&gt; gives you floating eyeballs while &lt;code&gt;2&lt;/code&gt; gives you stacked legs. To output clean game-engine cells like this &lt;code&gt;96×96&lt;/code&gt; sheet:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxw4nm7fxpi717rtgdup0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxw4nm7fxpi717rtgdup0.png" alt="Clean 8-frame slime sprite sheet grid" width="96" height="96"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;…you maintain a running virtual screen buffer and snapshot out of it on every tick:&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="n"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gifWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gifHeight&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="n"&gt;fctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;full&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="s2"&gt;"2d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;willReadFrequently&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCanvas&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="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;pctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;patch&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="s2"&gt;"2d"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;restoreSnapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ImageData&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;HTMLCanvasElement&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;rawFrames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Disposal 3: snapshot the screen BEFORE this frame touches it&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disposalType&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="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;restoreSnapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fctx&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;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="n"&gt;gifWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gifHeight&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="c1"&gt;// Paint this frame's dirty rectangle onto the running screen&lt;/span&gt;
  &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;pctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;putImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8ClampedArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;fctx&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="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Copy the composited full frame into our output list&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gifWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gifHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;out&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="s2"&gt;"2d"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;frames&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="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Honour the frame's cleanup rule before the next tick&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disposalType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&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="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disposalType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;restoreSnapshot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;putImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;restoreSnapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;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;Bonus 1990s browser quirk:&lt;/strong&gt; Any GIF frame delay &lt;code&gt;&amp;lt;= 10ms&lt;/code&gt; (&lt;code&gt;0&lt;/code&gt; or &lt;code&gt;1&lt;/code&gt; hundredth of a second in the binary header) is silently clamped to &lt;strong&gt;&lt;code&gt;100ms&lt;/code&gt; (10 fps)&lt;/strong&gt; by Chrome, Safari, and Firefox. Old GeoCities banner ads left the delay byte at &lt;code&gt;0&lt;/code&gt;, so browsers invented a floor. If your exporter trusts &lt;code&gt;f.delay === 0&lt;/code&gt; literally, your sprite sheet plays 10× faster in Unity or Godot than it ever did in Chrome!&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Cutting Uneven &amp;amp; AI-Generated Sheets (Edge Flood-Fill + Proximity Join)
&lt;/h2&gt;

&lt;p&gt;Clean pixel-art sheets sit on a strict &lt;code&gt;32×32&lt;/code&gt; or &lt;code&gt;64×64&lt;/code&gt; grid. Real-world sheets downloaded from itch.io forums — or generated by AI models — almost never do. They have a solid &lt;code&gt;#00ff00&lt;/code&gt; or &lt;code&gt;#14121f&lt;/code&gt; backdrop, drifted spacing, and detached sword slashes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fff8ja2e26tazh08t1j3v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fff8ja2e26tazh08t1j3v.png" alt="Sprite sheet cutter auto-detecting irregular frames" width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://giftospritesheet.com/sprite-sheet-cutter" rel="noopener noreferrer"&gt;sprite sheet cutter&lt;/a&gt;, two passes turn a messy sheet into jitter-free frames:&lt;/p&gt;

&lt;h3&gt;
  
  
  Pass A: Border-seeded flood fill (so black outlines survive a dark backdrop)
&lt;/h3&gt;

&lt;p&gt;If you simply delete every pixel matching the top-left corner color &lt;code&gt;(kr, kg, kb)&lt;/code&gt;, a character with black boots and black outlines on a dark sheet gets Swiss-cheesed.&lt;/p&gt;

&lt;p&gt;Instead, seed a DFS stack &lt;strong&gt;only from the 4 outer edges of the image&lt;/strong&gt;:&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="n"&gt;stack&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;Int32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;H&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="n"&gt;seen&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="no"&gt;W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;H&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;top&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;push&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&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="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;8&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;colorMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyRgb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tolerance&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="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&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="n"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&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="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Seed ONLY from top/bottom rows and left/right columns&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="n"&gt;x&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="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;W&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;H&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;W&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&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="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="n"&gt;y&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="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;H&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;W&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;W&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;W&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="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setAlphaZero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// push 4-neighbors...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backdrop melts away right up to the outer silhouette, while identical colors &lt;em&gt;inside&lt;/em&gt; the character stay untouched.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pass B: Proximity box merging + foot anchoring
&lt;/h3&gt;

&lt;p&gt;Once the backdrop is transparent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run 8-connected component labeling on &lt;code&gt;alpha &amp;gt; 8&lt;/code&gt; to find every pixel island.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge bounding boxes within &lt;code&gt;join&lt;/code&gt; pixels (e.g. &lt;code&gt;4px&lt;/code&gt;) of each other&lt;/strong&gt; so a detached wand spark or drop shadow stays married to its character instead of becoming "Frame 7.5".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pad every cut frame to the largest frame's &lt;code&gt;W × H&lt;/code&gt; anchored at &lt;code&gt;bottom-center&lt;/code&gt;.&lt;/strong&gt; Why bottom-center instead of dead-center? Because when a character jumps or swings overhead, their height grows upward — center-anchoring makes their feet levitate off the floor!&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  3. Scaling Pixel Art Without Turning It to Soup (&lt;code&gt;EPX&lt;/code&gt; in 15 Lines)
&lt;/h2&gt;

&lt;p&gt;Game devs constantly need a &lt;code&gt;32×32&lt;/code&gt; sprite enlarged to &lt;code&gt;256×256&lt;/code&gt; for Steam capsules, UI portraits, or high-DPI canvases. Bilinear filtering blurs it into vaseline. Plain &lt;strong&gt;Nearest Neighbor&lt;/strong&gt; keeps razor-sharp squares:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8dqwn4wwi71f78113g5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8dqwn4wwi71f78113g5.gif" alt="32x32 slime upscaled 8x with Nearest Neighbor" width="256" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nearest Neighbor is usually what you want inside a retro engine. But what if you want rounded diagonals &lt;strong&gt;without&lt;/strong&gt; inventing blurry anti-aliased colors?&lt;/p&gt;

&lt;p&gt;Enter &lt;strong&gt;EPX (Eric's Pixel Expansion)&lt;/strong&gt; — written by Eric Johnston at LucasArts around 1992 when porting SCUMM engine classics to higher-res color Macs (later famous as &lt;code&gt;Scale2x&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6fblbrz71kbfhhiloa1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6fblbrz71kbfhhiloa1.gif" alt="32x32 slime upscaled with EPX / Scale2x — smooth diagonals, zero new palette colors" width="256" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For every pixel &lt;code&gt;P&lt;/code&gt; with cardinal neighbors &lt;code&gt;A&lt;/code&gt; (up), &lt;code&gt;B&lt;/code&gt; (right), &lt;code&gt;C&lt;/code&gt; (left), &lt;code&gt;D&lt;/code&gt; (down), &lt;code&gt;P&lt;/code&gt; expands into a &lt;code&gt;2×2&lt;/code&gt; block, rounding off corners wherever two adjacent neighbors agree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;epx2x&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;out&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;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="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="n"&gt;y&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="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&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;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;x&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="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&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="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&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="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&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="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&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="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&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="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&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="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;W&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;W&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;W&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&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;Look closely at the assignments: &lt;strong&gt;zero new colors are ever synthesized.&lt;/strong&gt; Every output pixel is copied verbatim from &lt;code&gt;src&lt;/code&gt; via &lt;code&gt;Uint32Array&lt;/code&gt; integer comparisons! And if you actually &lt;em&gt;want&lt;/em&gt; painterly vector-like curves, you can step up to Hyllian's &lt;strong&gt;xBR&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwcqa9376mz9y6soy8kg2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwcqa9376mz9y6soy8kg2.gif" alt="32x32 slime upscaled with xBR" width="256" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotnfrhdunwcgz0dy3aeo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fotnfrhdunwcgz0dy3aeo.png" alt="Pixel art upscaler comparing Nearest, EPX and xBR side-by-side" width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Production gotcha: people often upload "pixel art" from Twitter/Discord that was **already&lt;/em&gt;* enlarged 4× with Nearest Neighbor, so each visible "pixel" is really a &lt;code&gt;4×4&lt;/code&gt; block. Run EPX or xBR on pre-upscaled art and only the microscopic corners of each &lt;code&gt;4×4&lt;/code&gt; block round off! In the &lt;a href="https://giftospritesheet.com/pixel-art-upscaler" rel="noopener noreferrer"&gt;pixel art upscaler&lt;/a&gt;, we scan &lt;code&gt;k = 32..2&lt;/code&gt; first to detect if every &lt;code&gt;k×k&lt;/code&gt; block is identical, and warn you what native scale the sprite is actually hiding at.)*&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Why &lt;code&gt;canvas.toBlob()&lt;/code&gt; Chokes on 200-Frame ZIP Exports
&lt;/h2&gt;

&lt;p&gt;One last browser trap: when someone slices a 180-frame animation and clicks &lt;strong&gt;Frames ZIP&lt;/strong&gt;, calling &lt;code&gt;await canvas.toBlob()&lt;/code&gt; inside a &lt;code&gt;for&lt;/code&gt; loop crawls in Chromium because &lt;code&gt;toBlob&lt;/code&gt; schedules async GPU readbacks and full PNG compression one at a time.&lt;/p&gt;

&lt;p&gt;Instead, grab &lt;code&gt;ctx.getImageData(0, 0, w, h).data.buffer&lt;/code&gt;, encode each PNG synchronously with &lt;code&gt;UPNG.encode([buffer], w, h, 0)&lt;/code&gt;, and bundle all the &lt;code&gt;Uint8Array&lt;/code&gt;s with &lt;code&gt;fflate&lt;/code&gt;'s &lt;code&gt;zipSync&lt;/code&gt; at &lt;code&gt;level: 0&lt;/code&gt; (PNGs are already Deflate-compressed — re-compressing them inside a ZIP burns CPU for ~0.3% savings). Yield every 8 frames with &lt;code&gt;setTimeout(r, 0)&lt;/code&gt; so the UI never drops a frame, and 180 sprites zip up in well under a second.&lt;/p&gt;




&lt;p&gt;All five tools (GIF → sheet, sheet → GIF, cutter, frame packer, and pixel-art upscaler) run 100% locally in your browser at &lt;a href="https://giftospritesheet.com" rel="noopener noreferrer"&gt;giftospritesheet.com&lt;/a&gt; — including direct &lt;code&gt;.tres&lt;/code&gt; SpriteFrames export for Godot 4, TexturePacker JSON, VRChat 1024×1024 emoji presets, and Sparrow v2 XML.&lt;/p&gt;

&lt;p&gt;If you've ever built a custom asset pipeline for a 2D game, what's the most cursed sprite sheet an artist (or AI) ever handed your importer?&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>canvas</category>
    </item>
    <item>
      <title>Single elimination, double elimination or round robin: pick by the numbers</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Fri, 13 Nov 2026 19:25:59 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/single-elimination-double-elimination-or-round-robin-pick-by-the-numbers-3af0</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/single-elimination-double-elimination-or-round-robin-pick-by-the-numbers-3af0</guid>
      <description>&lt;p&gt;The format decides how many games you have to fit into the day and how many each team is guaranteed. Choose from those two numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Games needed for N teams&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single elimination: N − 1&lt;/li&gt;
&lt;li&gt;Double elimination: 2N − 2, plus 1 if the final is replayed&lt;/li&gt;
&lt;li&gt;Round robin: N × (N − 1) ÷ 2&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So 8 teams is 7, 14 or 28 games. 16 teams is 15, 30 or 120.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Games guaranteed per team&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single elimination: 1&lt;/li&gt;
&lt;li&gt;Double elimination: 2&lt;/li&gt;
&lt;li&gt;Round robin: N − 1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose single elimination&lt;/strong&gt; when time or courts are short, or the field is large. It is the only format where 32 teams finish in an afternoon. Half the teams play once and go home.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose double elimination&lt;/strong&gt; when you want every team to play at least twice and the best team to win. It needs about twice the games.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose round robin&lt;/strong&gt; when the group is small and everyone came to play: six friends, a class, a league night. Past ten teams or so it only works over several weeks, or as pools of four or five that feed a bracket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Courts matter as much as teams.&lt;/strong&gt; On one court, 8 teams in double elimination is 14 time slots. On two courts it is 8.&lt;/p&gt;

&lt;p&gt;The full guide, with a table of time slots for one to four courts: &lt;a href="https://bracketmaker.co/blog/single-vs-double-elimination-vs-round-robin" rel="noopener noreferrer"&gt;https://bracketmaker.co/blog/single-vs-double-elimination-vs-round-robin&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Make any of the three, free: &lt;a href="https://bracketmaker.co" rel="noopener noreferrer"&gt;https://bracketmaker.co&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Written with AI assistance.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>sports</category>
    </item>
    <item>
      <title>What one SeniorMatch account saw free and paid, on the same day</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Fri, 13 Nov 2026 19:15:28 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/what-one-seniormatch-account-saw-free-and-paid-on-the-same-day-cg9</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/what-one-seniormatch-account-saw-free-and-paid-on-the-same-day-cg9</guid>
      <description>&lt;p&gt;On 12 November 2026 I read the same SeniorMatch member account twice in one day: in the morning as a free member, in the afternoon with Premium switched on. Same profile, same age range, same gallery. Only the paid tools changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;With the location left on Anywhere and the gallery sorted the default way, the first 40 cards came from 24 different states, provinces and countries. 29 were in the United States, 3 in Canada, the rest elsewhere. The median age was 63.5.&lt;/p&gt;

&lt;p&gt;Then I opened the Advanced tab, which only exists on Premium, and changed one thing: &lt;strong&gt;Sort by, Nearest first&lt;/strong&gt;. 39 cards loaded. All 39 were in Ontario, the province on the profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What paying actually changed
&lt;/h2&gt;

&lt;p&gt;Nobody new appeared. Those 39 people were members before I paid and after, and a free account could have found them by searching the province by name, which costs nothing.&lt;/p&gt;

&lt;p&gt;What Premium changed was the order and the effort. Nearest first puts the people you could drive to at the top without building a search each time. The other 13 advanced filters narrow that list by relationship status, religion, smoking, whether the photo is verified, and so on. And Premium lets you write first. On a free account you can reply when a paying member writes to you, but you cannot open a conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not change
&lt;/h2&gt;

&lt;p&gt;The size of the pool near you, or whether the people in it answer. Premium is a set of tools over the list you can already see. It does not add anyone to it, and it does not screen anyone: SeniorMatch says in capitals in its own service agreement that it runs no criminal background screening, on any tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use that if you are deciding
&lt;/h2&gt;

&lt;p&gt;Set your state and your age range on the free account, then count the people you would actually meet. If that number is small, Premium sorts a short list. If it is healthy, one month is a fair test of whether they write back.&lt;/p&gt;

&lt;p&gt;The longer version, with screenshots and the date of every figure, is at &lt;a href="https://seniormatchreview.com/answers/is-seniormatch-premium-worth-it/" rel="noopener noreferrer"&gt;https://seniormatchreview.com/answers/is-seniormatch-premium-worth-it/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Elena works at SeniorMatch. SeniorMatch Review earns a commission when readers register through its links.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Measuring reaction time in the browser: six things that went wrong</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Fri, 13 Nov 2026 16:19:54 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/measuring-reaction-time-in-the-browser-six-things-that-went-wrong-l0l</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/measuring-reaction-time-in-the-browser-six-things-that-went-wrong-l0l</guid>
      <description>&lt;p&gt;I built a small site with twelve reflex mini-tests (&lt;a href="https://agingcurve.org/" rel="noopener noreferrer"&gt;Aging Curve Test&lt;/a&gt;, in Korean). Timing a click sounds like one subtraction. These are the things that made the numbers wrong before they were right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;event.timeStamp&lt;/code&gt; and &lt;code&gt;performance.now()&lt;/code&gt; are not always the same clock.&lt;/strong&gt;&lt;br&gt;
The obvious code records &lt;code&gt;performance.now()&lt;/code&gt; when the signal is shown and subtracts it from &lt;code&gt;event.timeStamp&lt;/code&gt; when the player clicks. In one Chromium-based browser I tested in, the two values were on different time bases, so every reaction came out negative and was rejected as "too early". The fix was a small function that checks whether an event timestamp is plausibly close to &lt;code&gt;performance.now()&lt;/code&gt; and falls back to &lt;code&gt;performance.now()&lt;/code&gt; when it is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The signal is not on screen when you set the state.&lt;/strong&gt;&lt;br&gt;
Changing a colour in React does not put it on the glass. I record the show time immediately, then correct it inside &lt;code&gt;requestAnimationFrame&lt;/code&gt;, which is closer to the frame the player actually sees. Without that, everyone is one frame faster than they deserve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. One trial is mostly luck.&lt;/strong&gt;&lt;br&gt;
The reaction test takes five trials and reports the median. A single lucky anticipation or one blink no longer moves the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. People anticipate.&lt;/strong&gt;&lt;br&gt;
Anything faster than 100 ms after the signal is treated as a guess and the trial is repeated. So is a press before the signal. The wait before the signal is random so it cannot be counted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Holding a key is not clicking.&lt;/strong&gt;&lt;br&gt;
For the clicks-per-second test, &lt;code&gt;keydown&lt;/code&gt; events with &lt;code&gt;repeat: true&lt;/code&gt; are ignored. Otherwise holding the space bar gives 30 clicks a second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Mistakes need a price.&lt;/strong&gt;&lt;br&gt;
In the choice-reaction, aim, Stroop and go/no-go tests, speed alone rewards mashing. Each error adds a fixed number of milliseconds to the average (25, 15, 35 and 50 ms). The numbers are published on each test's page so nobody has to guess how the score was made.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scoring.&lt;/strong&gt; Each test maps a raw value to 0–100 between two published anchors. For simple reaction time, 190 ms is 100 points and 480 ms is 0. For comparison, Human Benchmark reports a median of 273 ms over 81 million clicks. The six ability scores are averaged and turned into an age with one line: &lt;code&gt;18 + (100 - total) * 0.6&lt;/code&gt;. It is a game, not a measurement of anything medical, and the site says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why 24?&lt;/strong&gt; The curve drawn on the site peaks at 24 because of Thompson, Blair and Henrey (2014), a PLOS ONE study of 3,305 StarCraft 2 players aged 16 to 44 that found in-game reaction times begin to slow at about that age (&lt;a href="https://doi.org/10.1371/journal.pone.0094215" rel="noopener noreferrer"&gt;doi:10.1371/journal.pone.0094215&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Everything runs in the browser: there is no account, and results stay in &lt;code&gt;localStorage&lt;/code&gt;. The scoring rules are written out at &lt;a href="https://agingcurve.org/guide" rel="noopener noreferrer"&gt;agingcurve.org/guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I run the site. This post was drafted with AI assistance from my own notes and code.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>gamedev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>3 Weird Bugs I Hit Rendering Typing-Text GIFs in Plain Canvas (Clipped g's, 1-Bit Halos, and Classic Outlook)</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Fri, 13 Nov 2026 14:04:59 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/3-weird-bugs-i-hit-rendering-typing-text-gifs-in-plain-canvas-clipped-gs-1-bit-halos-and-33pp</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/3-weird-bugs-i-hit-rendering-typing-text-gifs-in-plain-canvas-clipped-gs-1-bit-halos-and-33pp</guid>
      <description>&lt;p&gt;A few weeks ago I shipped &lt;a href="https://typingtextgif.com" rel="noopener noreferrer"&gt;Typing Text GIF&lt;/a&gt;, a small client-side tool that renders typing and typewriter text animations into looping GIFs or APNGs right in the browser.&lt;/p&gt;

&lt;p&gt;You'd think "draw some text on an HTML5 &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; and encode a GIF" is a solved weekend afternoon project. It almost is — until the bug reports roll in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;em&gt;"Why does the bottom of the letter &lt;code&gt;g&lt;/code&gt; get chopped off when I pick a serif font?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Why does my transparent GIF look like a 1998 GeoCities sticker with a crunchy white halo on dark mode?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"I put this in my Outlook signature and my boss says I just sent him a blank rectangle with a pipe character."&lt;/em&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three of these have tiny, surprisingly fun fixes in plain TypeScript. None of them require a backend, ffmpeg, or WASM.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5jfc6yv1u5f6uwtpeza.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5jfc6yv1u5f6uwtpeza.gif" alt="Typing headline GIF rendered in the browser" width="627" height="123"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is what was actually going wrong under the hood.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #1: &lt;code&gt;fontSize&lt;/code&gt; is a lie (why &lt;code&gt;g&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt; lose their tails)
&lt;/h2&gt;

&lt;p&gt;My first draft sized the canvas height to something naive like &lt;code&gt;fontSize * 1.25 + padding * 2&lt;/code&gt; and drew text centered at &lt;code&gt;height / 2&lt;/code&gt; with &lt;code&gt;textBaseline = "middle"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That works fine for &lt;code&gt;Inter&lt;/code&gt; typing &lt;code&gt;"HELLO WORLD"&lt;/code&gt;. Then someone types &lt;code&gt;"gypsy jazz"&lt;/code&gt; in a bouncy display font, or adds an uppercase &lt;code&gt;Å&lt;/code&gt; on the same line as a &lt;code&gt;p&lt;/code&gt;, and the descenders get guillotined right at the bottom edge of the frame.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fontSize: 48&lt;/code&gt; does not mean "every letter fits inside 48 pixels." It's the em-square size. Depending on the font's metrics, ascenders and descenders regularly poke outside it, and &lt;code&gt;"middle"&lt;/code&gt; baseline alignment varies subtly across browser text engines.&lt;/p&gt;

&lt;p&gt;Luckily, modern 2D canvas gives you the font's real metrics right off &lt;code&gt;ctx.measureText()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;measureLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CanvasRenderingContext2D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontCss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fontCss&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Probe string with tall ascenders, deep descenders, and diacritics&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;measureText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Mgjpqy|ÅÉ"&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="n"&gt;ascent&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;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fontBoundingBoxAscent&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actualBoundingBoxAscent&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="n"&gt;descent&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;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fontBoundingBoxDescent&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actualBoundingBoxDescent&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="n"&gt;ascent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;descent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ascent&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;descent&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make this rock-solid:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prefer &lt;code&gt;fontBoundingBoxAscent&lt;/code&gt; over &lt;code&gt;actualBoundingBoxAscent&lt;/code&gt;.&lt;/strong&gt; If you measure the &lt;em&gt;actual&lt;/em&gt; bounding box of each frame as letters type in one by one, &lt;code&gt;"a"&lt;/code&gt; has a tiny height, then &lt;code&gt;"ag"&lt;/code&gt; suddenly gets taller — so your text jumps vertically mid-word! &lt;code&gt;fontBoundingBox*&lt;/code&gt; gives you the font-wide ceiling and floor regardless of which characters have been typed so far.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draw on &lt;code&gt;"alphabetic"&lt;/code&gt; baseline using &lt;code&gt;top + ascent&lt;/code&gt;.&lt;/strong&gt; Instead of guessing where &lt;code&gt;"middle"&lt;/code&gt; lands, compute &lt;code&gt;const y = top + rowIndex * lineHeight + ascent&lt;/code&gt; and set &lt;code&gt;ctx.textBaseline = "alphabetic"&lt;/code&gt;. Every glyph sits on the exact same baseline from frame 1 to frame 90, and &lt;code&gt;g&lt;/code&gt;, &lt;code&gt;j&lt;/code&gt;, &lt;code&gt;p&lt;/code&gt;, &lt;code&gt;q&lt;/code&gt;, &lt;code&gt;y&lt;/code&gt; keep their tails.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Bug #2: The 1998 GeoCities Halo on Transparent GIFs
&lt;/h2&gt;

&lt;p&gt;Canvas renders crisp, anti-aliased text by blending edge pixels to partial alpha (&lt;code&gt;alpha = 0.15&lt;/code&gt;, &lt;code&gt;alpha = 0.62&lt;/code&gt;, etc.).&lt;/p&gt;

&lt;p&gt;The GIF89a spec from 1989 does not know what partial alpha is. In a GIF, a pixel is either &lt;strong&gt;100% opaque&lt;/strong&gt; or &lt;strong&gt;100% transparent&lt;/strong&gt; (1-bit transparency, controlled by a single &lt;code&gt;transparentIndex&lt;/code&gt; in the 256-color palette).&lt;/p&gt;

&lt;p&gt;If you render white text onto a transparent canvas (&lt;code&gt;rgba(0, 0, 0, 0)&lt;/code&gt;) and hand those raw bytes to a GIF quantizer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Semi-transparent edge pixels have RGB values mixed with whatever was underneath (usually black &lt;code&gt;0,0,0&lt;/code&gt; or white &lt;code&gt;255,255,255&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;Every pixel above your alpha threshold snaps to &lt;strong&gt;100% opaque&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;And suddenly your smooth white text has a crusty grey/black outline around every curve.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the user actually needs true 8-bit smooth alpha over an arbitrary gradient, the honest answer is &lt;strong&gt;export APNG&lt;/strong&gt; (browsers and iMessage love APNG, and &lt;code&gt;upng-js&lt;/code&gt; encodes it cleanly).&lt;/p&gt;

&lt;p&gt;But email clients, GitHub READMEs, and Notion embeds still expect &lt;code&gt;.gif&lt;/code&gt;. The trick old-school Photoshop Web Export used — and the one that still works like magic — is &lt;strong&gt;matte pre-blending&lt;/strong&gt;:&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="cm"&gt;/**
 * Blend semi-transparent edge pixels against the target background color
 * BEFORE snapping alpha to 1-bit.
 */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;matteAlpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Uint8ClampedArray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matteHex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&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="n"&gt;mr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matteHex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&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="n"&gt;mg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matteHex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&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="n"&gt;mb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matteHex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&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;let&lt;/span&gt; &lt;span class="n"&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&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="mi"&gt;255&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="n"&gt;a&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;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Composite the glyph's edge pixel over the destination matte color&lt;/span&gt;
    &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&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="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&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="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&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="c1"&gt;// Now snap to 1-bit GIF alpha&lt;/span&gt;
    &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your GIF is going onto a dark GitHub README (&lt;code&gt;#0d1117&lt;/code&gt;), you set the matte to &lt;code&gt;#0d1117&lt;/code&gt;. All the interior pixels of the background stay genuinely transparent (&lt;code&gt;a = 0&lt;/code&gt;), while the 1px anti-aliased fringe around each letter fades toward &lt;code&gt;#0d1117&lt;/code&gt; instead of white. Zero halo.&lt;/p&gt;

&lt;p&gt;Also: remember &lt;code&gt;dispose: 2&lt;/code&gt; (restore to background) in your GIF encoder when &lt;code&gt;transparent&lt;/code&gt; is enabled! Otherwise frame 2 draws right on top of frame 1 instead of clearing the previous cursor position.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #3: Classic Outlook Only Ever Sees Frame 0
&lt;/h2&gt;

&lt;p&gt;If you build a typing animation for &lt;code&gt;"Jane Doe · Founder"&lt;/code&gt;, what is on &lt;strong&gt;Frame 0&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;Usually: an empty canvas with a lonely blinking cursor &lt;code&gt;|&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now drop that GIF into an &lt;a href="https://typingtextgif.com/email-signature-gif" rel="noopener noreferrer"&gt;animated email signature&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs92t2hnxte51558ae18q.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs92t2hnxte51558ae18q.gif" alt="Outlook-safe 400x100 email signature GIF opening on a full line" width="400" height="100"&gt;&lt;/a&gt;&lt;br&gt;
 Gmail, Apple Mail, and modern Outlook on Mac/Web play the loop happily. &lt;strong&gt;Classic Outlook for Windows (2007–2019)&lt;/strong&gt; — which still uses the Microsoft Word rendering engine under the hood — refuses to animate GIFs and freezes permanently on &lt;strong&gt;Frame 0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Congratulations: half of your enterprise recipients just got a blank rectangle with a vertical bar at the bottom of your email.&lt;/p&gt;

&lt;p&gt;You don't need a separate static fallback image to fix this. Since a looping GIF is a ring buffer of frames, &lt;strong&gt;rotating the array doesn't change the loop at all&lt;/strong&gt;:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fullFirstFrame&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Find the exact frame where Phrase 0 finishes typing out&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;holdStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phrase&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chars&lt;/span&gt; &lt;span class="o"&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="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phrases&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Rotate the timeline so Frame 0 IS the completed headline!&lt;/span&gt;
  &lt;span class="k"&gt;return&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="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;holdStart&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="n"&gt;holdStart&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="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;Think about what happens in both worlds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In Gmail / Apple Mail:&lt;/strong&gt; The GIF opens on &lt;code&gt;"Jane Doe · Founder"&lt;/code&gt;, holds for 1.5 seconds with a blinking cursor, backspaces it, types &lt;code&gt;"Book a call: cal.com/jane"&lt;/code&gt;, backspaces, and types &lt;code&gt;"Jane Doe · Founder"&lt;/code&gt; right back to the start of the loop. Seamless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In Classic Outlook on Windows:&lt;/strong&gt; Frame 0 &lt;em&gt;is&lt;/em&gt; &lt;code&gt;"Jane Doe · Founder"&lt;/code&gt;. It looks like a crisp, intentional static banner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One &lt;code&gt;slice&lt;/code&gt; call, bug gone forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus: Fake Typewriter Ink That Doesn't Flicker
&lt;/h2&gt;

&lt;p&gt;When I added the &lt;a href="https://typingtextgif.com/typewriter-text-gif" rel="noopener noreferrer"&gt;typewriter text GIF&lt;/a&gt; mode, I wanted each letter to land slightly crooked, with uneven ribbon ink (&lt;code&gt;alpha&lt;/code&gt; between &lt;code&gt;0.78&lt;/code&gt; and &lt;code&gt;1.0&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktfs2kcg8obzathlypt2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktfs2kcg8obzathlypt2.gif" alt="Typewriter text GIF with deterministic uneven ribbon ink" width="796" height="100"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you reach for &lt;code&gt;Math.random()&lt;/code&gt; inside your &lt;code&gt;draw()&lt;/code&gt; function, every letter jitters wildly like a ransom note having a caffeine overdose, because &lt;code&gt;draw()&lt;/code&gt; runs once &lt;em&gt;per frame&lt;/em&gt;, not once per character!&lt;/p&gt;

&lt;p&gt;Instead, hash the character's index with a tiny deterministic sine hash:&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="n"&gt;noise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&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="n"&gt;x&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="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;127.1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;311.7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;43758.5453&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&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="n"&gt;x&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="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Inside the per-glyph loop:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;dy&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;noise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;glyph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;3.1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;glyph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&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="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;fontSize&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;rot&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;noise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;glyph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;7.7&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.78&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;noise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;glyph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;5.3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the 4th letter &lt;code&gt;'e'&lt;/code&gt; gets the exact same &lt;code&gt;-1.2px&lt;/code&gt; vertical slip and &lt;code&gt;84%&lt;/code&gt; ink density on frame 4, frame 5, and frame 60 — and if you re-export with the same text tomorrow, you get the exact same bytes out.&lt;/p&gt;




&lt;p&gt;If you want to poke around the rest of the canvas + &lt;code&gt;gifenc&lt;/code&gt; implementation, the whole engine is MIT-licensed on GitHub (&lt;code&gt;fancydirty/typing-text-gif&lt;/code&gt;), or you can try breaking it live at &lt;a href="https://typingtextgif.com" rel="noopener noreferrer"&gt;typingtextgif.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What's the weirdest format edge case a 35-year-old spec has ambushed you with lately?&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>The Weirdly Petty Engineering Behind Printing a Straight Barcode Sticker</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Fri, 13 Nov 2026 12:13:14 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/the-weirdly-petty-engineering-behind-printing-a-straight-barcode-sticker-4p76</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/the-weirdly-petty-engineering-behind-printing-a-straight-barcode-sticker-4p76</guid>
      <description>&lt;p&gt;Most software bugs are dramatic. A database locks up, a payment webhook drops on the floor, or an OAuth token expires in the middle of a demo.&lt;/p&gt;

&lt;p&gt;Barcode bugs are not dramatic. They are passive-aggressive.&lt;/p&gt;

&lt;p&gt;Everything looks gorgeous on your 5K monitor. Then you hit Print, peel a sticker off row 9 of an Avery 5160 sheet, and the bottom guard bars are sliced clean across the perforation line. Or worse: you run a thermal roll through a 203 DPI Zebra printer, the label looks crisp to your human eyes, and the fulfillment center scanner refuses to beep.&lt;/p&gt;

&lt;p&gt;Over the past couple of weeks building and rebuilding &lt;a href="https://upclabelmaker.com/" rel="noopener noreferrer"&gt;UPCLabelMaker&lt;/a&gt;, we spent an embarrassing amount of time chasing tiny, stubborn physical-world bugs like these. Not AI prompts. Not distributed consensus. Literally tenths of a millimeter of black ink on sticky paper.&lt;/p&gt;

&lt;p&gt;Here are four of the dumbest, most interesting traps we hit—and how we fixed them after actually decoding and measuring every single generated file.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The 203 DPI Thermal Head Math Problem (Why "Clean Vector" Still Bleeds)
&lt;/h2&gt;

&lt;p&gt;If you print an Amazon FNSKU sticker on a laser sheet, you have 300 or 600 DPI to burn. Nobody cares if a barcode bar is 0.330 mm or 0.334 mm.&lt;/p&gt;

&lt;p&gt;If you print on a direct-thermal shipping printer (Zebra LP2844, Rollo, Munbyn), the printhead usually runs at &lt;strong&gt;203 DPI&lt;/strong&gt;. That means each tiny heating element is exactly &lt;strong&gt;0.125 mm&lt;/strong&gt; wide—or in PostScript points, &lt;code&gt;72 / 203 ≈ 0.35468 pt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now watch what happens when you draw a "mathematically clean" 0.33 mm narrow bar onto a 2 × 1 inch PDF:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0.33 mm / 0.125 mm = 2.64 heating elements.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;A thermal printhead cannot turn on 64% of a resistor. So the printer driver rounds—sometimes 2 dots, sometimes 3 dots—and suddenly your narrow bars and narrow spaces aren't the same width anymore. To a human, it looks fine. To a warehouse laser scanner expecting strict module ratios, it reads like a stutter.&lt;/p&gt;

&lt;p&gt;In our &lt;a href="https://upclabelmaker.com/thermal-2x1-labels" rel="noopener noreferrer"&gt;2 × 1 in thermal label generator&lt;/a&gt; and PDF engine (&lt;code&gt;src/lib/pdf-engine.ts&lt;/code&gt;), we stopped pretending thermal printers have infinite resolution. On roll stock, we snap the narrow module width and the horizontal &lt;code&gt;barsX&lt;/code&gt; starting coordinate directly to integer multiples of &lt;code&gt;72 / 203&lt;/code&gt; points:&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="n"&gt;dotPt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;203&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1 physical heater dot at 203 DPI&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;totalMods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modulesLen&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;quietMods&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;maxDotsPerMod&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="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="n"&gt;maxSymW&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;totalMods&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;dotPt&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;idealDots&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawModuleW&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;dotPt&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="n"&gt;dotsPerMod&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="n"&gt;maxDotsPerMod&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idealDots&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;moduleW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dotsPerMod&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dotPt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;barsX&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawBarsX&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;dotPt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dotPt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two heating elements per bar (&lt;code&gt;0.25 mm&lt;/code&gt;) on a 2 × 1 inch label leaves a solid &lt;strong&gt;7.2 mm&lt;/strong&gt; of clear white space on both sides of a 10-character X00 FNSKU. Every bar lands squarely on physical heater elements instead of fighting the driver's dithering.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The "Helpful" Truncation Bug That Prints Someone Else's Barcode
&lt;/h2&gt;

&lt;p&gt;When we built an automated verification script to decode all 48 combinations of our PDF matrix (4 barcode formats × 4 paper stocks × 3 layout modes) plus single-label exports, it immediately caught a classic developer assumption.&lt;/p&gt;

&lt;p&gt;Suppose a seller pastes a &lt;strong&gt;14-digit GTIN-14&lt;/strong&gt; case code into a &lt;strong&gt;UPC-A&lt;/strong&gt; form (which takes 11 or 12 digits).&lt;/p&gt;

&lt;p&gt;What does a "forgiving" form usually do? It slices the string to 11 digits and recomputes a check digit.&lt;/p&gt;

&lt;p&gt;Wait—think about what that actually does in a warehouse. If you chop 3 digits off a 14-digit GTIN-14 and recalculate the Modulo-10 check digit, you still get a &lt;strong&gt;100% valid, scannable UPC-A barcode&lt;/strong&gt;. It just belongs to a completely different product.&lt;/p&gt;

&lt;p&gt;That is way worse than an error message. So we ripped out silent truncation. Now, if someone enters 14 digits on the &lt;a href="https://upclabelmaker.com/upc-label-generator" rel="noopener noreferrer"&gt;UPC-A barcode generator&lt;/a&gt; or &lt;a href="https://upclabelmaker.com/ean-13-label-generator" rel="noopener noreferrer"&gt;EAN-13 label generator&lt;/a&gt;, it flat-out refuses to truncate it and tells them a 14-digit code belongs in a &lt;a href="https://upclabelmaker.com/gs1-datamatrix-generator" rel="noopener noreferrer"&gt;GS1 DataMatrix 2D label&lt;/a&gt; (where AI &lt;code&gt;(01)&lt;/code&gt; expects GTIN-14). And if you just want to check or fix a broken 12th/13th digit without guessing, the &lt;a href="https://upclabelmaker.com/check-digit-calculator" rel="noopener noreferrer"&gt;check digit calculator&lt;/a&gt; flags mismatches explicitly.&lt;/p&gt;

&lt;p&gt;Same deal in our &lt;a href="https://upclabelmaker.com/bulk-barcode-generator" rel="noopener noreferrer"&gt;bulk barcode generator&lt;/a&gt;: if a CSV row has a wrong check digit, we list it and skip it instead of silently altering your inventory IDs.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Why Your PNG Barcode Looks Giant When Pasted Into Word
&lt;/h2&gt;

&lt;p&gt;Export a single 1D barcode as a PNG for a product packaging mockup or a Word sheet, drag it into Microsoft Word, and suddenly it takes up half the page.&lt;/p&gt;

&lt;p&gt;Why? Because HTML &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; exports PNGs with &lt;strong&gt;no &lt;code&gt;pHYs&lt;/code&gt; DPI metadata chunk&lt;/strong&gt; (or browsers default it to 72/96 DPI). So when you draw a crisp high-res image at 600 DPI pixel dimensions (e.g. 900 px wide) without writing the physical pixel-per-meter chunk into the PNG binary, Word assumes 96 DPI and blows your 1.46-inch UPC up to 9+ inches.&lt;/p&gt;

&lt;p&gt;Our single-label PNG exporter writes the exact &lt;code&gt;pHYs&lt;/code&gt; chunk (&lt;code&gt;23622 pixels/meter = 600 DPI&lt;/code&gt;) into the PNG byte stream before download, and locks the module width to the exact &lt;code&gt;0.330 mm&lt;/code&gt; GS1 100% spec. Drop it into Word or Figma and it lands at physical retail size on the first try.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Word Templates Lie About Row Height (and Seller Central Wastes 60% of Your Sheet)
&lt;/h2&gt;

&lt;p&gt;Two things constantly surprise people who just want to print stickers without learning GS1 specifications:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Why almost every free Avery 5160 Word template drifts by Row 7:&lt;/strong&gt; In Word, table rows default to &lt;em&gt;"At least 1.0 in"&lt;/em&gt; instead of &lt;em&gt;"Exactly 1.0 in"&lt;/em&gt; (1440 twips). The moment one product name wraps to two lines, row 4 grows by 4 millimeters, and rows 5 through 10 quietly walk off their die-cut stickers. Our downloadable &lt;a href="https://upclabelmaker.com/avery-5160-word-template" rel="noopener noreferrer"&gt;.docx templates for Avery 5160&lt;/a&gt; and &lt;a href="https://upclabelmaker.com/google-docs-label-template" rel="noopener noreferrer"&gt;Google Docs label templates&lt;/a&gt; use fixed table layouts, explicit spacer columns for the &lt;code&gt;3.175 mm&lt;/code&gt; horizontal gaps, and exact twip row heights so text can never shove row 8 down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why Amazon Seller Central wastes partial sticker sheets:&lt;/strong&gt; If you download an FNSKU PDF from Seller Central, it puts &lt;strong&gt;one SKU per 30-up page&lt;/strong&gt;. Ship 8 units each of 3 different products? Enjoy throwing away 66 blank stickers. We added multi-SKU mixing on our &lt;a href="https://upclabelmaker.com/amazon-fnsku-label-maker" rel="noopener noreferrer"&gt;Amazon FNSKU label generator&lt;/a&gt; (plus an &lt;a href="https://upclabelmaker.com/fba-label-converter" rel="noopener noreferrer"&gt;FBA 30-up PDF to 2×1 thermal converter&lt;/a&gt; that parses the PDF text streams directly in pure TypeScript without spawning headless Chrome or Ghostscript), and wrote up the exact FBA sticker dimensions in our &lt;a href="https://upclabelmaker.com/guides/amazon-fnsku-label-size" rel="noopener noreferrer"&gt;Amazon FNSKU label size guide&lt;/a&gt; and &lt;a href="https://upclabelmaker.com/guides/x00-barcode" rel="noopener noreferrer"&gt;X00 barcode guide&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Lesson: Screenshots Lie, Decoders Don't
&lt;/h2&gt;

&lt;p&gt;Early on, we judged label outputs by looking at screenshots. Bad idea. A barcode that is 0.4 mm too narrow or a title that clips 2 pixels into a quiet zone looks totally normal to a developer glancing at a preview.&lt;/p&gt;

&lt;p&gt;Nothing keeps you honest like wiring up a headless verification script that actually generates every PDF, rasterizes it, decodes the barcodes with a real scanner library, and measures bounding boxes against the physical paper margins. Every time we run that check, we trust our eyes a little less and our calipers a little more.&lt;/p&gt;

&lt;p&gt;If you've ever fought a printer scaling dialog ("Fit to Printable Area" is the mortal enemy of sticker sheets—always use &lt;strong&gt;Actual Size / 100%&lt;/strong&gt;) or had to convert Seller Central's 30-up PDFs for a thermal roll, feel free to poke at &lt;a href="https://upclabelmaker.com/" rel="noopener noreferrer"&gt;UPCLabelMaker&lt;/a&gt; or our &lt;a href="https://upclabelmaker.com/avery-5160-template" rel="noopener noreferrer"&gt;plain-paper Avery 5160 alignment test&lt;/a&gt; and tell me what still breaks on your printer.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>saas</category>
    </item>
    <item>
      <title>What makes a custom Yu-Gi-Oh! card look real</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Wed, 11 Nov 2026 15:40:12 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/what-makes-a-custom-yu-gi-oh-card-look-real-4e65</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/what-makes-a-custom-yu-gi-oh-card-look-real-4e65</guid>
      <description>&lt;p&gt;The fastest way to make a custom Yu-Gi-Oh! card is a browser card maker: pick a frame, add a picture, type the name, Type line, effect text and ATK/DEF, then download the PNG. No account, nothing to install. What takes time is making the result read like a card Konami might have printed. The long version, with stat tables and comparisons, is in the &lt;a href="https://yugiohcardmaker.co/blog/how-to-make-custom-yugioh-cards" rel="noopener noreferrer"&gt;full guide to making custom Yu-Gi-Oh! cards&lt;/a&gt;; this is the short one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes on a card, part by part
&lt;/h2&gt;

&lt;p&gt;The rulebook labels seven things on a Monster Card: name, Attribute, Level, Type, card number, ATK/DEF and description box. Printed cards carry a little more.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name&lt;/strong&gt; — the top bar. Two cards with the same name count as the same card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribute&lt;/strong&gt; — top right. DARK, EARTH, FIRE, LIGHT, WATER or WIND. Spells and Traps show a SPELL or TRAP symbol instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level, Rank or Link&lt;/strong&gt; — Level stars sit under the name and run from the right, 0 to 12. Xyz Monsters use a Rank with stars starting from the left. Link Monsters have neither; the Link Rating sits bottom right and equals the number of arrows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type line&lt;/strong&gt; — first line of the text box, in brackets, in this order: Type, card type, ability, Tuner, then Effect or Normal, e.g. &lt;code&gt;[Zombie/Tuner/Effect]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Card text&lt;/strong&gt; — effect text, or italic flavour text on a Normal Monster. Extra Deck monsters list materials on the first line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ATK/DEF&lt;/strong&gt; — bottom of the text box, multiples of 100 on most cards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set number&lt;/strong&gt; — under the picture on the right, in the form AAAA-RR###. Invent your own, such as FAN-EN001. &lt;strong&gt;Passcode&lt;/strong&gt; goes bottom left, eight digits; Tokens have none.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Leave off Konami's copyright line and the foil Eye of Anubis square: they mark an official card, yours is not one. Put your own credit there instead, and do not reuse a real card's passcode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the frame before anything else
&lt;/h2&gt;

&lt;p&gt;The frame colour tells a player how the card is summoned, so it has to match. Yellow is a Normal Monster with no effect, orange an Effect Monster, blue a Ritual. Fusion is violet, Synchro white, Xyz black, Link dark blue with a honeycomb texture; those four need materials. Spells are green, Traps purple, Tokens grey.&lt;/p&gt;

&lt;p&gt;For a gift or a joke card, take the yellow Normal frame over the orange one every time. A Normal Monster gets two lines of italic flavour text, which is exactly where the joke about your friend goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stats that look right for the Level
&lt;/h2&gt;

&lt;p&gt;Level is not decoration. A Level 5 or 6 monster needs one Tribute to Normal Summon and Level 7 or higher needs two, so stats climb with Level. The observed ceilings are roughly 1,750 ATK at Level 3, 2,000 at Level 4, 2,600 at Level 6, 3,000 at Level 8, 4,000 at Level 10 and 5,000 at Level 12; cards that break a ceiling usually carry a drawback.&lt;/p&gt;

&lt;p&gt;Effects are paid for in stats. Blue-Eyes White Dragon does nothing and sits at 3000; Elemental HERO Stratos searches your deck and sits at 1800. If your Level 4 has a good effect, 1,500 to 1,800 ATK is the honest range. A Level 4 with 2,000 ATK and a paragraph of upside is the classic first custom card, and the table will groan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing an effect that reads like a real one
&lt;/h2&gt;

&lt;p&gt;Since 2011 English cards have used Problem-Solving Card Text, and the punctuation carries rules. Text before a colon is the activation condition; before a semicolon is what you do to activate it, such as paying a cost or choosing a target; the rest is what happens. Write one effect in that shape, give it a real cost, and add the modern limiter: &lt;em&gt;You can only use this effect of "Your Card's Name" once per turn.&lt;/em&gt; Recent printings also say GY where old cards spelled out Graveyard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting artwork and printing it
&lt;/h2&gt;

&lt;p&gt;The picture window on a standard frame is square, so draw wider than you need and let the maker crop to fill; you can drag and zoom afterwards. The large download is 1,626 × 2,370 pixels, so artwork around 1,300 pixels on a side is plenty.&lt;/p&gt;

&lt;p&gt;Printing is where scale gets people. A Yu-Gi-Oh! card is 59 × 86 mm, smaller than a poker card, so do not let the printer dialog resize it. The &lt;a href="https://yugiohcardmaker.co/" rel="noopener noreferrer"&gt;card maker&lt;/a&gt; saves your cards so it can lay nine of them on a Letter or A4 sheet at that size, 300 dpi, with cut marks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong
&lt;/h2&gt;

&lt;p&gt;Too much text is the big one. Real cards squeeze letters sideways before dropping to a smaller size, and a good maker does the same, but the smallest sizes are hard to read. Mixed-up mechanics give a card away fastest: a Link Monster with a DEF value, an Xyz Monster described as "Level 4", a Synchro with no materials line, a yellow Normal frame with an effect in the box. A player spots any of those in a second.&lt;/p&gt;

&lt;p&gt;One limit before you sleeve a card up for locals: Konami's tournament policy (version 2.5, in effect since September 5, 2025) bans proxy and counterfeit cards from sanctioned events even if you own the real card. Custom cards are for kitchen-table games where everyone agreed to them, never for selling as the real thing.&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>gamedev</category>
      <category>design</category>
    </item>
    <item>
      <title>CSS sprite sheet animation: getting steps() right</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Tue, 10 Nov 2026 18:44:53 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/css-sprite-sheet-animation-getting-steps-right-539n</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/css-sprite-sheet-animation-getting-steps-right-539n</guid>
      <description>&lt;p&gt;Put the sheet on an element as a background, size the element to one frame, and animate &lt;code&gt;background-position&lt;/code&gt; across the sheet with a &lt;code&gt;steps()&lt;/code&gt; timing function. For 8 frames of 64 × 64 in one row:&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="nc"&gt;.sprite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="cm"&gt;/* one frame */&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&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="s2"&gt;"walk.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;no-repeat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;play&lt;/span&gt; &lt;span class="mf"&gt;0.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* 8 frames at 10 fps */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;play&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-512px&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="cm"&gt;/* 8 x 64px: the whole sheet */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;div&lt;/span&gt; &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sprite"&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"img"&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Character walking"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every snippet ran in Chromium 156, reading computed &lt;code&gt;background-position&lt;/code&gt; every 5 ms; not Safari or Firefox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why steps(N) ends at the full sheet width
&lt;/h2&gt;

&lt;p&gt;Duration is frames over frame rate. The end position trips people up: the last frame sits at -448px (7 × 64), so that looks like the stop. But &lt;code&gt;steps(8)&lt;/code&gt; is &lt;code&gt;steps(8, end)&lt;/code&gt; — 8 equal holds, the last jump at the end — so the &lt;code&gt;to&lt;/code&gt; value never shows. Ending at -512px lands the holds on 0, -64, -128 … -448.&lt;/p&gt;

&lt;p&gt;The classic bug mirrors it: end at -448px with &lt;code&gt;steps(8)&lt;/code&gt; and every step is 56px, so the first frames look fine and the last show two halves.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;jump-none&lt;/code&gt; form is the other correct answer, and I write it by hand even though a forward loop looks identical:&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="nc"&gt;.sprite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;play&lt;/span&gt; &lt;span class="mf"&gt;0.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jump-none&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;play&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-448px&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="cm"&gt;/* 7 x 64px: the last frame */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A sheet with more than one row
&lt;/h2&gt;

&lt;p&gt;One &lt;code&gt;steps()&lt;/code&gt; walks in a straight line; a grid must go across, drop, and go across again. Use one keyframe per frame with &lt;code&gt;steps(1)&lt;/code&gt;, which holds each value until the next:&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="nc"&gt;.sprite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&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="s2"&gt;"walk-grid.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;no-repeat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* 4 columns x 2 rows */&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;play&lt;/span&gt; &lt;span class="mf"&gt;0.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&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="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;play&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="mf"&gt;12.5&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-64px&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-128px&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="mf"&gt;37.5&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-192px&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;-64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="mf"&gt;62.5&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-64px&lt;/span&gt; &lt;span class="n"&gt;-64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-128px&lt;/span&gt; &lt;span class="n"&gt;-64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="mf"&gt;87.5&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-192px&lt;/span&gt; &lt;span class="n"&gt;-64px&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;Each percentage is frame index over frame count, with no 100% keyframe on purpose, so the last frame holds to the end. Uneven timing is free — keyframes 10% apart held frame 8 for 240 ms.&lt;/p&gt;

&lt;p&gt;Nobody types that for 40 frames. The &lt;a href="https://giftospritesheet.com/" rel="noopener noreferrer"&gt;GIF to sprite sheet tool&lt;/a&gt; and its sprite sheet maker both write this block, or the &lt;code&gt;steps(N)&lt;/code&gt; version for a single row, from a &lt;strong&gt;Copy CSS&lt;/strong&gt; button.&lt;/p&gt;

&lt;p&gt;For a full grid, two animations on separate axes do the same job:&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="nc"&gt;.sprite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&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="s2"&gt;"walk-grid.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;no-repeat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;across&lt;/span&gt; &lt;span class="mf"&gt;0.4s&lt;/span&gt; &lt;span class="nf"&gt;steps&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="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="cm"&gt;/* one row: 4 frames */&lt;/span&gt;
    &lt;span class="n"&gt;down&lt;/span&gt;   &lt;span class="mf"&gt;0.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&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="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="cm"&gt;/* 2 rows, each lasting one "across" */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;across&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position-x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-256px&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="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;down&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position-y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-128px&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;Same eight positions at the same times in my test — but not with empty cells, which play as blanks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling up without blur
&lt;/h2&gt;

&lt;p&gt;Multiply four numbers by one whole number — width, height, &lt;code&gt;background-size&lt;/code&gt;, end position — and add &lt;code&gt;image-rendering: pixelated&lt;/code&gt;. At 4×:&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="nc"&gt;.sprite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="cm"&gt;/* 64 x 4 */&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&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="s2"&gt;"walk.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2048px&lt;/span&gt; &lt;span class="mi"&gt;256px&lt;/span&gt; &lt;span class="n"&gt;no-repeat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* sheet size x 4 */&lt;/span&gt;
  &lt;span class="nl"&gt;image-rendering&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pixelated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;play&lt;/span&gt; &lt;span class="mf"&gt;0.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;play&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-2048px&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;background-size&lt;/code&gt; is the forgotten one. Without it the 512px sheet sits in a 256px box stepping 256px at a time, so I got frames 1–4, then 5–8, then nothing. MDN defines &lt;code&gt;pixelated&lt;/code&gt; as nearest-neighbour scaling to the nearest whole multiple.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making it responsive
&lt;/h3&gt;

&lt;p&gt;Percentages instead of pixels. &lt;code&gt;background-size: 800%&lt;/code&gt; makes an 8-frame row eight elements wide at any width. Positions measure from the far edge: 100% puts the last frame in the box, not one past it — &lt;code&gt;jump-none&lt;/code&gt; again.&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="nc"&gt;.sprite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;down&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* largest whole multiple of the frame that fits */&lt;/span&gt;
  &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;aspect-ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&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="s2"&gt;"walk.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="n"&gt;no-repeat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* 8 frames wide */&lt;/span&gt;
  &lt;span class="nl"&gt;image-rendering&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pixelated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;play&lt;/span&gt; &lt;span class="mf"&gt;0.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jump-none&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;play&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;round()&lt;/code&gt; keeps the scale whole: 192px in a 200px column, exactly 3×, where plain &lt;code&gt;width: 100%&lt;/code&gt; gave 3.125× and mixed 3- and 4-pixel art pixels. It shipped in current browsers in May 2024; otherwise set a pixel width per breakpoint. For a grid, use &lt;code&gt;background-size: 400% 200%&lt;/code&gt; and offsets of column ÷ (columns - 1).&lt;/p&gt;

&lt;h2&gt;
  
  
  Play once, reverse, pause on hover
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* Shared keyframes that stop on the last frame */&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;play-to-last&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-448px&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="cm"&gt;/* (8 - 1) x 64px */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* Play once and hold the last frame */&lt;/span&gt;
&lt;span class="nc"&gt;.sprite.once&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;play-to-last&lt;/span&gt; &lt;span class="mf"&gt;0.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jump-none&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;forwards&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* Backwards, 8 down to 1 */&lt;/span&gt;
&lt;span class="nc"&gt;.sprite.reverse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;play-to-last&lt;/span&gt; &lt;span class="mf"&gt;0.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jump-none&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* There and back: 1 to 8, then 8 to 1 */&lt;/span&gt;
&lt;span class="nc"&gt;.sprite.ping-pong&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;play-to-last&lt;/span&gt; &lt;span class="mf"&gt;0.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jump-none&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt; &lt;span class="n"&gt;alternate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* Freeze while the pointer is over it */&lt;/span&gt;
&lt;span class="nc"&gt;.sprite.pause-on-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation-play-state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* Still until hovered or focused */&lt;/span&gt;
&lt;span class="nc"&gt;.sprite.play-on-hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation-play-state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.sprite.play-on-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;hover&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.sprite.play-on-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;focus-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation-play-state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;running&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;Add &lt;code&gt;forwards&lt;/code&gt; to the classic &lt;code&gt;steps(8)&lt;/code&gt; rule and the sprite finishes on -512px, one frame past the sheet: an empty box under &lt;code&gt;no-repeat&lt;/code&gt;, a snap to frame 1 without it. That is why these rules use &lt;code&gt;jump-none&lt;/code&gt;. Hover does not exist on touch, so put play-on-hover on a focusable element — hence &lt;code&gt;:focus-visible&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Respect reduced motion
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.sprite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;background-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-192px&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* frame 4, the one that reads best as a still */&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;animation: none&lt;/code&gt; alone leaves frame 1 showing, which is not always the best still. In a ping-pong, frames 1 and 8 each hold twice at the turns — usually a natural pause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canvas, for per-frame timing or many sprites
&lt;/h2&gt;

&lt;p&gt;CSS runs out when game logic picks the frame, when durations change at run time, or when one element per sprite gets silly. The canvas version is the 9-argument &lt;code&gt;drawImage&lt;/code&gt;: source rectangle from the sheet, destination on the canvas.&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;canvas&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"stage"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/canvas&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;canvas&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"stage"&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="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;canvas&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="s2"&gt;"2d"&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="no"&gt;FRAME_W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FRAME_H&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;COLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// a 4 x 2 grid sheet&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;durations&lt;/span&gt; &lt;span class="o"&gt;=&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="mi"&gt;100&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="mi"&gt;100&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="mi"&gt;100&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="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ms per frame&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;durations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;scale&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;dpr&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="n"&gt;devicePixelRatio&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="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;FRAME_W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;FRAME_H&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;FRAME_W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;"px"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;FRAME_H&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;"px"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;imageSmoothingEnabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// after resizing, which resets the context&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sheet&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;Image&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="n"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"walk-grid.png"&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;frameAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;total&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="n"&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;durations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&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="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;durations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&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;return&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;durations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frameAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="no"&gt;COLS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;FRAME_W&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sy&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="no"&gt;COLS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;FRAME_H&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&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="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;ctx&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="n"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FRAME_W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FRAME_H&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="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&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="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tick&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="n"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&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="nf"&gt;then&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tick&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="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frame comes from elapsed time, not from counting &lt;code&gt;requestAnimationFrame&lt;/code&gt; calls, so speed does not follow the refresh rate. My run drew at about 30 fps: 100 ms frames changed 0–33 ms late, the 300 ms hold lasted 317 ms. The &lt;code&gt;durations&lt;/code&gt; array maps onto the &lt;code&gt;duration&lt;/code&gt; field in the JSON atlas the tool exports. One trap: resizing a canvas resets &lt;code&gt;imageSmoothingEnabled&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frames slide sideways.&lt;/strong&gt; No &lt;code&gt;steps()&lt;/code&gt;, or a later &lt;code&gt;animation&lt;/code&gt; shorthand that drops the timing function, resetting it to &lt;code&gt;ease&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Half of two frames.&lt;/strong&gt; The step size is not the frame width (448 ÷ 8 is 56), or the sheet has gaps, making the stride frame plus gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blurry.&lt;/strong&gt; Missing &lt;code&gt;image-rendering: pixelated&lt;/code&gt;, a fractional scale, or a PNG smoothed before it reached the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uneven pixels.&lt;/strong&gt; Frames that are not a whole number of screen pixels wide: a percentage width, &lt;code&gt;transform: scale(1.37)&lt;/code&gt;, a parent in &lt;code&gt;vw&lt;/code&gt;. At 77.3px a 2px outline rendered 1px.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too big for a phone.&lt;/strong&gt; Decoded, a 100-frame row of 256px frames is about 26 MB, and iOS caps a canvas at 4,096 × 4,096.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sprite sheet, GIF or video?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Sheet (PNG)&lt;/th&gt;
&lt;th&gt;GIF&lt;/th&gt;
&lt;th&gt;Animated WebP / APNG&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Colour&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;256 colours&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transparency&lt;/td&gt;
&lt;td&gt;Full alpha&lt;/td&gt;
&lt;td&gt;On/off&lt;/td&gt;
&lt;td&gt;Full alpha&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pause, reverse, play once&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code needed&lt;/td&gt;
&lt;td&gt;The CSS above&lt;/td&gt;
&lt;td&gt;An &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;An &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Control is the reason to pick a sheet: a GIF plays when it loads, and CSS cannot pause it or stop it for reduced motion. GIF transparency is all or nothing too, so shadows get a hard fringe. File size I will not promise either way — export both and look. If nobody interacts with it, an animated WebP is simpler; past a few seconds, use video.&lt;/p&gt;

&lt;p&gt;The full version, with every measurement and the browser sources, is at &lt;a href="https://giftospritesheet.com/blog/css-sprite-sheet-animation" rel="noopener noreferrer"&gt;CSS sprite sheet animation on giftospritesheet.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>gamedev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Reading Phantom Blade Zero's PBZS v3 save container in the browser</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Tue, 10 Nov 2026 12:08:37 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/reading-phantom-blade-zeros-pbzs-v3-save-container-in-the-browser-jo</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/reading-phantom-blade-zeros-pbzs-v3-save-container-in-the-browser-jo</guid>
      <description>&lt;p&gt;Phantom Blade Zero keeps every save slot in one file, &lt;code&gt;PBZSaveGame.sav&lt;/code&gt;. I wanted a save editor that runs entirely in a browser tab, so nothing gets uploaded, and that meant reading the container in JavaScript. These are my notes on the format, called PBZS v3 after its magic bytes, and on the three things that cost me the most time.&lt;/p&gt;

&lt;p&gt;The container layout builds on the MIT-licensed PBZ-Editor project and the oozextract contributors. What I added is the browser implementation and the checks described below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the file lives
&lt;/h2&gt;

&lt;p&gt;On Steam the save is not in &lt;code&gt;%LOCALAPPDATA%&lt;/code&gt; like most Unreal Engine games. It sits inside the install folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;SteamLibrary&amp;gt;\steamapps\common\PBZ\ProjectPBZ\Saved\SaveGames\Steam\&amp;lt;17-digit SteamID&amp;gt;\PBZSaveGame.sav
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same folder holds &lt;code&gt;1RealTime&lt;/code&gt;, &lt;code&gt;2Hourly&lt;/code&gt; and &lt;code&gt;3Daily&lt;/code&gt;, which are rolling backups of the whole container.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 12-byte header
&lt;/h2&gt;

&lt;p&gt;The first 12 bytes are cleartext:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offset&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0x00&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;ASCII &lt;code&gt;PBZS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0x04&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Container version, &lt;code&gt;0x03&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0x05&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Flags, observed as &lt;code&gt;0x01&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0x06&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Slot count, uint16 LE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0x08&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Size of the encrypted directory block, uint32 LE&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Sealed blocks: the tag comes first
&lt;/h2&gt;

&lt;p&gt;After the header comes an encrypted directory, then one encrypted block per slot. Every sealed block has the same 36-byte preamble:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PBZS 01 01 00 00 (8 B) | nonce (12 B) | GCM tag (16 B) | ciphertext
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the first trap. AES-GCM libraries, WebCrypto included, expect &lt;code&gt;ciphertext || tag&lt;/code&gt;. Here the tag sits before the ciphertext, so you have to move those 16 bytes to the end before decrypting:&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="n"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;36&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="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&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="n"&gt;joined&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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;joined&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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;joined&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="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&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="n"&gt;plain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"AES-GCM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;joined&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 decrypted directory is a packed list of entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint16 nameLen | name (UTF-8) | uint8 algorithm | uint8 flag | uint32 rawSize | uint32 offset | uint32 blockSize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slot names look like &lt;code&gt;AutoSave0&lt;/code&gt;, &lt;code&gt;ManualSave1&lt;/code&gt; and &lt;code&gt;__Advanced__&lt;/code&gt;. The last one holds account-wide state such as the wardrobe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compression: Oodle Kraken in the browser
&lt;/h2&gt;

&lt;p&gt;Each slot decrypts to a compressed buffer, and the &lt;code&gt;algorithm&lt;/code&gt; byte says how: &lt;code&gt;0&lt;/code&gt; is raw JSON, &lt;code&gt;1&lt;/code&gt; is zlib, &lt;code&gt;2&lt;/code&gt; is Oodle Kraken. The game writes Kraken for everything.&lt;/p&gt;

&lt;p&gt;That was the second trap, because there is no Kraken decoder in a browser. I compiled an open-source Kraken decompressor to WebAssembly. The format also defines zlib and uncompressed slots, so a writer has options. When the editor saves, every slot you did not touch keeps its original ciphertext byte for byte.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key is a per-build constant
&lt;/h2&gt;

&lt;p&gt;The AES-256 key is not in the save and is not derived from the Steam account. It is a constant in &lt;code&gt;PBZ.exe&lt;/code&gt;: a 64-byte block that follows the UTF-16LE string &lt;code&gt;sealed but block is PLAINTEXT&lt;/code&gt;. XOR the first 32 bytes with the last 32 and you have the key. GCM authentication of the directory block tells you whether you found the right one.&lt;/p&gt;

&lt;p&gt;Two consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Saves are not bound to an account. The 17-digit SteamID only names the folder.&lt;/li&gt;
&lt;li&gt;A game update could change the key. Patch 1.0302256 (4 November 2026) did not: a save written two days after it still opens with the key from the launch build.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is inside a slot
&lt;/h2&gt;

&lt;p&gt;A slot is plain UTF-8 JSON. Inventory lives in trees such as &lt;code&gt;MainWeaponInfos&lt;/code&gt;, &lt;code&gt;ShaChiWeaponInfo&lt;/code&gt;, &lt;code&gt;AccessoryInfos&lt;/code&gt; and &lt;code&gt;MedicineInfos&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The third trap was mine. I first read &lt;code&gt;WeaponLevel&lt;/code&gt; as a small "+N" grade with an offset. It is simply the level the upgrade screen shows: 1 for a new weapon, 30 at the first-playthrough cap, up to 60 in New Game Plus. A finished Cycle 10 save holds every weapon at 60. Check your assumptions against a real late-game file.&lt;/p&gt;

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

&lt;p&gt;The editor and the full byte-level write-up are here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Save editor: &lt;a href="https://phantombladezerosaves.com/save-editor/" rel="noopener noreferrer"&gt;https://phantombladezerosaves.com/save-editor/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Format specification: &lt;a href="https://phantombladezerosaves.com/save-file-format/" rel="noopener noreferrer"&gt;https://phantombladezerosaves.com/save-file-format/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Spec as a repository: &lt;a href="https://github.com/fancydirty/phantom-blade-zero-save-format" rel="noopener noreferrer"&gt;https://github.com/fancydirty/phantom-blade-zero-save-format&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is an unofficial fan project and is not affiliated with S-GAME. Back up the whole SteamID folder before you replace or edit anything.&lt;/p&gt;

</description>
      <category>reverseengineering</category>
    </item>
    <item>
      <title>CSS typing animation: a typewriter effect that survives any font, any length and line wraps</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Tue, 10 Nov 2026 10:31:09 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/css-typing-animation-a-typewriter-effect-that-survives-any-font-any-length-and-line-wraps-4hfh</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/css-typing-animation-a-typewriter-effect-that-survives-any-font-any-length-and-line-wraps-4hfh</guid>
      <description>&lt;p&gt;For a single line in a monospace font, CSS can do this on its own: animate the element's width from zero to the length of the text in &lt;code&gt;ch&lt;/code&gt; units, use &lt;code&gt;steps()&lt;/code&gt; with the same number so it grows one character at a time, and let a right border be the cursor. Copy the first block below. If your font isn't monospace, the text needs to wrap, or someone else will be editing the words, skip to the per-letter version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The copy-paste version
&lt;/h2&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;p&lt;/span&gt; &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"typing"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Ship it before the coffee cools.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.typing&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32ch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="cm"&gt;/* characters in the line, spaces included */&lt;/span&gt;
  &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nowrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ui-monospace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Menlo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Consolas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monospace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1em&lt;/span&gt; &lt;span class="n"&gt;solid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* the cursor */&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="mf"&gt;3.2s&lt;/span&gt; &lt;span class="nf"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;blink&lt;/span&gt; &lt;span class="mf"&gt;0.75s&lt;/span&gt; &lt;span class="n"&gt;step-end&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;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;typing&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;blink&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transparent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.typing&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;none&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;When you change the text, change two numbers. &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;steps()&lt;/code&gt; both have to equal the character count, and spaces and punctuation count too. "Ship it before the coffee cools." is 32 characters, so it's &lt;code&gt;32ch&lt;/code&gt; and &lt;code&gt;steps(32)&lt;/code&gt;. The duration is up to you; 100 ms per character (3.2s here) reads as quick but human.&lt;/p&gt;

&lt;h2&gt;
  
  
  How steps() makes it type
&lt;/h2&gt;

&lt;p&gt;Without &lt;code&gt;steps()&lt;/code&gt; the width would grow smoothly and you'd see letters slide in half-cut. &lt;code&gt;steps(32)&lt;/code&gt; splits the 3.2 seconds into 32 equal jumps, and because every glyph in a monospace font is exactly &lt;code&gt;1ch&lt;/code&gt; wide (&lt;code&gt;ch&lt;/code&gt; is the width of the "0" glyph), each jump uncovers exactly one character.&lt;/p&gt;

&lt;p&gt;The second argument of &lt;code&gt;steps()&lt;/code&gt; decides where each jump lands. It defaults to &lt;code&gt;end&lt;/code&gt;, so nothing shows for the first 100 ms and the last character appears right as the animation ends. &lt;code&gt;steps(32, jump-start)&lt;/code&gt; shows the first letter immediately, which feels snappier if the line is the first thing on the page. The cursor uses &lt;code&gt;step-end&lt;/code&gt; for the same reason: the border switches between visible and transparent instead of fading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why ch and not width: 100%
&lt;/h3&gt;

&lt;p&gt;The CSS-Tricks snippet from 2016, still the top result for this search, animates to &lt;code&gt;width: 100%&lt;/code&gt;. A percentage width is a share of the parent, so in an ordinary block container 100% means the whole column, and the cursor keeps travelling long after the text has ended. When I ran it in a normal page, the cursor stopped about 600px to the right of the full stop. It works in their demo because the container there is sized by the text (their notes say the demo relies on flexbox), so 100% of the parent happens to equal the text. Animating to &lt;code&gt;32ch&lt;/code&gt; doesn't depend on the parent at all, which is why I'd use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong with the classic version
&lt;/h2&gt;

&lt;p&gt;Swap the font for anything proportional and it breaks in two ways at once. A "W" is far wider than an "i", so the 1ch jumps no longer line up with letters and you get characters cut through the middle while it types. And &lt;code&gt;32ch&lt;/code&gt; is no longer the width of the text: with Georgia, the same code finished with the cursor parked about 180px past the end of the sentence.&lt;/p&gt;

&lt;p&gt;On phones, the same &lt;code&gt;white-space: nowrap&lt;/code&gt; that keeps the reveal on one line means the line can never wrap. At 28px that sentence is about 540px wide; on a 375px phone the end of it is simply cut off. You can shrink the font with a media query, but a longer headline will hit the same wall.&lt;/p&gt;

&lt;p&gt;And it doesn't survive editing. The moment a client edits the headline in a CMS, the character count is wrong and the cursor either stops early or overshoots. If anyone other than you will touch the words, use the next version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Any font, any length, any number of lines
&lt;/h2&gt;

&lt;p&gt;Instead of uncovering a box, put every letter in its own &lt;code&gt;span&lt;/code&gt;, hide them all with &lt;code&gt;visibility: hidden&lt;/code&gt;, and reveal each one after a delay of its index times the speed. Hidden letters still take up their space, so the browser lays out the finished text before anything appears: lines wrap where they normally would, and nothing jumps when the last word lands. There's nothing to count and no font restriction.&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;h1&lt;/span&gt; &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Any font, any length, and it wraps onto as many lines as it needs.&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;--speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;55ms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.type&lt;/span&gt; &lt;span class="nc"&gt;.ch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;show&lt;/span&gt; &lt;span class="mi"&gt;0s&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;--i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;--speed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;forwards&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.type&lt;/span&gt; &lt;span class="nc"&gt;.ch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;               &lt;span class="cm"&gt;/* the cursor, shown on the newest letter */&lt;/span&gt;
  &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;-0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mf"&gt;06em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.06em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentColor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;caret&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;--speed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;--i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;--speed&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="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.type&lt;/span&gt; &lt;span class="nc"&gt;.ch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;last-child&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blink&lt;/span&gt; &lt;span class="mi"&gt;1s&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;--i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;--speed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;step-end&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;show&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;visible&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;caret&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;blink&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&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="mf"&gt;50.01&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.type&lt;/span&gt; &lt;span class="nc"&gt;.ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.type&lt;/span&gt; &lt;span class="nc"&gt;.ch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;visible&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="nc"&gt;.sr-only&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nowrap&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 cursor is a thin &lt;code&gt;::after&lt;/code&gt; bar on each letter that's only visible during that letter's own slot, so it always sits after the newest character, including at the start of a new line. The last letter keeps it and blinks four times.&lt;/p&gt;

&lt;p&gt;Something has to write those spans, and for that I use a short script. It doesn't animate anything; it splits the text once, keeps a full copy for screen readers, and hands the timing back to CSS. If the script never runs, the heading just shows as normal text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="k"&gt;of&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;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;".type"&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;\&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;+/&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;label&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="s2"&gt;"span"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sr-only"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;shown&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="s2"&gt;"span"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"aria-hidden"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"true"&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;char&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;s&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="s2"&gt;"span"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ch"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"--i"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;char&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&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="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceChildren&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shown&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;h3&gt;
  
  
  Dropping the script with sibling-index()
&lt;/h3&gt;

&lt;p&gt;If your templates already output one span per letter (a static site generator can do this at build time), CSS can number them itself. &lt;code&gt;sibling-index()&lt;/code&gt; returns an element's position among its siblings and, according to MDN, has worked in all major browsers since August 2026. Registering &lt;code&gt;--i&lt;/code&gt; as an integer makes it resolve on the letter and pass the number down to the cursor. Replace the inline &lt;code&gt;--i&lt;/code&gt; with this:&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="k"&gt;@property&lt;/span&gt; &lt;span class="nv"&gt;--i&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;integer&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;inherits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;initial-value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.type&lt;/span&gt; &lt;span class="nc"&gt;.ch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;--i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sibling-index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested this in Chrome only. Older browsers ignore it, so the letters simply appear together, which is an acceptable fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screen readers and reduced motion
&lt;/h2&gt;

&lt;p&gt;The classic version is easy here: the text is in the page the whole time, just clipped, so a screen reader reads the full line whatever the animation is doing. The per-letter version isn't. Some screen readers announce a run of single-letter spans as separate letters, which is why the script hides the animated copy with &lt;code&gt;aria-hidden="true"&lt;/code&gt; and adds a visually hidden copy of the sentence. I used a hidden span rather than &lt;code&gt;aria-label&lt;/code&gt; because ARIA doesn't allow naming plain &lt;code&gt;p&lt;/code&gt; and &lt;code&gt;span&lt;/code&gt; elements, and a hidden span works on any element. In Chrome's accessibility tree the heading above comes out as the single sentence.&lt;/p&gt;

&lt;p&gt;Both versions include a &lt;code&gt;prefers-reduced-motion&lt;/code&gt; block that shows the finished text straight away. Notice too that neither cursor blinks forever. WCAG 2.2.2 asks for a way to pause anything that moves or blinks for more than five seconds next to other content, and a cursor blinking under your headline counts. Giving the blink an iteration count (6 × 0.75s, 4 × 1s) keeps it under five seconds, which is simpler than adding a pause button. The same rule applies to the typing itself, so a paragraph that takes ten seconds to type out is a problem twice over.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to stop using CSS
&lt;/h2&gt;

&lt;p&gt;Once you want words that type, delete themselves and get replaced ("We build websites / apps / stores"), CSS can still do it: &lt;a href="https://dev.to/afif/a-scalable-css-only-typewriter-effect-2opn"&gt;Temani Afif's version&lt;/a&gt; stacks the phrases in a grid and slides between them. But the phrase count is baked into three linked numbers, and you can't hold one word longer than another. A short script is easier to change; the &lt;a href="https://typingtextgif.com/blog/javascript-typing-animation" rel="noopener noreferrer"&gt;JavaScript typing animation guide&lt;/a&gt; has a tested version with backspace, plus what typed.js and TypeIt cost for commercial sites now.&lt;/p&gt;

&lt;p&gt;And there are places where neither runs. Can I Email estimates that only about a third of email clients support &lt;code&gt;@keyframes&lt;/code&gt; (their data was last tested in 2021). GitHub READMEs strip styles, and Notion pages and Google Slides have no CSS at all. In those places the same effect works as an image. The &lt;a href="https://typingtextgif.com/" rel="noopener noreferrer"&gt;typing text GIF maker&lt;/a&gt; renders this exact animation, cursor included, as a GIF or animated PNG, so the effect looks the same in an email signature as it does on your site.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://typingtextgif.com/blog/typing-animation-css" rel="noopener noreferrer"&gt;typingtextgif.com&lt;/a&gt;. The GIF renderer mentioned above is open source: &lt;a href="https://github.com/fancydirty/typing-text-gif" rel="noopener noreferrer"&gt;fancydirty/typing-text-gif&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>a11y</category>
    </item>
    <item>
      <title>Inside Phantom Blade Zero's PBZS v3 Save Format (AES-256-GCM + Oodle Kraken in the Browser)</title>
      <dc:creator>Crane Zhou</dc:creator>
      <pubDate>Tue, 03 Nov 2026 12:42:38 +0000</pubDate>
      <link>https://dev.to/fancy39_9841cbc02f99f729c/inside-phantom-blade-zeros-pbzs-v3-save-format-aes-256-gcm-oodle-kraken-in-the-browser-14gm</link>
      <guid>https://dev.to/fancy39_9841cbc02f99f729c/inside-phantom-blade-zeros-pbzs-v3-save-format-aes-256-gcm-oodle-kraken-in-the-browser-14gm</guid>
      <description>&lt;p&gt;Most Unreal Engine 5 action RPGs on PC drop your save files into &lt;code&gt;%LOCALAPPDATA%\&amp;lt;ProjectName&amp;gt;\Saved\SaveGames\&lt;/code&gt;. If you go looking there for &lt;em&gt;Phantom Blade Zero&lt;/em&gt;, all you'll find is &lt;code&gt;GameUserSettings.ini&lt;/code&gt; and shader caches.&lt;/p&gt;

&lt;p&gt;While building a client-side browser save editor and starter-save archive at &lt;strong&gt;&lt;a href="https://phantombladezerosaves.com/" rel="noopener noreferrer"&gt;phantombladezerosaves.com&lt;/a&gt;&lt;/strong&gt;, we had to document where the Steam build actually writes &lt;code&gt;PBZSaveGame.sav&lt;/code&gt; and how its custom &lt;code&gt;PBZS v3&lt;/code&gt; binary container seals and compresses slot records. Here is a breakdown of the format and the two quirks that trip up standard WebCrypto and compression libraries.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Where Steam Actually Stores &lt;code&gt;PBZSaveGame.sav&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Instead of your Windows user profile, &lt;em&gt;Phantom Blade Zero&lt;/em&gt; writes saves directly inside the Steam library folder next to the game installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;SteamLibrary&amp;gt;\steamapps\common\PBZ\ProjectPBZ\Saved\SaveGames\Steam\&amp;lt;17-digit-SteamID64&amp;gt;\PBZSaveGame.sav
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside that 17-digit folder sit four items:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PBZSaveGame.sav&lt;/code&gt; — a single archive holding all manual slots, autosaves, and your account wardrobe.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1RealTime\&lt;/code&gt;, &lt;code&gt;2Hourly\&lt;/code&gt;, and &lt;code&gt;3Daily\&lt;/code&gt; — automatic rolling backup directories rotated by the game while you play.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One useful discovery from decrypting the container: &lt;strong&gt;neither the outer directory nor the inner slot JSON stores your 17-digit SteamID64.&lt;/strong&gt; The number only names the folder on disk. That means any valid &lt;code&gt;PBZSaveGame.sav&lt;/code&gt; can be dropped into your own SteamID64 folder and loaded at the Bell checkpoint without re-signing an account ID (see our &lt;a href="https://phantombladezerosaves.com/save-file-location/" rel="noopener noreferrer"&gt;Steam save location &amp;amp; backup guide&lt;/a&gt; and &lt;a href="https://phantombladezerosaves.com/transfer-save-between-accounts/" rel="noopener noreferrer"&gt;account transfer walkthrough&lt;/a&gt; for the Steam Cloud sync ordering).&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The 12-Byte PBZS v3 Header
&lt;/h2&gt;

&lt;p&gt;Only the first 12 bytes of &lt;code&gt;PBZSaveGame.sav&lt;/code&gt; are stored in cleartext:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offset&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4 B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ASCII&lt;/td&gt;
&lt;td&gt;Magic bytes &lt;code&gt;PBZS&lt;/code&gt; (&lt;code&gt;50 42 5A 53&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x04&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1 B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uint8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Container version (&lt;code&gt;3&lt;/code&gt; in Steam build &lt;code&gt;26266538&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x05&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1 B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uint8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flags (&lt;code&gt;1&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x06&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2 B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uint16 LE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Total number of slot records in the archive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x08&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4 B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uint32 LE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Byte length of the encrypted directory block starting at offset &lt;code&gt;12&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because the header gives you both &lt;code&gt;slotCount&lt;/code&gt; and &lt;code&gt;directorySize&lt;/code&gt;, you can verify whether a file is an intact PBZS v3 archive without having the decryption key: &lt;code&gt;12 + directorySize&lt;/code&gt; must land on a sealed block boundary, and walking &lt;code&gt;slotCount&lt;/code&gt; blocks from there must reach &lt;code&gt;EOF&lt;/code&gt; with zero trailing bytes.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Quirk #1: Out-of-Line AES-256-GCM Authentication Tags
&lt;/h2&gt;

&lt;p&gt;Immediately after byte 12 sits the directory block, followed by one sealed block per save slot (&lt;code&gt;__Advanced__&lt;/code&gt;, &lt;code&gt;__Achievements__&lt;/code&gt;, &lt;code&gt;AutoSave0&lt;/code&gt;, &lt;code&gt;ManualSave1&lt;/code&gt;, &lt;code&gt;NightmareRun0&lt;/code&gt;, etc.). Every sealed block shares a 36-byte preamble:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PBZS 01 01 00 00 (8 B)  |  nonce (12 B)  |  GCM tag (16 B)  |  ciphertext (N B)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most crypto APIs — including browser WebCrypto (&lt;code&gt;crypto.subtle.decrypt&lt;/code&gt;) — expect the 16-byte AES-GCM authentication tag to be appended to the &lt;strong&gt;end&lt;/strong&gt; of the ciphertext (&lt;code&gt;ciphertext || tag&lt;/code&gt;). PBZS v3 stores the 16-byte tag &lt;strong&gt;before&lt;/strong&gt; the ciphertext at bytes &lt;code&gt;20..36&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To decrypt a block in the browser with WebCrypto, you slice the 12-byte nonce from &lt;code&gt;8..20&lt;/code&gt;, copy &lt;code&gt;ciphertext&lt;/code&gt; (&lt;code&gt;36..end&lt;/code&gt;) followed by &lt;code&gt;tag&lt;/code&gt; (&lt;code&gt;20..36&lt;/code&gt;) into a contiguous &lt;code&gt;Uint8Array&lt;/code&gt;, and pass that buffer to &lt;code&gt;crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, tagLength: 128 }, key, combined)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the key is wrong or a single byte was corrupted in transit, GCM authentication fails immediately before touching the payload.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Quirk #2: Oodle Kraken Slot Payloads vs. &lt;code&gt;zlib&lt;/code&gt; Re-Encoding
&lt;/h2&gt;

&lt;p&gt;Decrypting the directory block reveals a packed table of slot entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uint16 nameLen | name (UTF-8) | uint8 algorithm | uint8 flag | uint32 rawSize | uint32 offset | uint32 blockSize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;algorithm&lt;/code&gt; byte specifies how each slot's UTF-8 JSON was compressed prior to encryption:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; — Uncompressed raw bytes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; — Standard &lt;code&gt;zlib&lt;/code&gt; deflate stream&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt; — &lt;strong&gt;Oodle Kraken&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every native save written by the game uses &lt;code&gt;algorithm = 2&lt;/code&gt; (Oodle Kraken), but the game's loader natively accepts &lt;code&gt;algorithm = 1&lt;/code&gt; (&lt;code&gt;zlib&lt;/code&gt;) as well. That makes a pure browser editor practical: we ship a compiled WebAssembly Oodle Kraken decoder (&lt;code&gt;ooz.wasm&lt;/code&gt;) to decompress native &lt;code&gt;algorithm = 2&lt;/code&gt; slots in the tab, modify only the touched JSON keys, and re-pack modified slots with &lt;code&gt;zlib&lt;/code&gt; while copying untouched slots through with their original ciphertext byte-for-byte intact.&lt;/p&gt;

&lt;p&gt;Full byte-level notes live in the &lt;a href="https://phantombladezerosaves.com/save-file-format/" rel="noopener noreferrer"&gt;PBZS v3 Format Specification&lt;/a&gt; and the open-source verifier repo at &lt;a href="https://github.com/fancydirty/phantom-blade-zero-save-format" rel="noopener noreferrer"&gt;github.com/fancydirty/phantom-blade-zero-save-format&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Inside the Slot JSON: The &lt;code&gt;+1&lt;/code&gt; Weapon Grade Offset
&lt;/h2&gt;

&lt;p&gt;Once a slot is decompressed, the payload is clean UTF-8 JSON:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Player.Dust&lt;/code&gt; holds your currency balance as a standard signed integer.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Inventory.InventoryInfos&lt;/code&gt; maps material and goods IDs (such as &lt;code&gt;Weapon_Material_*&lt;/code&gt; and &lt;code&gt;Medi_Material_*&lt;/code&gt;) to &lt;code&gt;{ CurrentAmount, MaxAmount }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Inventory.MainWeaponInfos&lt;/code&gt; and &lt;code&gt;Inventory.ShaChiWeaponInfo&lt;/code&gt; store weapon ownership and upgrade levels.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail that catches manual JSON editors off guard: &lt;strong&gt;weapon grades are stored one higher than the &lt;code&gt;+n&lt;/code&gt; shown in the game's UI.&lt;/strong&gt; An un-upgraded base weapon (&lt;code&gt;+0&lt;/code&gt;) is stored as &lt;code&gt;1&lt;/code&gt; (or omitted), and the first-cycle ceiling of &lt;code&gt;+7&lt;/code&gt; is stored as &lt;code&gt;8&lt;/code&gt;. Outfits are stored globally inside the &lt;code&gt;__Advanced__&lt;/code&gt; slot rather than inside individual manual saves. We published the full mapping of internal string keys to English and Chinese names on the &lt;a href="https://phantombladezerosaves.com/item-ids/" rel="noopener noreferrer"&gt;Phantom Blade Zero Item IDs page&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try the Local Browser Save Editor or Starter Saves
&lt;/h2&gt;

&lt;p&gt;If you want to inspect or edit your own &lt;code&gt;PBZSaveGame.sav&lt;/code&gt; without installing desktop trainers or Cheat Engine tables (which frequently break when a Steam hotfix shifts memory pointers — see our &lt;a href="https://phantombladezerosaves.com/save-editor-vs-trainer/" rel="noopener noreferrer"&gt;Save Editor vs. Trainers comparison&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://phantombladezerosaves.com/save-editor/" rel="noopener noreferrer"&gt;Phantom Blade Zero Browser Save Editor&lt;/a&gt;&lt;/strong&gt; — runs 100% locally in your browser tab via WebCrypto and WASM; no files are ever uploaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://phantombladezerosaves.com/all-weapons-save/" rel="noopener noreferrer"&gt;Cycle 1 All-Weapons Starter Save&lt;/a&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;a href="https://phantombladezerosaves.com/max-materials-save/" rel="noopener noreferrer"&gt;Cycle 1 Max Dust &amp;amp; Materials Save&lt;/a&gt;&lt;/strong&gt;, and &lt;strong&gt;&lt;a href="https://phantombladezerosaves.com/66-days-mode-save/" rel="noopener noreferrer"&gt;Cycle 10 &amp;amp; 66 Days Mode Save&lt;/a&gt;&lt;/strong&gt; — decrypted, verified against Steam build &lt;code&gt;26266538&lt;/code&gt;, and published with SHA-256 hashes.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gamedevwebassemblyjavascript</category>
    </item>
  </channel>
</rss>
