the guide
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.
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.
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.
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)); }
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; }
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.
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.
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.
| result | recipe | result | recipe |
|---|---|---|---|
| Steam | fire + water | Plant | earth + rain |
| Lava | fire + earth | Tree | plant + earth |
| Energy | fire + air | Forest | tree + tree |
| Mud | water + earth | Swamp | mud + plant |
| Rain | water + air | Life | swamp + energy |
| Dust | earth + air | Golem | life + clay |
| Sea | water + water | Fish | life + sea |
| Stone | lava + air | Bird | life + air |
| Obsidian | lava + water | Phoenix | bird + fire |
| Cloud | steam + air | Sky | cloud + air |
| Storm | cloud + energy | Sun | sky + fire |
| Sand | stone + air | Moon | sky + stone |
| Glass | sand + fire | Star | sky + energy |
| Metal | stone + fire | Salt | sea + sun |
| Clay | mud + sand | Crystal | stone + energy |
| Brick | clay + fire | Gold | metal + sun |
| Wall | brick + brick | Philosopher's Stone | gold + life |
| House | wall + glass |
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.
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:
IntersectionObserver scroll-spy
lights the section you're in; the bar slides down only after fonts are ready, so there
is no unstyled flash.<span>s
with staggered transition-delays for a cascade entrance; "ALCHEMIST" pulses
its amber glow on a steps(3) keyframe. Eight element sprites float around
the title on slow alternate transforms, each with a negative delay so they
never move in sync.y+fade, staggers
card groups, and scrubs the hero floaters upward at different speeds for cheap parallax.
If the CDN fails or prefers-reduced-motion is set, everything is simply
visible — the reveals are additive, never load-bearing.localStorage state as the shelf. Undiscovered entries render as grey
"???" pages; discovered ones show the live sprite, name and recipe, and clicking one
sends it back to the forge. It fills in as you play.translate shake on the whole page (a bigger one for the Philosopher's
Stone), plus palette bursts on all three bench slots. A "shake on/off" toggle in
the HUD persists the preference, and reduced-motion disables it outright.width/height and
image-rendering: pixelated. That's the entire rendering stack.vnoise helpers above. Draw one flame. Tune it until
it feels alive at 11fps — this is 80% of the craft.{id, name, recipe, flavor, draw}. Reuse painters with new palettes.localStorage array of ids.