<?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: Alex Kolovskiy</title>
    <description>The latest articles on DEV Community by Alex Kolovskiy (@kolovsky).</description>
    <link>https://dev.to/kolovsky</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%2F4024409%2F145148ce-330a-4b06-bd4a-09f740cfe6b7.jpg</url>
      <title>DEV Community: Alex Kolovskiy</title>
      <link>https://dev.to/kolovsky</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kolovsky"/>
    <language>en</language>
    <item>
      <title>nvm loses your global CLIs every time you switch. So I fixed it.</title>
      <dc:creator>Alex Kolovskiy</dc:creator>
      <pubDate>Tue, 14 Jul 2026 23:51:10 +0000</pubDate>
      <link>https://dev.to/kolovsky/nvm-loses-your-global-clis-every-time-you-switch-so-i-fixed-it-3gae</link>
      <guid>https://dev.to/kolovsky/nvm-loses-your-global-clis-every-time-you-switch-so-i-fixed-it-3gae</guid>
      <description>&lt;p&gt;Every nvm user knows this loop. You install a CLI globally, it works, then a week later &lt;code&gt;nvm use 18&lt;/code&gt; for some legacy project and the command is gone. Not broken — gone. &lt;code&gt;command not found&lt;/code&gt;. Because nvm doesn't have global packages; each node version has its own &lt;code&gt;lib/node_modules&lt;/code&gt;, and switching versions swaps the entire world out from under you.&lt;/p&gt;

&lt;p&gt;The standard answers are all bad. &lt;code&gt;nvm reinstall-packages&lt;/code&gt; copies everything between versions, which is a sledgehammer and reinstalls things into versions where they don't belong. &lt;code&gt;--default-packages&lt;/code&gt; only helps at install time. And "just use npx" means cold-starting your tools through the network.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/alexkolovsky/nvmpin" rel="noopener noreferrer"&gt;nvmpin&lt;/a&gt;: pin each global package to a specific node version, and it keeps working no matter which version your shell is on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nvm use 16
&lt;span class="go"&gt;Now using node v16.20.2

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;which cowsay
&lt;span class="go"&gt;/Users/alex/.nvmpin/bin/cowsay

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cowsay ok
&lt;span class="go"&gt; ____
&lt;/span&gt;&lt;span class="gp"&gt;&amp;lt; ok &amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt; ----
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a real transcript from the pre-release smoke test — cowsay is pinned to node 22, the shell is on 16, nothing breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Isn't this just Volta?"
&lt;/h2&gt;

&lt;p&gt;Mostly, yes — and if you're choosing a version manager from scratch, look at Volta first. It shims everything transparently and it's more complete than this will ever be.&lt;/p&gt;

&lt;p&gt;nvmpin exists for one population: people already on nvm who aren't leaving. Volta and nvm fight over your PATH; migrating means unwinding years of muscle memory, dotfiles, CI scripts, and team conventions. nvmpin sits &lt;em&gt;on top&lt;/em&gt; of an existing nvm install and changes nothing about how you use nvm. That's the whole pitch. If that's not you, close the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;No daemon, no wrapper around nvm, no PATH interception magic. Three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A registry at &lt;code&gt;~/.nvmpin/pins.json&lt;/code&gt; mapping package → node version.&lt;/li&gt;
&lt;li&gt;A shim directory &lt;code&gt;~/.nvmpin/bin&lt;/code&gt;, prepended to PATH after nvm's init.&lt;/li&gt;
&lt;li&gt;Generated shims — three-line bash scripts with absolute paths baked in:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# nvmpin shim for cowsay -&amp;gt; v22.21.1 (do not edit)&lt;/span&gt;
&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"/Users/alex/.nvm/versions/node/v22.21.1/bin/node"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"/Users/alex/.nvm/versions/node/v22.21.1/lib/node_modules/cowsay/cli.js"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nvmpin add cowsay --node 22&lt;/code&gt; installs it into node 22's tree and writes the shim. &lt;code&gt;nvmpin move pkg --node 18&lt;/code&gt; reinstalls into 18 — a real reinstall, never a re-point, because native modules are compiled per node ABI. &lt;code&gt;nvmpin scan&lt;/code&gt; shows which globals live in which versions. &lt;code&gt;nvmpin doctor&lt;/code&gt; tells you when something's broken. Zero dependencies, POSIX only for now.&lt;/p&gt;

&lt;p&gt;Simple idea. The interesting part is everything npm did to sabotage it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sabotage #1: lifecycle scripts compile against the wrong node
&lt;/h2&gt;

