pxl: pixel art as code
You write a small .px.js file; pxl renders PNGs and upscaled previews you can look at. The DSL
is built to avoid writing pixels twice: mirroring, auto-outline, palette-swap variants, reusable
parts, primitives, brushes and tilemaps. Needs Node ≥ 22.18. No install step.
PXL below means node <this skill's directory>/bin/pxl.js.
Workflow
- Write
art.px.js(start from the template below; examples inexamples/). - Build only what you're working on:
PXL build art.px.js --only slime,heart - Look at it: read
out/_preview/<name>.png(upscaled, checkerboard = transparent, grid every pixel, darker every 8). For exact pixel fixes usePXL ascii art.px.js <name>(keys + x/y rulers); it's cheaper than an image. - Fix with small edits (a
grid(x, y, ...)patch, one op) and rebuild. Repeat. - If the user wants to watch: start
PXL serve art.px.jsin the background and give them the URL. It rebuilds on every save and replays the edited asset's painting steps; it also writes the sameout/files, so keep reading previews as usual.
Errors name the file:line:col, the asset/layer/op path, and suggest fixes ("did you mean 'r'?"). One broken asset doesn't stop the others from building.
Template
pal({ k: "#1b1630", g: "#8ccf63", G: "#4c8a44", t: "#2a5560", w: "#fff6e0", r: "#e2585e", R: "#a63a4c" })
ramps("ktGg", "kRr") // dark -> light, drives shade()/light()
// Symmetric: write the left half, mirror it, outline it.
sprite("slime", { mx: true, outline: "k" }, `
....ggg
..ggGGg
.ggGwGg
gggkwgg
gggkkgg
tgggggg
.tttttt`)
sprite("slime_red", { base: "slime", swap: { g: "r", G: "R" } }) // variant: 1 line
sprite("_eye", "wk\nkk") // "_" = part, not exported
sprite("chest", { w: 16, h: 12, outline: "k" },
rect(0, 3, 16, 9, "R"), rect(0, 0, 16, 4, "r"), stamp("_eye", 7, 4), shade([0, 9, 16, 3]))
scene("meadow", { w: 160, h: 96, bg: "w" },
layer("ground", { y: 64 }, rect(0, 0, 160, 32, "G"), noise(0, 0, 160, 32, "g", 0.1, 3)),
layer("props", stamp("slime", 40, 54), stamp("slime_red", 70, 54, { flip: "x" })),
layer("shadow", { shade: true }, poly([38, 64, 88, 64, 92, 67, 34, 67], "k"))) // cast shadow
sheet("chars", ["slime*", "chest"], { pad: 1 })
Essentials
- Colors: one-char keys from
pal,"#hex","."skip,"_"erase. Default palette is pico8 (0-f);use("sweetie16" | "db16" | "endesga32" | "gray8")for others. - Items run in order: grid strings (drawn at 0,0), ops,
(c) => { c.rect(...) }functions,layer(...)s. Then mirror → swap → flip → rot → outline (grows 1px each side) → crop. - Shapes:
px rect box line(…, width) hline vline circ ring ellipse poly fill wire text. - Tone:
dither grad noise,shade/light(region, steps, density),recolor, andshade:/light:layers for cast shadows and glows. Regions:[cx,cy,r],[x,y,w,h], polygon, or a function.clip(region, ...items)restricts any items (half shapes, slopes). - Reuse:
stamp tile pattern tilemap scatter. Names can be groups or globs (group("rocks", ["rock_a", "rock_a", "rock_b"]),"tree_*"). Each placement picks randomly but reproducibly. Userflip,rswap,seed, andreseed: truefor per-copy variety. - Brushes:
scatter("_leaf*", x, y, w, h, 30, { on: "g", clip: true, rflip: "xy" })paints texture with tiny part sprites, only on the given colors. - Animation:
anim("cat", { outline: "k", fps: 10 }, grid, frame(), frame(grid(23, 0, "_K"))): frames are patches over shared items or abase; orframes: 6+(c) => ... c.phase ...for procedural loops. Stamping an anim animates the scene (rdelay: truedesyncs copies,frame: nfreezes one). Check_preview/<name>.png(all frames side by side) orpxl asciiper frame. - Glow:
glow("zgQ", { r: 6, strength: 0.5 })directly in a sprite/scene body gives every pixel of those keys a halo (flames, potions, moons, neon);ramp: 2instead stays in the palette. Give glowing things their own keys so nothing else lights up, or limit it withwhere.
Full API with every option: reference.md. Dynamic lighting (lamps, light blocked by solid layers, sky light, Terraria-style): read lighting.md only when a scene needs lights.
Habits that save tokens and give better art
- Write half of anything symmetric (
mx) and letoutlinedraw outlines. Never hand-draw either. - Recolors are
base+swap; repeated details are_parts+stamp; families are agroupor a loop:for (let i = 1; i <= 3; i++) sprite("tree_" + i, ...). - Large areas: primitives,
grad,noise,pattern,tilemap. Never big hand grids. - Organic texture (leaves, grass, stones, rust): 2–3 tiny brush parts +
scatterwithon/clip. - No filler noise. Don't sprinkle
noisespeckle over walls, floors or plaster to make them "less flat": it reads as random dots, not material. Every mark on a surface needs a cause you could name, and it should sit where that cause would put it:- built structure: siding/plank lines, brick or stone courses with offset joints, tile seams;
- weathering: rain-stain streaks under ledges and sills, splash-back grime fading up from the ground, rust runs under metal brackets, paint flaking off boards (following the board lines);
- clutter that tells the story: posters, pipes, vents, cracks from a corner.
Flat color is fine; a clean wall beats a dotted one.
noiseis OK only where the material itself is granular (gravel, sand, asphalt, tar roofs, rough stone), and keep it sparse. On faces drawn at an angle (3/4 views), make the marks follow the face's slant.
- Lighting: define
ramps, thenlight/shaderegions; stack adensity: 0.5pass for soft edges. Useshade: truelayers with apolyfor cast shadows across many objects. - Use mnemonic keys (
g/Glight/dark green). Keep 2–4 shades per material for a clean pixel-art look. - Build with
--onlywhile iterating; a full build also writes every preview.