<?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: glebr2d2</title>
    <description>The latest articles on DEV Community by glebr2d2 (@glebr2d2).</description>
    <link>https://dev.to/glebr2d2</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%2F3860109%2F7c8b937a-d42d-4022-aba4-2cc00015259d.jpeg</url>
      <title>DEV Community: glebr2d2</title>
      <link>https://dev.to/glebr2d2</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/glebr2d2"/>
    <language>en</language>
    <item>
      <title>Unity, Godot, or my own engine? Why my card games are plain TypeScript" published: false tags: gamedev, reactnative, typescript, showdev</title>
      <dc:creator>glebr2d2</dc:creator>
      <pubDate>Mon, 05 Oct 2026 09:13:32 +0000</pubDate>
      <link>https://dev.to/glebr2d2/unity-godot-or-my-own-engine-why-my-card-games-are-plain-typescript-published-false-tags-1a06</link>
      <guid>https://dev.to/glebr2d2/unity-godot-or-my-own-engine-why-my-card-games-are-plain-typescript-published-false-tags-1a06</guid>
      <description>&lt;p&gt;For a couple of weeks I couldn't decide how to build my card game app: pick a ready-made engine, or write my own code.&lt;/p&gt;

&lt;p&gt;On one side were real game engines: Unity, Godot, the usual suspects. On the other was the slightly crazy option of writing it myself in TypeScript on top of React Native. This post is about why I ended up with the crazy option, what it gave me, and what it cost.&lt;/p&gt;

&lt;p&gt;The app is called Decks. It's on iOS now with fourteen games: nine solitaires (Klondike, Yukon, Spider, FreeCell, Pyramid, TriPeaks, Golf, Black Hole, Spanish Solitaire) and five puzzles (Codebreak, Triples, Memo, Tower of Hanoi and a two-tower variant).&lt;/p&gt;

&lt;p&gt;The idea came from Board Game Arena&lt;br&gt;
I play on Board Game Arena myself, and that's where the inspiration came from. Not any single game on it, but the idea that one platform carries hundreds of them. I wanted the same kind of platform for card games: one engine, many games.&lt;/p&gt;

&lt;p&gt;That goal decided almost everything. If the plan is "many games", then the worst thing I can do is write each game as code. Fifteen games written as fifteen implementations is faster for the first two and slower for every one after that.&lt;/p&gt;

&lt;p&gt;So in Decks a game is a JSON document, and the engine is an interpreter for it. The engine doesn't know the name of any game it plays.&lt;/p&gt;

&lt;p&gt;Why I didn't pick an engine&lt;br&gt;
I didn't decide on paper. I built a prototype in Godot first, and two things didn't work for me: the app came out heavy and slow to launch, and the game rules were hard to test separately from the rendering.&lt;/p&gt;

&lt;p&gt;When I looked at what my games actually are, the picture was simple. Cards are flat rectangles. A flip is a scale along one axis with the face swapped at zero. A fan is a rotation around a point outside the card. When I wrote the rendering decision down, I literally noted that WebGPU and 3D engines were considered and rejected: the problem is flat, and the pseudo-3D comes from matrices for free.&lt;/p&gt;

&lt;p&gt;What a card game does need is everything around the table: menus, settings, rules pages, text that grows with the system font size, native purchases. An app framework gives you those.&lt;/p&gt;

&lt;p&gt;And the heart of the project isn't rendering at all. It's the rules. I wanted rules I could run a thousand times in a test, from a seed, with no screen attached. That pushed me toward a stack where the rules are just code in a plain language with nothing underneath.&lt;/p&gt;

&lt;p&gt;I should admit that before Decks I had almost no React Native experience. I learned it along the way.&lt;/p&gt;

&lt;p&gt;What I picked&lt;br&gt;
The repo has three packages:&lt;/p&gt;

&lt;p&gt;game-core: the rules engine. Pure TypeScript.&lt;br&gt;
tokens: design tokens, just numbers and hex colors.&lt;br&gt;
mobile: the app, built with Expo, React Native, React Native Skia and Reanimated.&lt;br&gt;
Dependencies point inward only. mobile knows about game-core; the reverse is forbidden, and the linter checks it.&lt;/p&gt;