&lt;p&gt;First naive version of the installer: run the target version's npm with the target version's node.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&amp;lt;target&amp;gt;/bin/node &amp;lt;target&amp;gt;/bin/npm i &lt;span class="nt"&gt;-g&lt;/span&gt; node-sass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks airtight. It isn't. On npm 10, lifecycle scripts — postinstall, node-gyp — resolve &lt;code&gt;node&lt;/code&gt; from the &lt;em&gt;ambient&lt;/em&gt; PATH, not from the node that's running npm. So installing node-sass "into node 18" from a shell where node 22 was active produced a binding compiled for ABI 127 — node 22's ABI — sitting inside node 18's tree. Loading it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: The module was compiled against a different Node.js version using
NODE_MODULE_VERSION 127. This version of Node.js requires NODE_MODULE_VERSION 108.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: the installer prepends the target version's bin dir to the spawned PATH, so gyp finds the right node. There's now a regression test that asserts exactly this, because it's the kind of bug that comes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sabotage #2: one stray prefix setting redirects every install
&lt;/h2&gt;

&lt;p&gt;If you have &lt;code&gt;NPM_CONFIG_PREFIX&lt;/code&gt; exported — or &lt;code&gt;prefix=&lt;/code&gt; sitting in &lt;code&gt;~/.npmrc&lt;/code&gt; from some tutorial five years ago — npm ignores the version tree entirely and installs into that prefix. Your shims then point at paths that don't exist. nvm users are exactly the people likely to have this half-configured; nvm itself refuses to run when it's set.&lt;/p&gt;

&lt;p&gt;First instinct: strip the bad env vars. Wrong, twice. Stripping doesn't touch the npmrc form, and over-stripping &lt;code&gt;npm_config_*&lt;/code&gt; would eat registry URLs, proxies, and auth tokens — breaking everyone behind a corporate proxy.&lt;/p&gt;

&lt;p&gt;The actual fix is one line, and it's better than stripping: &lt;em&gt;assert&lt;/em&gt; the correct value. npm's config precedence is env &amp;gt; userconfig &amp;gt; defaults, so setting &lt;code&gt;npm_config_prefix=&amp;lt;versionDir&amp;gt;&lt;/code&gt; in the spawned env defeats every npmrc layer and every ambient variable at once.&lt;/p&gt;

&lt;p&gt;Almost. While verifying this on npm 10.9, I found a behavior I haven't seen documented anywhere: npm matches the &lt;code&gt;npm_config_&lt;/code&gt; env prefix case-insensitively, and on casing conflicts, &lt;strong&gt;last in env order wins&lt;/strong&gt;. Try it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;npm_config_prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/lower &lt;span class="nv"&gt;NPM_CONFIG_PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/upper npm prefix &lt;span class="nt"&gt;-g&lt;/span&gt;
&lt;span class="go"&gt;/tmp/upper
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means a bare assignment can silently lose to a stale uppercase variable that happens to sit later in the environment. So the installer deletes every casing of the key first, then sets its own last. Deterministic, and yes, there's a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sabotage #3: my own doctor called my healthy system broken
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nvmpin doctor&lt;/code&gt; originally checked that the shim dir precedes every nvm bin dir in PATH — reasonable, since rc misordering means shims permanently lose. Then, during the real-machine smoke test, doctor exited 2 on a system where everything demonstrably worked.&lt;/p&gt;

&lt;p&gt;The cause: &lt;code&gt;nvm use&lt;/code&gt; &lt;em&gt;prepends&lt;/em&gt; the selected version's bin dir to PATH in the live shell. Every time. It's inherent nvm behavior — which means the "shims must be first" invariant is violated in any shell where you've ever switched versions, while the shims keep resolving fine as long as no same-named bin exists in the prepended dir.&lt;/p&gt;

&lt;p&gt;A diagnostic that fires in every normal shell trains you to ignore it right before the one time it matters. So the check is now collision-aware: it only errors when an earlier nvm dir &lt;em&gt;actually contains&lt;/em&gt; a bin that shadows one of your pins (and even then, not if it's the pin's own target version — identical resolution either way). Bare ordering after &lt;code&gt;nvm use&lt;/code&gt; is a note, exit 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody asked about: tests
&lt;/h2&gt;

