← back to the workbench

the guide

How Pixel Alchemist was made

A micro crafting game — 39 elements, every sprite drawn live as 16×16 procedural pixel art. No image files, no sprite sheets, no libraries. One HTML file, a canvas, and some trigonometry with opinions.

↑ these four are being drawn right now, pixel by pixel, by the same code excerpted below.

01The concept

The game is the old alchemist's fantasy: start with fire, water, earth and air, smash pairs together, and follow the chain of discoveries all the way to the Philosopher's Stone. The design bet was that every single visual — icons, logo, particles, background embers — could be generated by code at runtime, and that the constraint would produce charm instead of limiting it.

The whole site ships as static HTML/CSS/JS. The main page is one file. The only network requests are two Google Fonts.

02Art direction

The mood is a midnight workshop: near-black indigo, one warm amber light source, and small doses of arcane green and cyan. Dark scenes need depth, so the page layers a radial glow, drifting ember particles, a vignette, and a faint CRT scanline overlay — four cheap layers that read as atmosphere.

bg #0b0913
panel #1a1428
line #2e2547
ink #ece4d4
amber #ffb648
green #7ce38b
cyan #6fd7e6

Type is Pixelify Sans for display and VT323 for everything else — both bitmap-flavored Google Fonts. Panels get stepped "pixel-notch" corners with a single clip-path utility instead of border images:

/* stepped 8px corners on any box — pure CSS, no assets */
.notch{ clip-path: polygon(0 8px, 4px 8px, 4px 4px, 8px 4px, 8px 0,
  calc(100% - 8px) 0, /* …mirrored around all four corners… */ 0 calc(100% - 8px)); }

03The 16×16 sprite engine

Every element implements one function: draw(g, t) — paint into a 16×16 grid g at time t. The grid is just an array of 256 color strings with a few painter helpers:

function makeGrid(){
  return {
    d: new Array(16*16).fill(null),
    px(x,y,c){ x|=0; y|=0; if(x<0||x>15||y<0||y>15) return; this.d[y*16+x]=c; },
    rect(x,y,w,h,c){ /* px in a loop */ },
    disc(cx,cy,r,c){ /* px where dx²+dy² ≤ r² — passing c=null ERASES,
                        which is how the moon's crescent is cut */ },
  };
}
// blitting is trivial: one 1×1 fillRect per non-empty cell,
// on a 16×16 canvas that CSS upscales with image-rendering: pixelated
function renderGrid(ctx,g){
  ctx.clearRect(0,0,16,16);
  for(let i=0;i<256;i++){ const c=g.d[i]; if(!c) continue;
    ctx.fillStyle=c; ctx.fillRect(i%16,(i/16)|0,1,1); }
}

Randomness must be deterministic (a sprite should flicker, not boil), so texture comes from a hash-based value noise instead of Math.random():

function h2(x,y){ const n=Math.sin(x*127.1+y*311.7)*43758.5453; return n-Math.floor(n); }
function vnoise(x,y){ // bilinear value noise with smoothstep — 6 lines, no deps
  const xi=Math.floor(x), yi=Math.floor(y), xf=x-xi, yf=y-yi;
  const s=a=>a*a*(3-2*a), u=s(xf), v=s(yf);
  return h2(xi,yi)*(1-u)*(1-v) + h2(xi+1,yi)*u*(1-v)
       + h2(xi,yi+1)*(1-u)*v   + h2(xi+1,yi+1)*u*v;
}

Anatomy of the flame

The fire sprite is a column-height field: each column's flame height follows an inverted parabola (tall middle, short edges) modulated by noise scrolling through time. Color is picked by a "heat" score — hotter near the bottom-center, noisier near the tips:

function flame(g,t,C){                       // C = 5-color ramp, dark→bright
  for(let x=2;x<=13;x++){
    const cx=Math.abs(x-7.5)/5.5;           // 0 at center, 1 at edge
    const n=vnoise(x*0.9, t*2.6);           // column jitter, animated
    const h=(1-cx*cx)*12*(0.55+0.55*n);     // parabolic height
    for(let y=0;y<h;y++){
      const f=y/h;                          // 0 base → 1 tip
      const heat=(1-f)*(1-cx*0.8) + 0.28*vnoise(x*1.3+9, (14-y)*0.7-t*3.2);
      g.px(x, 14-y, heat>.78?C[4] : heat>.56?C[3] : heat>.33?C[2] : C[1]);
    }
  }
}

