<?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>Fire. Flour. Amore. — a Neapolitan pizzeria landing page with a chef who judges your toppings</title>
      <dc:creator>Alex Kolovskiy</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:48:15 +0000</pubDate>
      <link>https://dev.to/kolovsky/fire-flour-amore-a-neapolitan-pizzeria-landing-page-with-a-chef-who-judges-your-toppings-29m4</link>
      <guid>https://dev.to/kolovsky/fire-flour-amore-a-neapolitan-pizzeria-landing-page-with-a-chef-who-judges-your-toppings-29m4</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, Perfect Landing&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pizza Mania&lt;/strong&gt; — a landing page for a fictional Neapolitan pizzeria where the comfort food is pizza, and the comfort is aggressively defended by a chef named Giovanni.&lt;/p&gt;

&lt;p&gt;The centerpiece is a &lt;strong&gt;build-your-own-pizza workshop&lt;/strong&gt;: toppings composite as layered SVGs on a live pizza, the price ticks up in real time, and every ingredient lands with a little "thud" on the placemat. Add pineapple, and Giovanni scooters in to protest. Add it again in the same session and he comes back &lt;em&gt;angrier&lt;/em&gt;. The third time he just… stops reacting. His disappointment is persisted in &lt;code&gt;sessionStorage&lt;/code&gt;, because emotional continuity matters.&lt;/p&gt;

&lt;p&gt;Around the workshop there's a full landing page: five house pizzas with a hand-drawn poster art direction, a cart with a real checkout flow (delivery to 11 countries, including Ukraine 🇺🇦), customer reviews — one of which honestly withholds a star — a love letter to Neapolitan dough, and the whole thing in &lt;strong&gt;three languages: English, Italian, and Ukrainian&lt;/strong&gt;, with real pluralization rules for each.&lt;/p&gt;

&lt;p&gt;Also: if your cart contains pineapple, the "Place order" button dodges your mouse. Twice. Then it gives up and mutters &lt;em&gt;"va bene."&lt;/em&gt; It never dodges keyboard users — Giovanni has opinions, not accessibility violations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;🍕 &lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://pizzamania.kolovsky.dev/" rel="noopener noreferrer"&gt;https://pizzamania.kolovsky.dev/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📦 &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/alexkolovsky/pizzamania" rel="noopener noreferrer"&gt;https://github.com/alexkolovsky/pizzamania&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%2Fwmhxcpl3emcfrnq192ev.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%2Fwmhxcpl3emcfrnq192ev.png" alt="Pizza Mania — fire. flour. amore." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try this path: hero → "Build your pizza" → add pineapple → apologize → remove pineapple (Giovanni visibly forgives you) → "Surprise me" → order. Then switch the language to Italian and do it again, because Giovanni is funnier in Italian.&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The stack is deliberately boring in the right places.&lt;/strong&gt; Astro 7 with fully static output, and exactly &lt;em&gt;one&lt;/em&gt; Svelte 5 island for the builder + cart. Everything else ships zero JS. The nav's cart badge lives &lt;em&gt;outside&lt;/em&gt; the island — a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; and the island talk to the same nanostores atoms, which turned out to be a lovely way to prove state can cross the island boundary in both directions. GSAP only loads via dynamic &lt;code&gt;import()&lt;/code&gt; at the moment a gag actually plays, so Giovanni never touches the initial bundle. No UI framework for styles — hand-written CSS with custom properties and &lt;code&gt;clamp()&lt;/code&gt; type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real work was making the jokes accessible.&lt;/strong&gt; Every gag has an accessibility-equivalent path, not just an escape hatch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One shared &lt;code&gt;aria-live&lt;/code&gt; region for the whole page. Toasts are &lt;code&gt;aria-hidden&lt;/code&gt;; the same text goes through the live region exactly once, and same-tick announcements get &lt;em&gt;queued and joined&lt;/em&gt; instead of overwriting each other (adding a 10th topping says both "topping added" &lt;em&gt;and&lt;/em&gt; "maybe you want a lasagna?").&lt;/li&gt;
&lt;li&gt;Under &lt;code&gt;prefers-reduced-motion&lt;/code&gt;, Giovanni's scene doesn't even mount — his lines arrive as a static toast instead. Same joke, no motion.&lt;/li&gt;
&lt;li&gt;The cart panel is a real dialog: focus trap, &lt;code&gt;inert&lt;/code&gt; + scroll-lock on everything behind it, focus re-anchoring when the cart/checkout/success stages swap the DOM out from under you, and focus restore to the trigger on close.&lt;/li&gt;
&lt;li&gt;The composite pizza is one &lt;code&gt;role="img"&lt;/code&gt; whose label reads the current recipe &lt;em&gt;and price&lt;/em&gt; out loud.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The thing I'm most proud of&lt;/strong&gt; is a color: &lt;code&gt;#b07708&lt;/code&gt;. The mustard stars looked great and measured &lt;strong&gt;2.05:1&lt;/strong&gt; against the cream cards — decorative to a contrast checker, meaningless to an actual human squinting at them. Finding a gold that still reads as &lt;em&gt;gold&lt;/em&gt; but clears 3:1 on every background took longer than some features. That's frontend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; &lt;code&gt;hreflang&lt;/code&gt; links must be absolute URLs (Lighthouse will politely dock you until you believe it); Safari needs &lt;code&gt;tabindex="-1"&lt;/code&gt; on the skip-link target; and a marquee needs a pause mechanism even when it already respects reduced motion — WCAG 2.2.2 doesn't care that it looks cool.&lt;/p&gt;