&lt;p&gt;79 tests, zero dependencies, all &lt;code&gt;node:test&lt;/code&gt;. The unit suite runs against fixture nvm trees and a stub npm. But sabotages #1 and #2 are claims about &lt;em&gt;npm's&lt;/em&gt; behavior, not mine — a stub that simulates npm honoring &lt;code&gt;$npm_config_prefix&lt;/code&gt; proves nothing if npm 11 changes the rules. So there's a gated integration test (&lt;code&gt;NVMPIN_INTEGRATION=1&lt;/code&gt;) that runs real npm against a sandbox under a three-way prefix conflict, and it's wired into &lt;code&gt;prepublishOnly&lt;/code&gt; — publishing without re-verifying the npm contract is impossible by default. It already paid for itself once: the claims were derived on npm 10.8.2 and the test confirmed they hold on 10.9.4.&lt;/p&gt;

&lt;p&gt;Every non-obvious call — all eighteen of them — is written down in &lt;a href="https://github.com/alexkolovsky/nvmpin/blob/main/DECISIONS.md" rel="noopener noreferrer"&gt;DECISIONS.md&lt;/a&gt; with the reasoning and, where it exists, the terminal receipt. Four real bugs got caught before a single user existed. I'd rather ship the bug list myself than read it in the issues.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-g&lt;/span&gt; nvmpin
nvmpin setup
nvmpin add &amp;lt;your-most-missed-cli&amp;gt; &lt;span class="nt"&gt;--node&lt;/span&gt; 22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;POSIX only (macOS/Linux), node ≥ 18, MIT. If you're on an npm version I haven't verified, run &lt;code&gt;NVMPIN_INTEGRATION=1 npm test&lt;/code&gt; in the repo and tell me what the diagnostic line says — that's free contract verification for everyone.&lt;/p&gt;

&lt;p&gt;GitHub: github.com/alexkolovsky/nvmpin · npm: npmjs.com/package/nvmpin&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>npm</category>
    </item>
    <item>
      <title>I Built an 8-bit RPG With Zero Image or Audio Files. Here's How.</title>
      <dc:creator>Alex Kolovskiy</dc:creator>
      <pubDate>Fri, 10 Jul 2026 20:42:58 +0000</pubDate>
      <link>https://dev.to/kolovsky/i-built-an-8-bit-rpg-with-zero-image-or-audio-files-heres-how-41hm</link>
      <guid>https://dev.to/kolovsky/i-built-an-8-bit-rpg-with-zero-image-or-audio-files-heres-how-41hm</guid>
      <description>&lt;h2&gt;
  
  
  A game with no assets folder
&lt;/h2&gt;

&lt;p&gt;Open the source of most browser games and you will find a &lt;code&gt;public/&lt;/code&gt; directory sagging under the weight of PNGs, sprite sheets, and &lt;code&gt;.mp3&lt;/code&gt; files. Open the source of &lt;strong&gt;Pixel Quest&lt;/strong&gt; and you will find… fonts. Two of them. That's it.&lt;/p&gt;

&lt;p&gt;No sprite sheets. No music files. No sound effects. And yet the game hums with chiptune battle themes, splashes damage numbers off a lunging hero, and renders four hand-drawn 16×16 heroes marching across a CRT-scanlined landing page.&lt;/p&gt;

&lt;p&gt;Everything you see and hear is &lt;em&gt;computed&lt;/em&gt;. This is the story of how that came together — a month-long build (74 commits, June to July) of a bilingual, choice-driven, permadeath-optional RPG that lives entirely inside &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; and a text editor's worth of arithmetic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The pitch:&lt;/strong&gt; pick a class, brave five cursed lands, pass d20 skill checks, out-think telegraphed boss attacks, and choose an ending. In English or Ukrainian. On your phone or your desktop. With nothing downloaded but code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The stack, briefly
&lt;/h2&gt;

&lt;p&gt;Nothing exotic — the trick is what we &lt;em&gt;don't&lt;/em&gt; add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://nextjs.org" rel="noopener noreferrer"&gt;Next.js 16&lt;/a&gt;&lt;/strong&gt; (App Router, TypeScript) — the app shell, landing page, and SEO layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://phaser.io" rel="noopener noreferrer"&gt;Phaser 3&lt;/a&gt;&lt;/strong&gt; — the game engine: scenes, rendering, tweens, input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drizzle ORM + Neon serverless Postgres + Auth.js&lt;/strong&gt; — optional cloud saves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vitest + Playwright&lt;/strong&gt; — unit tests for the combat math and story graph, smoke tests for real gameplay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero runtime art or audio dependencies.&lt;/strong&gt; Custom i18n, a seeded RNG, a pixel-sprite texture generator, and a WebAudio synth do the rest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last bullet is the whole personality of the project. Let's pull it apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sprites are just strings
&lt;/h2&gt;