&lt;p&gt;Advantage 1: a core with zero dependencies&lt;br&gt;
This is the package.json of the engine, trimmed to the relevant fields:&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "name": "&lt;a class="mentioned-user" href="https://dev.to/deck"&gt;@deck&lt;/a&gt;/game-core",&lt;br&gt;
  "description": "Pure core: state, moves, rules interpreter, AI. Zero runtime dependencies, zero DOM, zero React.",&lt;br&gt;
  "dependencies": {},&lt;br&gt;
  "devDependencies": { "vitest": "^3.2.4", "@types/node": "^22.0.0" }&lt;br&gt;
}&lt;br&gt;
(My docs are actually in Russian; I translated the description for this post.)&lt;/p&gt;

&lt;p&gt;ESLint forbids the core from touching React, the DOM, the platform, Math.random and Date.now. Randomness and time come in as parameters.&lt;/p&gt;

&lt;p&gt;That one rule pays off over and over:&lt;/p&gt;

&lt;p&gt;The same core runs in Node and on the phone. Tests don't mock a game engine, because there is none. The last test runs in the 1.0.3 cycle were about 1,650 core tests and about 5,000 app tests.&lt;br&gt;
Any bug becomes a seed. My project rules say every bug is reproduced as "seed + sequence of moves", and that pair becomes a test in the same commit. There's a tests/repro/ folder with files like 012-espanol-suit-run.test.ts. Card bugs otherwise live forever as "sometimes it happens".&lt;br&gt;
Shuffles mid-game are reproducible too. The generator is mulberry32, and its state grows additively, so jumping to the n-th draw of a seed is O(1):&lt;/p&gt;

