A game with no assets folder
Open the source of most browser games and you will find a public/ directory sagging under the weight of PNGs, sprite sheets, and .mp3 files. Open the source of Pixel Quest and you will find… fonts. Two of them. That's it.
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.
Everything you see and hear is computed. 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 <canvas> and a text editor's worth of arithmetic.
The pitch: 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.
The stack, briefly
Nothing exotic — the trick is what we don't add:
- Next.js 16 (App Router, TypeScript) — the app shell, landing page, and SEO layer.
- Phaser 3 — the game engine: scenes, rendering, tweens, input.
- Drizzle ORM + Neon serverless Postgres + Auth.js — optional cloud saves.
- Vitest + Playwright — unit tests for the combat math and story graph, smoke tests for real gameplay.
- Zero runtime art or audio dependencies. Custom i18n, a seeded RNG, a pixel-sprite texture generator, and a WebAudio synth do the rest.
That last bullet is the whole personality of the project. Let's pull it apart.
Sprites are just strings
Every character, enemy, and prop is a 16×16 grid described as an array of strings. Each character is a key into a palette; . is transparent. Here is the warrior, in full:
export const SPRITES: Record<string, PixelSprite> = {
warrior: {
colors: { X: "#10101e", S: "#c8d0e0", D: "#8890a8", F: "#f0c090", A: "#4868b0", B: "#283878", G: "#ffd040" },
rows: [
"................",
".....XXXXXX.....",
"....XSSSSSSX....",
"....XSDDDDSX....",
"....XSFFFFSX..X.",
"....XXFXXFX..XS.",
// ...the rest of the knight...
],
},
};
At boot, a generator walks these grids and paints them into Phaser textures pixel-by-pixel. No image decoding, no network round-trips, no onload races — the art is already there 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.
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.
Music is arithmetic, not audio
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.
type SfxName = "click" | "hit" | "crit" | "heal" | "hurt" | "death" | "victory" | "levelup" | "phase";
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 Sound and Music toggles. Flip music back on mid-battle and it resumes the track the current scene last asked for, because the synth remembers the desired track even while muted. Little touches like that are cheap when your audio engine is a hundred lines of code instead of a media pipeline.
Determinism, so the dice can be trusted
An RPG lives and dies by its randomness, and randomness is notoriously hard to test. The fix is a seeded PRNG — mulberry32 — injected everywhere a die is rolled:
export type Rng = () => number;
export function mulberry32(seed: number): Rng {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Classic d20 roll: integer in [1, 20]. */
export function d20(rng: Rng = defaultRng): number {
return rollInt(1, 20, rng);
}
In production, defaultRng is Math.random. 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.
The balance sim that caught a lie
Here's my favorite part, because it's where the game caught me in a mistake.
Pixel Quest ships three difficulties, and the top one — Nightmare — promises permadeath and pain. I thought it was brutal. Then I wrote a Monte-Carlo balance simulator (scripts/balance-sim.ts) 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.
The verdict was humbling. Old Nightmare was, statistically, Adventurer difficulty plus permadeath. Its boss attack multiplier (1.025) was actually below Adventurer's (1.07) — Nightmare bosses hit softer. The scary label was a placebo.
So Nightmare got a real identity: a new heavyMul field that makes telegraphed "!!" attacks land at 1.8× if you ignore the warning.
nightmare: {
enemyHpMul: 1.0,
enemyAtkMul: 1.05,
bossHpMul: 1.1,
bossAtkMul: 1.1,
dcShift: 2,
// Heavies land at 1.5 x 1.2 = 1.8x: telegraphs demand an answer.
heavyMul: 1.2,
startPotions: 0,
potionHeal: 12,
permadeath: true,
},
Now Nightmare's turn-to-turn identity is guard play as survival, not optimization. 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: your intuition about difficulty is a hypothesis; a simulator is the experiment.
A story graph the tests refuse to let me break
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.
Prose that large will 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:
- Every scene must be reachable — no orphans.
- Every choice must point somewhere real — no dangling links.
- Every line must exist in both locales.
- Every chapter must terminate in a boss fight.
If I fat-finger a scene id, npm test 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.
Saving without losing anyone
Progress auto-saves to localStorage 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.
The write itself is best-effort: Safari private mode and quota limits can throw mid-combat, 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:
// Unknown/future version: park the blob before reporting "no save" so the
// run isn't silently lost when a New Game overwrites the main key.
if (!migrated) safeWrite(BACKUP_KEY, raw);
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.
Made in Ukraine
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 uanimals.org — 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.
Where to try it
Pixel Quest runs entirely in the browser — nothing to install, nothing to download. Pick a class, pick a difficulty, and press START.
Play it now: pixelquestgame.com
A few things worth trying on your first run:
-
Read the telegraphs. When an enemy winds up a
!!heavy, guarding isn't optimal play — on Nightmare it's survival. - Turn the sound on. Every note is synthesized on the fly — there isn't a single audio file in the build.
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.
Let me know what you think; I'll continue to improve this project.
Top comments (0)