&lt;p&gt;Every character, enemy, and prop is a 16×16 grid described as an array of strings. Each character is a key into a palette; &lt;code&gt;.&lt;/code&gt; is transparent. Here is the warrior, in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SPRITES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PixelSprite&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;warrior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;X&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#10101e&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#c8d0e0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;D&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#8890a8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;F&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#f0c090&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;A&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#4868b0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;B&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#283878&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;G&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffd040&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;................&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.....XXXXXX.....&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;....XSSSSSSX....&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;....XSDDDDSX....&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;....XSFFFFSX..X.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;....XXFXXFX..XS.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// ...the rest of the knight...&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;At boot, a generator walks these grids and paints them into Phaser textures pixel-by-pixel. No image decoding, no network round-trips, no &lt;code&gt;onload&lt;/code&gt; races — the art is &lt;em&gt;already there&lt;/em&gt; the moment the JavaScript parses. It also means a sprite is a diff-able, reviewable, version-controlled artifact. Want to give the mage a bigger hat? Edit two rows of a string and the pull request shows you exactly what changed.&lt;/p&gt;

&lt;p&gt;The same sprites pull double duty on the marketing site: a small helper re-renders these grids as inline SVG data URIs so the four classes can parade across the landing page — server-rendered, no client JS required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Music is arithmetic, not audio
&lt;/h2&gt;

&lt;p&gt;There is no soundtrack file to stream. Instead, a tiny WebAudio synth schedules oscillators against the audio clock — persistent lead and bass "voices" playing note sequences defined per campaign and per scene. Chapters get a wandering theme; boss fights get something with more teeth.&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;type&lt;/span&gt; &lt;span class="nx"&gt;SfxName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hurt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;death&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;victory&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;levelup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;phase&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each effect is a shaped blip — a crit is just a hit with more attitude. Because it's synthesized on demand, the entire audio layer costs zero kilobytes to download and respects independent &lt;strong&gt;Sound&lt;/strong&gt; and &lt;strong&gt;Music&lt;/strong&gt; toggles. Flip music back on mid-battle and it resumes the track the current scene last asked for, because the synth &lt;em&gt;remembers the desired track even while muted&lt;/em&gt;. Little touches like that are cheap when your audio engine is a hundred lines of code instead of a media pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determinism, so the dice can be trusted
&lt;/h2&gt;

