Written with AI help (Claude), checked against the project's docs.
The header, tool cards, link buttons and footer of the GitHub profile at github.com/skulitom are now pixel art that moves: a night workbench where a flask boils and steam rolls up past a window, and a small scene for each tool. AI agents made all of it with Pixelbolt, a pixel-art tool that agents drive with JSON recipes. The liquid, mist and steam are real fluid simulations, shaded as pixel art. And it stays sharp at every width GitHub shows it at.
This post covers the README trick that keeps the pixels crisp, the simulations behind the steam, and how one command rebuilds every pixel to the same bytes.
A profile README is Markdown plus a little HTML. No JavaScript, no <style>, no style="". You get images.
Pixel art needs whole pixels. The usual move is a GIF with width="100%", so it fills the column. But the browser scales that image with smoothing, and GitHub's column is almost never a whole multiple of your art. These are the widths we measured:
| Viewport | README column | Cell of a two-column table |
|---|---|---|
| 1440 px | 846 px | 395.5 px |
| 1280 px | 831 px | 388 px |
| 1012 px | 563 px | 254 px |
| 768 px | 383 px | 164 px |
| 390 px phone (2x screen) | 308 px | 126.5 px |
Here is one frame of a 192x100 card at 846 px wide, rendered by Chrome at 1x on a page that sizes images the way GitHub does. Left, as a plain image. Right, the same frame through the SVG wrapper described below.
An SVG file is an image as far as the README is concerned, but inside it you can write CSS. Two properties do the work:
image-rendering: pixelated on the embedded sprite sheet, so it scales without smoothing.@keyframes animation with step-end timing that moves the sheet one frame at a time behind a clip.
This is the Inkbolt card's wrapper, trimmed (one keyframe stop per frame, 72 in all):
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="200" viewBox="0 0 192 100"
shape-rendering="crispEdges">
<style>
image{image-rendering:pixelated;image-rendering:crisp-edges;image-rendering:pixelated}
.a{animation-timing-function:step-end;animation-iteration-count:infinite}
@keyframes k0{0.0000%{transform:translate(-192px,0px)}1.3889%{transform:translate(-384px,0px)} /* ... */}
.l0{animation-name:k0;animation-duration:5040ms;transform:translate(-192px,0px)}
</style>
<defs><clipPath id="c0"><rect x="0" y="0" width="192" height="100"/></clipPath></defs>
<g clip-path="url(#c0)">
<image class="a l0" width="1536" height="1000" href="data:image/png;base64,..."/>
</g>
</svg>
The sheet is 1536x1000: eight columns and ten rows of 192x100 frames. 72 frames at 70 ms each is 5,040 ms, so each frame owns 100/72 = 1.3889% of the animation. The viewBox is in art pixels, so the README can scale the whole SVG to any width and every art pixel stays a hard-edged block.
In the README it is an ordinary image:
<picture>
<source media="(prefers-reduced-motion: reduce)" srcset="assets/v4/inkbolt-still.svg">
<img src="assets/v4/inkbolt.svg" width="100%" alt="An ink drop falls into water and blooms into a vortex ring">
</picture>
We tested this in Chrome 155, Firefox 157 and WebKit 26.6 at 1x and 2x, and on github.com itself, before drawing any real art. It is cheap: a 96-frame, 208x162 stress sheet came to about 530 KB, and the finished profile's 40 SVGs (the header, phone header, four cards, footer and seven link buttons with their phone-width versions, each with a motionless twin) total 531,619 bytes.
Things that caught us out:
data: URI.prefers-reduced-motion query, but no engine we tested applied it to an SVG shown through <img>. The motionless twin goes in a <picture> source instead.<text> drawn over the art, so they stay sharp at any size. Screen readers read the alt, so describe the scene there.
Pixelbolt compiles a fluid source into an ordinary recipe of palette pixels. Gases use stable fluids (advection, a pressure solve, vorticity confinement, buoyancy from temperature); liquids are FLIP particles, four per cell, on the same grid. "Look" layers then map fields such as density, speed, depth, light and shadow onto palette colours with ordered dither, so no colour outside the palette can appear, and looping effects blend their last frames into the first.
The header's steam is a 128x60 gas grid over 96 frames, fed by eight billows a loop at uneven intervals. One billow and the look, trimmed from the real source:
{
"format": "pixelforge-fluid",
"width": 128, "height": 60,
"effects": { "steam": {
"frames": 96, "duration": 70, "loop": true, "seed": 5,
"buoyancy": 0.2, "vorticity": 0.35, "dissipation": 0.04, "drag": 0.09,
"emitters": [
{ "name": "billow-1", "at": [86, 53], "radius": 3, "rate": 1, "start": 0, "end": 3,
"density": 3.6, "temperature": 0.9, "velocity": [-0.3, -1.4], "expand": 2.0, "vortex": 0.3 }
],
"look": [
{ "field": "density", "colors": ["u", "v"], "thresholds": [0.16, 0.34], "dither": 0.85 },
{ "field": "shadow", "colors": ["v", "t"], "thresholds": [0.25, 0.55], "dither": 0 },
{ "field": "light", "colors": ["x", "F", "G"], "thresholds": [0.22, 0.5, 0.82], "dither": 0 }
]
} }
}
(pixelforge-fluid is the format's name from before the tool was renamed Pixelbolt.) The boiling liquid under the steam is a separate 44x50 FLIP simulation. Every piece shares one palette of 70 colours in 12 ramps; the header loop uses 44 of them.
The agents didn't get there first time. Three things they found:
"wind": [0, -0.08], removed the downward tails without changing the look.
The recipes live in the profile repo's art/ folder. Small Python generators write the JSON recipes and fluid sources, Pixelbolt runs the simulations and draws every pixel, and a short wrapper script packs the sheets into the SVGs:
bash art/build.sh
It fetches Pixelbolt at the pinned commit (8c5f662), needs Node 20+ and Python 3.11+ with Pillow, and takes about 45 seconds. From a clean copy it rebuilt all 40 SVGs byte for byte. That works because Pixelbolt's simulation is deterministic by design: a fixed timestep, fixed iteration counts, seeded noise, and only arithmetic, square roots and polynomial trigonometry, so a source gives the same recipe on every machine and in the browser. (The one caveat in the notes: the Python generators use Python's math module, so another platform's maths library could in principle move a pixel.)
The portfolio at skulitom.github.io uses the same art, played on a canvas. Under the workbench there are three buttons. "Add heat" and "Stir the flask" don't switch to a pre-made clip: Pixelbolt's fluid compiler runs in a Web Worker in the page, re-simulates the steam or the liquid with your change, and plays the new loop.
On the live site, on a desktop Ryzen 9 7950X3D, "Add heat" took 932 ms in Chrome, 795 ms in Firefox and 793 ms in WebKit. (The recording above, of the same page just before it went live, shows 957 ms.) To get under a second, the live version simulates a 48-frame loop with 12 pressure iterations; the pre-rendered art uses longer settings. One limit showed up: an emitter's start/end window can't wrap around the loop, so a click near the end of the loop is moved to frame 0.
Pixelbolt has no npm dependencies. From a clone:
node bin/pixelbolt.js compile examples/steam-vent.fluid.json --out output/steam.json
node bin/pixelbolt.js gif output/steam.json --scale 4 --out output/steam.gif
The repo has ten fluid presets (campfire, torch, steam, magic, explosion, smoke puff, waterfall, drip, potion, splash), and its fluid guide covers emitters, looks and loops. Limits worth knowing: a fluid canvas is at most 128x128, a frame at most 256x256, gravity and obstacles are static, and emitters move only on circles and ellipses (or by chaining time windows along a path). It runs on the CPU, with no GPU and no model calls.
To see how the wrapper is built, read art/tools/wrap.py in the profile repo: it turns a Pixelbolt sheet and atlas into a self-contained animated SVG and its still twin.
art/