pixelfx
Pixel art effects as code. Each effect is one file, src/fx/<category>/<name>.js, that draws into two buffers:
color (pixels: opaque, dithered or truly translucent) and light (additive glow). No images, so an effect
can be played live, recolored, mirrored, reseeded and rendered to a sprite sheet for any engine.
The method is Hugo Duprez's (How to make (good) pixel art effects with Claude): code is clay, trust your eyes, feed your references. One-shot prompts make mediocre effects. Drafts you render, look at and refine make good ones.
Before anything
- Work in a pixelfx checkout. If the working directory has no
tools/render.mjs, clone one next to the project:git clone https://github.com/patch-gg/pixelfx. No install step: the tools use only Node. - Check it runs:
node tools/render.mjs fire/torchwritesrecords/fire/torch/contact.png. Open that PNG with your image-reading tool. That contact sheet is how you see every effect from now on.
1. Look for an existing effect first
node tools/list.mjs <words> searches ids, names, tags and descriptions (node tools/list.mjs frost,
node tools/list.mjs slash loop). docs/CATALOG.md lists everything by category. Render the candidates and
look at them before choosing. Most requests are an existing effect with different options:
palette: the main ramp, dark to bright, as hex strings. AnyPALramp insrc/core/color.js(fire ember lava smoke dust ice water electric volt poison holy heal arcane shadow blood earth stone gold leaf wood metal bone neon cyber matrix candy night) or your own. A blue fire is{"palette": PAL.ice}.seedfor a different but equivalent take;flipto face left;angleto rotate;light(0..1+) for glow strength; plus each effect's own knobs in itsmeta.options(size, count, radius, text, height...).- Try options without editing code:
node tools/render.mjs fire/torch --opts '{"palette":["#102","#215","#43a","#8cf","#fff"]}'.
2. Put it in the game
Canvas or WebGL games in JS or TS (plain canvas, Phaser, PixiJS, Kaplay, three.js): use the code directly. It
has no dependencies; copy src/core and the effect files you need, or depend on the repo.
jsimport { createFrame, clearFrame, createFx, composite } from "pixelfx"; // src/core/index.js import * as frostNova from "pixelfx/fx/ice/frost-nova"; // src/fx/ice/frost-nova.js const frame = createFrame(320, 180); // the game's pixel resolution; ox, oy scroll it with the camera const img = new ImageData(320, 180); const fx = createFx(frostNova, { x: hero.x, y: hero.y, flip: hero.facing < 0 }); // every tick fx.update(dt); clearFrame(frame); fx.draw(frame, "back"); // what goes behind characters drawCharacters(); // your own sprites, or draw them into frame.color yourself fx.draw(frame, "front"); composite(frame, img.data, background); // color over the background plus light; or null for transparent ctx.putImageData(img, 0, 0); // then scale the canvas up with image-rendering: pixelated if (fx.done()) removeIt(); // one-shots end on their own; fx.stop() fades a loop out
Several effects can share one frame. In a WebGL engine, upload the composited RGBA to a texture each frame, or render the effect to a sprite sheet once (below) and play it like any other animation.
Any engine (Godot, Unity, GameMaker, Defold, LÖVE, Bevy, Construct): export sprite sheets.
node tools/render.mjs ice/frost-nova --sheet writes records/ice/frost-nova/frost-nova.png (transparent
frames in a grid) and frost-nova.json (Aseprite-style frames, so Phaser's atlas loader, Godot and Unity Aseprite
importers and most tools read it; meta.pixelfx has fps, loop, frame size, columns and the pivot). Set the
pivot to meta.pixelfx.pivot (the effect's origin, e.g. the feet), import with nearest-neighbour filtering and no
compression, and play at meta.pixelfx.fps. Glow is baked into the sheet as color with alpha, which reads as
additive over dark scenes; for bright scenes, render a recolored version or keep the effect on a dark layer.
For the full library: node tools/render.mjs <category> --sheet does a whole category.
3. Make a new one
Read docs/AUTHORING.md first: it is the contract (meta, draw(p, t, o) for pure effects or create(o) for
simulated ones, local coordinates, seamless loops, ending empty), the toolkit, the painter API and the quality bar.
Then:
- Say it in one line with art direction: what it is, how it moves, its palette, its timing. "A frost nova: crystals erupt in a ring from the feet outward, a white flash at the center, shards spin off, the floor stays frosted and fades."
- Find references in the library:
node tools/list.mjs <words>, then read the two or three closest effect files and borrow their techniques (ParticlesandBitsinsrc/core/sim.jsfor sparks, smoke and debris,p.discfor translucent volumes,cyclefor seamless particles,tileFbmscrolls for flames and water). Look at the closest of Hugo Duprez's effects too: they are the quality bar. If his has more detail, light or life at the same size on screen, yours isn't done. - Draft.
node tools/new.mjs <category>/<name>(add--loopfor a loop) gives a working file. When the direction is open, write 3 to 5 genuinely different drafts (<name>-a.js..-e.js), render them all (node tools/render.mjs "<category>/<name>-*") and look at every contact sheet before choosing. Show the user the strongest two or three if they're around (--gifmakes animations to share). - Look, judge, fix. Render, open
records/<id>/contact.png, and judge like a picky pixel artist. Fix the worst thing, render again. Three rounds minimum; a showpiece takes more.--allshows every frame,--mp4shows motion at full speed. - Check.
node tools/check.mjs <id>catches drawing outside the canvas, one-shots that don't end empty, loop seams, unseeded randomness and slow frames. Fix until it passes clean. Delete the drafts you didn't keep.
What to look for, and the usual fix:
| you see | fix |
|---|---|
| it reads as mush at 1x | fewer colors, a clear silhouette, one focal point, bigger shapes |
| speckle: lone noise pixels inside shapes | threshold noise less, quantize with at(), no noise on small shapes |
| floaty, no punch | fast in, slow out (ease.outQuart/outExpo), a 1 to 2 frame white hero frame, shorter total time |
| stiff or mechanical | vary every particle (size, speed, delay, life), add secondary motion (sparks, smoke, debris) |
| washed out | less glow (k 0.2 to 0.5), darker edge steps, keep white for the hottest core only |
| flat | light from one side: brighter top-left, darker bottom-right step; a darker ramp step at the base |
| smooth gradients, vector look | hard ramp steps in color; softness only in light and in translucent smoke |
| a checkerboard where smoke should be | SMOKE, VAPOUR or DUST particles, or p.disc: truly translucent, lit on top |
| seam when a loop wraps | cycle, loopNoise(phase), whole-cell tileNoise scrolls, sin(phase * TAU * n) |
| a one-shot that pops off | let it shrink, dither out or fall away before t = duration |
Directional effects face right, ground effects stand on y = 0, actor effects wrap o.actor (a 24 x 38 stock
character's mask and pixels), and nothing uses Math.random. Generic motifs are fine; never recreate a specific game's
sprites, characters or logos.
4. Native code instead of JS
If the game can't run JS and a sprite sheet isn't enough (an effect that follows a moving target, or needs
per-instance colors at runtime), port the effect. Pure effects are a function of time; simulated ones step the particles in
src/core/sim.js. Keep the two-buffer idea, the 4x4 Bayer threshold for dithered alpha and the palette ramp lookups.
Render the JS original and your port side by side to check they match.