Most of the 39 sprites reuse a small kit of painters — flame, cloudShape, mound, ingot — re-skinned with different 5-color palettes. Lava is a dark mound plus a short flame. Gold is the metal ingot with a warmer ramp and a sparkle. The Phoenix is the bird with fire colors and a trailing ember wake. Constraint-driven reuse is what keeps the set coherent.

04Animation at 11 fps, on purpose

Pixel art reads better when it steps. One requestAnimationFrame loop redraws every mounted icon, but only every 90 ms — authentic chunky motion, and ~45 animated canvases cost almost nothing:

let last=0;
function tick(now){
  if(now-last >= 90){            // ≈11fps — the pixel-art sweet spot
    last=now; const t=now/1000;
    for(const r of icons){ r.grid.clear(); r.el.draw(r.grid,t); renderGrid(r.ctx,r.grid); }
  }
  requestAnimationFrame(tick);
}

CSS transitions that should feel mechanical use steps() easing (button presses, card hops); everything organic uses an expo-out cubic-bezier. With prefers-reduced-motion, sprites render one static frame and the particle systems stay off.

05The crafting graph

Recipes are unordered pairs, so the lookup key is just the two ids sorted and joined. The whole game logic is a dictionary hit:

const RECIPES={};   // built from each element's declared recipe
ELEMENTS.forEach(e=>{ if(e.recipe) RECIPES[e.recipe.slice().sort().join('+')]=e.id; });

const key=[a,b].sort().join('+');   // 'fire+water'
const result=RECIPES[key];          // → 'steam' | undefined (fizzle)

The 39-node graph is a hand-tuned DAG: four roots, ~7 tiers, one apex. Progress is saved to localStorage after every discovery. The hint button filters recipes to those whose ingredients are already on your shelf — the grimoire never spoils something you can't actually make yet.

Spoiler — the full recipe book
resultreciperesultrecipe
Steamfire + waterPlantearth + rain
Lavafire + earthTreeplant + earth
Energyfire + airForesttree + tree
Mudwater + earthSwampmud + plant
Rainwater + airLifeswamp + energy
Dustearth + airGolemlife + clay
Seawater + waterFishlife + sea
Stonelava + airBirdlife + air
Obsidianlava + waterPhoenixbird + fire
Cloudsteam + airSkycloud + air
Stormcloud + energySunsky + fire
Sandstone + airMoonsky + stone
Glasssand + fireStarsky + energy
Metalstone + fireSaltsea + sun
Claymud + sandCrystalstone + energy
Brickclay + fireGoldmetal + sun
Wallbrick + brickPhilosopher's Stonegold + life
Housewall + glass

06Chiptune bleeps from thin air

No audio files either. A single WebAudio helper makes every sound in the game — square-wave selects, a sawtooth fizzle, an arpeggio for discoveries:

function blip(f, d=0.08, type='square', vol=0.18, slide=0){
  const o=AC.createOscillator(), g=AC.createGain();
  o.type=type; o.frequency.setValueAtTime(f, AC.currentTime);
  if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide), AC.currentTime+d);
  g.gain.setValueAtTime(vol, AC.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+d);   // no clicks
  o.connect(g).connect(AC.destination); o.start(); o.stop(AC.currentTime+d+0.02);
}
// discovery = ascending major arpeggio
[392,523,659,880].forEach((f,i)=> setTimeout(()=>blip(f,0.12), i*90));

The AudioContext is created lazily on the first user gesture (a browser requirement anyway), and the ♫ toggle in the header persists the preference.

07Juice: particles, runes, embers

08Dressing the game as a site

Version two wrapped the game in a real website without touching the engine. Everything new is built from the same three ingredients — the sprite engine, the amber palette, and the notch chrome — so the dressing reads as one world:

Why still no AI imagery: the site's entire identity is "zero image files — every pixel placed by code." A photographic AI hero would have been easy and would have broken the claim. The upgrade budget went to motion and structure instead.

09Replicate it

  1. Make a 16×16 canvas, upscale it with CSS width/height and image-rendering: pixelated. That's the entire rendering stack.
  2. Write the grid + vnoise helpers above. Draw one flame. Tune it until it feels alive at 11fps — this is 80% of the craft.
  3. Build a painter kit (mound, cloud, ingot, orb) and define each element as {id, name, recipe, flavor, draw}. Reuse painters with new palettes.
  4. Recipes = sorted-pair dictionary. State = a localStorage array of ids.
  5. Add juice in this order: palette-sampled particle bursts → discovery toast → sound → idle background. Stop before it stops being tiny.
No AI-generated raster assets were used anywhere on this site — every pixel is procedural. The one cheat allowed: two Google Fonts, because type is the one thing you shouldn't pixel by hand at 2am.