&lt;p&gt;// packages/game-core/src/model/rng.ts (trimmed)&lt;br&gt;
const STEP = 0x6d2b79f5&lt;br&gt;
export function countingRng(seed: number, skip: number): CountingRng {&lt;br&gt;
  let n = skip &amp;gt;&amp;gt;&amp;gt; 0&lt;br&gt;
  const base = seed &amp;gt;&amp;gt;&amp;gt; 0&lt;br&gt;
  return {&lt;br&gt;
    next: () =&amp;gt; {&lt;br&gt;
      n = (n + 1) &amp;gt;&amp;gt;&amp;gt; 0&lt;br&gt;
      let t = (base + Math.imul(n, STEP)) &amp;gt;&amp;gt;&amp;gt; 0&lt;br&gt;
      t = Math.imul(t ^ (t &amp;gt;&amp;gt;&amp;gt; 15), t | 1)&lt;br&gt;
      t ^= t + Math.imul(t ^ (t &amp;gt;&amp;gt;&amp;gt; 7), t | 61)&lt;br&gt;
      return ((t ^ (t &amp;gt;&amp;gt;&amp;gt; 14)) &amp;gt;&amp;gt;&amp;gt; 0) / 4_294_967_296&lt;br&gt;
    },&lt;br&gt;
    drawn: () =&amp;gt; n,&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
The engine supports reshuffling in the middle of a game (say, a discard pile turned back into a stock), and that has to replay exactly too. Because of the O(1) jump, a save only needs the seed and the number of draws so far.&lt;/p&gt;

&lt;p&gt;Advantage 2: rules are data&lt;br&gt;
Here is Golf's only move rule: a card from the tableau goes to the waste if it is one rank above or below the waste's top card.&lt;/p&gt;

&lt;p&gt;"legal": { "op": "or", "args": [&lt;br&gt;
  { "op": "eq", "args": [{ "op": "sub", "args": [&lt;br&gt;
    { "op": "ord", "args": [{ "ref": "sel" }, "rank"] },&lt;br&gt;
    { "op": "ord", "args": [{ "op": "at", "args": [{ "op": "zone", "args": ["waste"] }, "top"] }, "rank"] }&lt;br&gt;
  ]}, 1] },&lt;br&gt;
  { "...": "the same, the other way round" }&lt;br&gt;
]}&lt;br&gt;
TriPeaks has the same idea, but computed mod 13, so a king and an ace are neighbours there. In Golf they aren't. That difference is a different arithmetic expression in a data file. Neither game has a code path of its own.&lt;/p&gt;

&lt;p&gt;There's no eval and no new Function (ESLint fails the build on both). That isn't about security first. You can't ask arbitrary code "what moves are possible right now?", and hints, the "no moves left" check and the test bots all depend on listing moves.&lt;/p&gt;

&lt;p&gt;The best proof came late. Yukon, the fourteenth game, shipped in 1.0.3. The commit says it plainly: Yukon is Klondike minus two lines (runOf, alternates), with a different deal, and "the engine and the language were not touched". It arrived with undo, hints, saving and rules text already working.&lt;/p&gt;

&lt;p&gt;Advantage 3: one language everywhere&lt;br&gt;
The app, the engine, the tests and the build-time tools are all TypeScript. The scripts/ folders are full of small solvers and measuring tools that import the very same engine. For example, the animated tables in onboarding are real games played by the engine at build time and recorded to a file. The app just plays the recording back.&lt;/p&gt;

&lt;p&gt;Even the website is tied to the app: a script generates a manifest from the app's code (which games ship, which links the paywall uses), and the site checks its text against it.&lt;/p&gt;

&lt;p&gt;Advantage 4: native UI where it matters&lt;br&gt;
Only the table is a Skia canvas. Everything around it (menus, settings, sheets, rules) is ordinary React Native views. So VoiceOver can read them, and they grow with the system text size for free. For the 1.0.3 release I ran an automated walk at the largest accessibility text size (AX5) in five languages: 855 scenes, zero violations.&lt;/p&gt;

&lt;p&gt;System settings like Reduce Motion, Reduce Transparency and Bold Text are read from the phone in exactly one place, and the app follows them. When I needed something React Native doesn't offer, I wrote a small Swift Expo module. There are two: StoreKit 2 for purchases, and a one-function module that reports the phone's thermal state.&lt;/p&gt;

&lt;p&gt;Card faces aren't image files either. They're drawn once from design tokens into a Skia atlas, so fourteen games don't drag fourteen sets of card art along. The whole app is about 59 MB on the App Store.&lt;/p&gt;

&lt;p&gt;The cost: you are the game engine now&lt;br&gt;
Here's the honest part. Choosing "no engine" means you write the engine bits yourself.&lt;/p&gt;

&lt;p&gt;Hit testing is mine. Which card is under your finger is a pure function I wrote. It once returned the first card at equal depth. In Pyramid all cards share one depth and the rows overlap by more than half, so taps went to the card behind, which had no move. My own bug report was "I tap a card and it doesn't flip the first time". The rule is now "the last one drawn wins", the same order Skia paints in.&lt;/p&gt;

&lt;p&gt;The 60 fps budget is mine. Work is split by how often it runs. The frame (60 times a second, in a Reanimated worklet on the UI thread) only does numbers: which card, which drop zone, springs. Legal moves are computed once per touch and turned into rectangles. The rules engine is never called on a frame, and the linter enforces it:&lt;/p&gt;

&lt;p&gt;// eslint.config.js (trimmed)&lt;br&gt;
{&lt;br&gt;
  files: [&lt;br&gt;
    'packages/mobile/src/gesture/&lt;strong&gt;/*.{ts,tsx}',&lt;br&gt;
    'packages/mobile/src/frame/&lt;/strong&gt;/&lt;em&gt;.{ts,tsx}',&lt;br&gt;
  ],&lt;br&gt;
  rules: {&lt;br&gt;
    'no-restricted-imports': ['error', { patterns: [{&lt;br&gt;
      group: ['&lt;a class="mentioned-user" href="https://dev.to/deck"&gt;@deck&lt;/a&gt;/game-core', '&lt;a class="mentioned-user" href="https://dev.to/deck"&gt;@deck&lt;/a&gt;/game-core/&lt;/em&gt;', '&lt;strong&gt;/game-core/&lt;/strong&gt;'],&lt;br&gt;
      message: "Frame and gesture don't call the rules. …", // translated from Russian&lt;br&gt;
    }] }],&lt;br&gt;
  },&lt;br&gt;
}&lt;br&gt;
The hit-test file even duplicates a three-line CardId type instead of importing it. Not even a type import is allowed.&lt;/p&gt;

&lt;p&gt;I learned this the hard way. My first drag wrapped each card in a conditional transform. On 52 cards that meant about 3,120 checks per second on the UI thread, and the app crashed on any touch. The fix was a single drag layer with one derived transform.&lt;/p&gt;

&lt;p&gt;Later, Codebreak's frame rate sagged over a game (my notes say 118 → 41). It turned out to be nine layers of one problem: work that kept running after its result stopped changing. The deal animated 174 cards when 13 were visible. A font was created three times per card per frame. Now a test across all games checks that an idle table holds zero live subscriptions.&lt;/p&gt;

&lt;p&gt;The table is invisible to VoiceOver. To the system, a Skia canvas is one big picture. I wrote it down as a real cost the player pays, not me. Spoken table play is still an open decision.&lt;/p&gt;

&lt;p&gt;Native dependencies bite. On Expo SDK 57 the app built fine and crashed on launch with Symbol not found: JavaScriptActor.assumeIsolated. The prebuilt ExpoModulesCore 57.0.11 had been compiled against expo-modules-jsi 57.0.4, while 57.0.8 was being resolved. The fix was to declare expo-modules-core directly (~57.0.15) so the prebuilt pair matches. Prebuilt binaries don't follow semver the way source does.&lt;/p&gt;

&lt;p&gt;Generality isn't free. A blackjack rule set I wrote (not shipped) came to 3,581 lines of JSON, because the language can't name a subexpression yet. "Hand value" is repeated 36 times. And I keep one rule ("branch on what a game declares, never on its name") by review for now. The test that would enforce it is written down as debt.&lt;/p&gt;

&lt;p&gt;Did I ever regret not using an engine? Sometimes, mostly while I was writing touch and gesture handling myself. In the end, no regrets.&lt;/p&gt;

&lt;p&gt;Where this is going&lt;br&gt;
I started with solitaires and puzzles on purpose: they're the best stress test for the table and the language, and a good first product. But the engine was never meant to stop there. From day one the first slice was Durak and Klondike, so the language would grow with hidden hands, turns and an opponent, not only with stacks and cascades. Durak and a couple of other two-player games live in the repo as data files and just aren't on the shelf yet.&lt;/p&gt;

&lt;p&gt;Next up are two-player games, starting with Durak, and more solitaires.&lt;/p&gt;

&lt;p&gt;Was it worth it?&lt;br&gt;
For me, yes. A new game costs a description, not a second engine, and the newest one proved it. The price is that I'm also the person who owns hit testing, frame budgets and native build quirks.&lt;/p&gt;

&lt;p&gt;If you're a solo developer facing the same choice, my advice is simple: choose your stack for the task, not for the hype. A flat card game doesn't need a 3D engine.&lt;/p&gt;

&lt;p&gt;If you want to see the result, the app is &lt;a href="https://deckgames.app/" rel="noopener noreferrer"&gt;Decks&lt;/a&gt; (&lt;a href="https://apps.apple.com/us/app/decks-yukon-solitaire-cards/id6808801330" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;). Happy to answer questions about the engine, Skia in the comments and for any feedback&lt;br&gt;
This article was written with AI assistance, but not AI written&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>reactnative</category>
      <category>typescript</category>
      <category>showdev</category>
    </item>
    <item>
      <title># How I Built a Client-Side HEIC Converter — No Server Required</title>
      <dc:creator>glebr2d2</dc:creator>
      <pubDate>Fri, 03 Apr 2026 21:32:21 +0000</pubDate>
      <link>https://dev.to/glebr2d2/-how-i-built-a-client-side-heic-converter-no-server-required-2fl4</link>
      <guid>https://dev.to/glebr2d2/-how-i-built-a-client-side-heic-converter-no-server-required-2fl4</guid>
      <description>&lt;p&gt;Every time someone asked me to "just send the photo as JPG," I died a little inside. iPhones have been saving photos as HEIC since iOS 11, and in 2026, the format mismatch is still a daily pain point for millions of people. Most online converters ask you to upload your personal photos to some random server. I wanted to build something better: a converter that runs entirely in your browser, with zero server involvement.&lt;/p&gt;

&lt;p&gt;Here's how I built it, what broke along the way, and what I learned about decoding Apple's image format with WebAssembly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Client-Side Matters
&lt;/h2&gt;

&lt;p&gt;The typical file converter architecture is straightforward: user uploads file, server processes it, server returns the result. It works, but it comes with baggage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy&lt;/strong&gt;: Your photos hit someone else's machine. For personal photos, that's a hard sell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server costs&lt;/strong&gt;: Image processing is CPU-intensive. At scale, you're paying real money for compute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt;: Upload + process + download vs. just... process. Locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline capability&lt;/strong&gt;: A client-side converter works on a plane, in a coffee shop with bad wifi, anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is complexity. Browsers weren't designed to decode proprietary image formats. But WebAssembly changed that equation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How HEIC Decoding Works in the Browser
&lt;/h2&gt;

&lt;p&gt;HEIC (High Efficiency Image Container) is built on top of the HEVC video codec — the same tech used for 4K video. Browsers don't support it natively (except Safari, somewhat). So you need a decoder.&lt;/p&gt;

&lt;p&gt;The pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HEIC file (ArrayBuffer)
    → libheif (compiled to WASM via Emscripten)
    → Raw pixel data (RGBA)
    → Canvas API (draw pixels)
    → canvas.toBlob() → JPG/PNG Blob
    → Download link
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used the &lt;a href="https://github.com/nicedoc/heic-to" rel="noopener noreferrer"&gt;&lt;code&gt;heic-to&lt;/code&gt;&lt;/a&gt; library, which wraps libheif's WASM build into a clean async API. The core conversion is surprisingly compact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;HeicTo&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heic-to&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convertHeicToJpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heicFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// heic-to handles WASM loading, decoding, and Canvas rendering&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jpgBlob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;HeicTo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;heicFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// File or Blob&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// target format&lt;/span&gt;
    &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;       &lt;span class="c1"&gt;// 0.0 - 1.0&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;jpgBlob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// standard Blob, ready to download&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, &lt;code&gt;heic-to&lt;/code&gt; loads a ~1.5MB WASM binary (libheif), decodes the HEIC container, extracts the HEVC-compressed image data, and renders the raw pixels onto an offscreen canvas. The Canvas API then does the format conversion via &lt;code&gt;toBlob()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For batch conversions with multiple files, I zip the results client-side using JSZip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;JSZip&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jszip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;batchConvert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zip&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;JSZip&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;HeicTo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;heic$/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zipBlob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// trigger download...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything stays in browser memory. No temporary files on a server, no cleanup jobs, no S3 buckets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenges Nobody Warns You About
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Color Space Mismatch
&lt;/h3&gt;

&lt;p&gt;This was the most insidious bug. iPhones shoot in Display P3 color space — a wider gamut than the sRGB that JPG typically assumes. When you decode HEIC and render to Canvas, the browser may or may not apply color management depending on the platform.&lt;/p&gt;

&lt;p&gt;The result: photos that look subtly different after conversion. Slightly desaturated greens, shifted skin tones. Not wrong enough to notice immediately, but wrong enough to bother a photographer.&lt;/p&gt;

&lt;p&gt;My solution was an optional sRGB normalization pass. After initial decoding, I re-draw through a Canvas context to force sRGB interpretation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;convertToSrgb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;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="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`image/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&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;It's not perfect color management, but it handles the 90% case. I exposed it as a toggle so users can decide.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory Limits on Large Files
&lt;/h3&gt;

&lt;p&gt;A 48MP iPhone photo in HEIC might be 8-12MB compressed. Decoded to raw RGBA pixels, that's &lt;code&gt;8064 × 6048 × 4 bytes ≈ 195MB&lt;/code&gt; in memory. Multiply that by a batch of 20 photos, and you're pushing browsers to their limits.&lt;/p&gt;

&lt;p&gt;I process files sequentially rather than in parallel. It's slower, but it avoids the "Aw, Snap!" crash that Chrome shows when you blow through its memory ceiling. For files above ~80MB, I recommend the Chrome extension, which has slightly more generous resource limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Browser Compatibility
&lt;/h3&gt;

&lt;p&gt;The WASM-based decoder works in all modern browsers — Chrome, Firefox, Edge, Safari 15+. But I hit edge cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Safari on iOS&lt;/strong&gt;: Intermittent failures with certain HEIC variants that use tiling (multi-image containers). The same files decode fine on macOS Safari.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firefox&lt;/strong&gt;: Slightly slower WASM execution compared to Chrome's V8 engine, but functionally identical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Older browsers&lt;/strong&gt;: No WASM = no conversion. I show a clear error rather than failing silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Chrome Extension Angle
&lt;/h2&gt;

&lt;p&gt;After building the web version, packaging it as a Chrome extension was a natural next step. The core conversion logic is identical — same &lt;code&gt;heic-to&lt;/code&gt; library, same Canvas pipeline. The extension adds convenience: right-click context menu integration, persistent settings, and one fewer tab to keep open.&lt;/p&gt;

&lt;p&gt;The extension runs under Manifest V3 with a service worker background script. One quirk: Chrome extensions have stricter CSP (Content Security Policy) than regular web pages, so all WASM loading has to go through &lt;code&gt;'self'&lt;/code&gt; — no CDN loading. I bundle everything locally.&lt;/p&gt;

&lt;p&gt;You can try both the web converter and the extension at &lt;a href="https://www.convert.rocks/heic-to-jpg/" rel="noopener noreferrer"&gt;convert.rocks&lt;/a&gt; — the web version is free, no sign-up, and yes, you can disconnect your internet after loading the page to verify nothing gets uploaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with WebP output sooner.&lt;/strong&gt; JPG and PNG were the obvious first targets, but WebP gives you better compression than JPG with better quality. I added it later using &lt;code&gt;webp-converter-browser&lt;/code&gt;, but it requires an extra conversion step (HEIC → PNG → WebP) that I'd architect differently from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;@jsquash&lt;/code&gt; for modern format support.&lt;/strong&gt; The &lt;a href="https://github.com/nicedoc/jSquash" rel="noopener noreferrer"&gt;jSquash&lt;/a&gt; project provides modular WASM codecs for AVIF, JXL, and WebP with much smaller bundles (~10KB-2MB per codec vs. monolithic builds). For the next phase, I'm adding AVIF encoding and decoding — it's the next "HEIC moment" as Android and web platforms push adoption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consider OffscreenCanvas and Web Workers.&lt;/strong&gt; Currently, conversion blocks the main thread during the Canvas rendering step. For large files, the UI freezes for 1-2 seconds. Moving the Canvas pipeline to a Web Worker with &lt;code&gt;OffscreenCanvas&lt;/code&gt; would keep the UI responsive. It's well-supported now but requires restructuring the conversion flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progressive rendering for batch jobs.&lt;/strong&gt; Right now, all files convert before any results appear. A streaming approach — showing each converted file as it finishes — would feel much faster even if total time is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;The entire converter is ~15KB of application JavaScript plus ~1.5MB of WASM (loaded on demand). No framework, no build step for the web version — just vanilla JS, a CDN-loaded library, and the Canvas API. It loads in under 2 seconds on a 3G connection, and conversion takes 1-3 seconds per photo depending on resolution.&lt;/p&gt;

&lt;p&gt;For an image format that causes daily frustration for millions of iPhone users sharing photos with Windows and Android users, a 15KB solution that runs in the browser feels like the right level of complexity.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're building browser-based file tools and want to compare notes, find me in the comments. I'm currently working on AVIF and JXL converters using the same client-side architecture.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>tutorial</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