&lt;p&gt;Final Lighthouse on the production build: &lt;strong&gt;99 / 100 / 100 / 100&lt;/strong&gt; (perf / a11y / best practices / SEO).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; sound design (a faint wood-fire crackle on the hero is &lt;em&gt;right there&lt;/em&gt;), more Giovanni lore, and possibly a &lt;code&gt;prefers-reduced-judgment&lt;/code&gt; media query for pineapple lovers.&lt;/p&gt;

&lt;p&gt;No pizzas were harmed. One chef was mildly inconvenienced.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>frontendchallenge</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I built a Korean fire-noodle stall in pure CSS — and you can eat it</title>
      <dc:creator>Alex Kolovskiy</dc:creator>
      <pubDate>Sat, 08 Aug 2026 18:30:40 +0000</pubDate>
      <link>https://dev.to/kolovsky/i-built-a-korean-fire-noodle-stall-in-pure-css-and-you-can-eat-it-475m</link>
      <guid>https://dev.to/kolovsky/i-built-a-korean-fire-noodle-stall-in-pure-css-and-you-can-eat-it-475m</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, CSS Art&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspiration
&lt;/h2&gt;

&lt;p&gt;Hello all, I want to share my favorite dish — Buldak (Korean fire noodles). I don't eat it that often lately since it's pretty spicy, but I recommend trying it if you've never had Korean food before. Cheers.&lt;/p&gt;

&lt;p&gt;Instead of just drawing a bowl, I built a whole Korean street stall (포차 / pocha) where you assemble your own pot: toss in toppings from a side-dish tray, pick your heat level with chili peppers, and — when it's ready — eat it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;iframe height="600" src="https://codepen.io/editor/kolovskiy/embed/019fe278-6ff0-767a-ad8a-8c01298d3ac5?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Things to try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tap the side dishes — each topping gets tossed into the pot with a little arc and a squash landing, and the dish gets a red 담김 ("added") stamp&lt;/li&gt;
&lt;li&gt;Crank the heat to 핵불닭 (nuclear) — the sauce saturates, bubbles boil faster, flames erupt under the pot, the pot starts trembling, and the stall lights flicker&lt;/li&gt;
&lt;li&gt;Hit 먹자 EAT! — the chopsticks dive in, the noodles disappear in bites, and you're left with a sauce-smeared pot and a "잘 먹었습니다" note&lt;/li&gt;
&lt;li&gt;Watch the receipt — it counts your toppings and totals the price live
## Journey&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The gimmick:&lt;/strong&gt; the entire builder is a CSS state machine. Every topping is a checkbox, the heat level is a radio group, and even "eat" is just one more checkbox — the whole scene reacts through &lt;code&gt;:has()&lt;/code&gt; selectors. The challenge allows a sprinkle of JavaScript, and I spent my entire JS budget on one thing: a ~6-line "chef's pick" randomizer button. Everything else — the toss animations, the heat states, the eating sequence — is pure CSS.&lt;/p&gt;