&lt;p&gt;An RPG lives and dies by its randomness, and randomness is notoriously hard to test. The fix is a seeded PRNG — &lt;code&gt;mulberry32&lt;/code&gt; — injected everywhere a die is rolled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mulberry32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Rng&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;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="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mh"&gt;0x6d2b79f5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&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;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;61&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4294967296&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="cm"&gt;/** Classic d20 roll: integer in [1, 20]. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;d20&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;defaultRng&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rollInt&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;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rng&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;In production, &lt;code&gt;defaultRng&lt;/code&gt; is &lt;code&gt;Math.random&lt;/code&gt;. In tests, you hand the combat engine a fixed seed and assert that a level-3 rogue with a lucky charm passes a DC-14 lockpick exactly when it should. The dice ceremony players see on screen — the d20 tumbling to a verdict — is the same function the test suite pins down to the integer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The balance sim that caught a lie
&lt;/h2&gt;

&lt;p&gt;Here's my favorite part, because it's where the game caught &lt;em&gt;me&lt;/em&gt; in a mistake.&lt;/p&gt;

&lt;p&gt;Pixel Quest ships three difficulties, and the top one — &lt;strong&gt;Nightmare&lt;/strong&gt; — promises permadeath and pain. I &lt;em&gt;thought&lt;/em&gt; it was brutal. Then I wrote a Monte-Carlo balance simulator (&lt;code&gt;scripts/balance-sim.ts&lt;/code&gt;) that plays thousands of full runs with a "competent player" policy: it guards against telegraphed heavy attacks, uses AGI to dodge, ticks status effects on both sides, charges and spends ultimate meters, and has the cleric hold its heal-cleanse until it actually needs it.&lt;/p&gt;

&lt;p&gt;The verdict was humbling. Old Nightmare was, statistically, &lt;strong&gt;Adventurer difficulty plus permadeath&lt;/strong&gt;. Its boss attack multiplier (1.025) was actually &lt;em&gt;below&lt;/em&gt; Adventurer's (1.07) — Nightmare bosses hit &lt;em&gt;softer&lt;/em&gt;. The scary label was a placebo.&lt;/p&gt;

&lt;p&gt;So Nightmare got a real identity: a new &lt;code&gt;heavyMul&lt;/code&gt; field that makes telegraphed "!!" attacks land at &lt;strong&gt;1.8×&lt;/strong&gt; if you ignore the warning.&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="nx"&gt;nightmare&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;enemyHpMul&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;enemyAtkMul&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;bossHpMul&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;bossAtkMul&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;dcShift&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Heavies land at 1.5 x 1.2 = 1.8x: telegraphs demand an answer.&lt;/span&gt;
  &lt;span class="nx"&gt;heavyMul&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;startPotions&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;potionHeal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;permadeath&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Nightmare's turn-to-turn identity is &lt;em&gt;guard play as survival, not optimization&lt;/em&gt;. Miss a telegraph and you feel it. The sim confirmed the new tiers: Nightmare survival lands around 0.25–0.4× the Adventurer rate. The lesson: &lt;strong&gt;your intuition about difficulty is a hypothesis; a simulator is the experiment.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A story graph the tests refuse to let me break
&lt;/h2&gt;

&lt;p&gt;The narrative is data: branching scenes, requirements, effects, skill checks, riddles, secret paths, and a moral choice that forks the ending three ways — roughly 4,000 lines of it across four campaigns, every word mirrored in English and Ukrainian.&lt;/p&gt;

&lt;p&gt;Prose that large &lt;em&gt;will&lt;/em&gt; rot. Links break. A translation goes missing. A chapter forgets to end in a boss. So the story is validated by the unit suite as a graph:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every scene must be reachable — no orphans.&lt;/li&gt;
&lt;li&gt;Every choice must point somewhere real — no dangling links.&lt;/li&gt;
&lt;li&gt;Every line must exist in &lt;strong&gt;both&lt;/strong&gt; locales.&lt;/li&gt;
&lt;li&gt;Every chapter must terminate in a boss fight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I fat-finger a scene id, &lt;code&gt;npm test&lt;/code&gt; goes red before a player ever hits a dead end. Content becomes as safe to refactor as code, which is the only way a solo dev keeps four campaigns coherent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Saving without losing anyone
&lt;/h2&gt;

&lt;p&gt;Progress auto-saves to &lt;code&gt;localStorage&lt;/code&gt; after every scene, and — if you sign in — mirrors to Postgres for cross-device play. But saves are where players get hurt, so the code is paranoid on their behalf.&lt;/p&gt;

&lt;p&gt;The write itself is best-effort: Safari private mode and quota limits can throw &lt;em&gt;mid-combat&lt;/em&gt;, and a crash there would eat the run, so writes are wrapped and failures are swallowed silently — you keep playing, just without persistence. Schema changes run through an explicit migration ladder so a v1 save from launch week upgrades cleanly to v4 today. And if a save blob is ever unreadable — corrupt JSON, a version from the future — it isn't discarded:&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="c1"&gt;// Unknown/future version: park the blob before reporting "no save" so the&lt;/span&gt;
&lt;span class="c1"&gt;// run isn't silently lost when a New Game overwrites the main key.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;migrated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;safeWrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BACKUP_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bad save gets parked in a backup key so a future fixed client — or a hand recovery — can still bring the run back. Nobody's dragon-slaying afternoon gets thrown away by a schema bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Made in Ukraine
&lt;/h2&gt;

&lt;p&gt;One more thing that isn't a technical decision but is the truest part of the project: Pixel Quest is made in Ukraine, ships in Ukrainian as a first-class language (not an afterthought bolted onto English), and the landing page carries a banner for &lt;a href="https://uanimals.org" rel="noopener noreferrer"&gt;uanimals.org&lt;/a&gt; — a hand-drawn pixel cat bobbing beside floating hearts, linking to help for animals caught in the war. It's a small pixel gesture. It felt like the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to try it
&lt;/h2&gt;

&lt;p&gt;Pixel Quest runs entirely in the browser — nothing to install, nothing to download. Pick a class, pick a difficulty, and press &lt;strong&gt;START&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Play it now:&lt;/strong&gt; &lt;a href="https://pixelquestgame.com" rel="noopener noreferrer"&gt;pixelquestgame.com&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;A few things worth trying on your first run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the telegraphs.&lt;/strong&gt; When an enemy winds up a &lt;code&gt;!!&lt;/code&gt; heavy, guarding isn't optimal play — on Nightmare it's survival.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn the sound on.&lt;/strong&gt; Every note is synthesized on the fly — there isn't a single audio file in the build.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pixel Quest is a small game, but it's proof that "no assets" is a feature, not a limitation — and that the most interesting parts of a game are often the systems the player never directly sees.&lt;/p&gt;

&lt;p&gt;Let me know what you think; I'll continue to improve this project. &lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>typescript</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
