<?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>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>