&lt;p&gt;Favorite tricks in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Noodles from border arcs.&lt;/strong&gt; Each strand is an ellipse with &lt;code&gt;border: 7px solid transparent&lt;/code&gt; and only &lt;code&gt;border-top-color&lt;/code&gt; set — you get a clean curved noodle for one element. A pile of them at different sizes and rotations, plus a red radial overlay with &lt;code&gt;mix-blend-mode: multiply&lt;/code&gt; for the sauce coating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The receipt is CSS counters.&lt;/strong&gt; &lt;code&gt;counter-reset: tops 0 price 350&lt;/code&gt; on the root, then every checked dish does &lt;code&gt;counter-increment: tops 1 price 50&lt;/code&gt;, and the bill renders &lt;code&gt;counter(tops) " added · ₩" counter(price) "0"&lt;/code&gt;. The base pot is ₩3500, each topping adds ₩500 — computed and displayed with zero JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eating in bites, not a wipe.&lt;/strong&gt; The eat sequence animates &lt;code&gt;clip-path: inset()&lt;/code&gt; through stepped keyframes — chomp to 31%, settle back to 28%, chomp to 64%, settle, gone — synced with the chopsticks' dive animation. The little settle-backs between bites sold the effect more than anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Toss physics with one keyframe.&lt;/strong&gt; Toppings fly in from the tray's direction, overshoot, squash on landing (&lt;code&gt;scale: 1.08 .9&lt;/code&gt;), rebound, and settle. Each ingredient keeps its own final rotation because the keyframes end on &lt;code&gt;rotate: var(--r)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sesame is one element.&lt;/strong&gt; A single 5px dot with a dozen &lt;code&gt;box-shadow&lt;/code&gt; offsets, mixing golden and black toasted seeds.
&lt;strong&gt;What I learned:&lt;/strong&gt; &lt;code&gt;:has()&lt;/code&gt; has quietly turned CSS into a real state-management tool. Once I stopped thinking "how do I fake interactivity" and started thinking "what is my state and what observes it," the whole thing became a set of declarative rules — honestly cleaner than some JS state code I've written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also learned to put transition delays in the &lt;em&gt;checked-state&lt;/em&gt; rules, not the base rules. My first eat sequence took three seconds to reset because the delays applied in both directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest limitation:&lt;/strong&gt; real drag-and-drop is impossible in pure CSS (nothing can track pointer position), so instead of faking it badly, I made the tap feel physical — the tray dish empties, gets stamped, and the ingredient arcs into the pot. There's a genuine pure-CSS drag primitive (&lt;code&gt;resize&lt;/code&gt; handles + container queries) I considered for a "pull to pour sauce" mechanic, but it's clunky on touch, so it didn't make the cut.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small things I'm proud of:&lt;/strong&gt; the dented brass 양은냄비 pot (the pot every Korean instant noodle deserves) instead of a generic bowl, the celadon-glazed side dishes, and that &lt;code&gt;prefers-reduced-motion&lt;/code&gt; users still get a fully working builder — the eat still works, just without the theatrics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next:&lt;/strong&gt; maybe a slurp. If anyone knows how to make sound in pure CSS, my DMs are open.&lt;/p&gt;

&lt;p&gt;Code is MIT — fork it, add your own toppings.&lt;/p&gt;

</description>
      <category>frontendchallenge</category>
      <category>devchallenge</category>
      <category>css</category>
      <category>webdev</category>
    </item>
    <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>
