Key takeaways
- Apart from its icons, Pockling loads no image or sound files, painting its art and synthesising its sound.
- Its 30 JavaScript modules weigh 142.8 KB gzipped, and the eight painter modules are 35.4 KB of that.
- Painting one pet’s body takes about 5,700 canvas fill() calls, with every tuft placed by the pet’s saved seed.
- Eyes and mouths are shaders, so the face stays sharp at any size and blends between 28 expressions.
- Talk-back replays your voice 1.5 times as fast, which also makes it seven semitones higher.
Everything you see in Pockling, our free virtual pet game for iPhone and iPad, is procedural art in JavaScript. Each pet, coat, outfit, food, room and park scene is painted on a canvas with the Canvas 2D API the first time the game needs it, then uploaded to WebGL 2 as a texture. The eyes and mouth are shaders, and every sound is synthesised with Web Audio. Apart from its icons, the game loads no image or sound files. As served on 6 November 2026, its 30 JavaScript modules weigh 142.8 KB gzipped, and the eight painter modules in its bake folder are 35.4 KB of that.
The App Store app, Pockling: Cute Virtual Pet, is the web game inside a small native layer, so you can try the same game free in your browser and read its source, starting from app.js. This post follows that code from the painted canvases to the speaker, and ends with a checklist for drawing a game’s art in code.

Baking procedural art in JavaScript into WebGL 2 textures
gl.js holds the whole renderer. It’s a WebGL 2 sprite batcher with a 2D transform stack, grid meshes that can bend and two small shaders for the face. A browser without WebGL 2 gets a message saying the game needs it.
Every picture goes through one texture store. The game registers a painter for each key, such as pet.body or food.apple.0, and paints nothing until a scene asks for that key. Then the store runs the painter, which returns a freshly painted canvas, and uploads it with texImage2D, builds mipmaps for most keys and keeps the texture. In our test the room opened with 115 painters registered and drew its first noon frame from 24 textures, 4.4 million pixels in all. The pet’s body alone is 1,200 × 1,200 pixels, because pets are painted at 4 pixels per rig unit when the device pixel ratio is 2 or more, and at 3 below that.
// Register painters by key; paint and upload each one the first time it's drawn.
class PaintedTextures {
constructor(gl) { this.gl = gl; this.painters = new Map(); this.made = new Map(); }
define(key, paint) { this.painters.set(key, paint); this.free(key); }
get(key) {
if (!this.made.has(key)) this.made.set(key, upload(this.gl, this.painters.get(key)()));
return this.made.get(key);
}
free(key) { this.gl.deleteTexture(this.made.get(key) ?? null); this.made.delete(key); }
contextRestored() { this.made.clear(); } // the old textures died with the old context
}
Because the painter is the source of truth, a resize or a lost GPU context only costs a repaint. Rooms are painted to fit the screen, so a resize redefines their six keys. On webglcontextlost the renderer calls preventDefault(), which the WebGL specification asks for before it will deliver webglcontextrestored, and on restore it rebuilds its shaders and empties the store. The HTML menus over the canvas reuse the same painters, turning outfits and foods into PNG data URLs for their buttons.
Each vertex in the sprite batch carries a premultiplied colour and an additive flag, and the fragment shader multiplies alpha by one minus the flag. Under the ONE, ONE_MINUS_SRC_ALPHA blend, a sprite with zero alpha adds its colour, as Shawn Hargreaves explained in his 2009 post on premultiplied alpha. So glows, sparkles and ordinary sprites go through one blend state and one batcher. A resting frame in the room is 27 draw calls and 1,264 triangles, 1,168 of them in the body and belly meshes.
| Asset family | How it’s painted | Textures | Variety |
|---|---|---|---|
| Pet parts | Fur tufts over a shaded ball, with ears and tails as straight strips | 10 to 12 a pet | 6 kinds with 4 coats each, and one of 100,000 fur seeds per pet |
| Eyes and mouth | Two shaders, every frame | None | 28 named expressions and anything between them |
| Outfits | Paths and gradients | 15 | 9 for the head, 2 for the face, 4 for the neck |
| Food | Paths and gradients, with bites erased | 36 | 12 foods in 3 states each |
| Room | Painted to the screen’s size | 6 a room | 6 colour schemes |
| Sky | Gradient strips, soft dots and star fields | 11 | 4 skies blended by the hour |
| Park | Tiles 1,024 units wide that wrap | 9 | 5 scrolling layers and 4 flower clumps |
| Eggs | Patterned shells, halves clipped along one zigzag | 22 | 6 patterns and 3 crack stages |
| Bath and hiding spots | Painted to the screen’s scale, plus two emoji | 11 | Tub, water, duck, towel, soap, shower head and 4 hiding places |
| Effects and props | Small shapes and gradients, with emoji in the thought bubbles | 32 | Hearts, sparkles, bubbles, foam, fog, berries and more |
A Pockling is 10 to 12 painted parts
bake/pet.js paints each pet as separate textures, a body, a belly, ears, a tail, paws, a paw showing its toe beans for when one is raised, feet, a nose, cheeks and brows. Kitlings add whiskers, Draklings add wings, and coats with a marking add one more. Ears come in six kinds, from a Kitling’s triangle to a Drakling’s furless horns, and tails in five. Both are painted as straight strips with the root at the top, so the renderer can bend them every frame.
A coat is a few lines of data in catalog.js, four fur colours from deepest shadow to brightest light plus colours for the belly, inner ears, nose, cheeks and eyes. Each pet also gets a seed from its hatching time, one of 100,000, saved with it. Hashed with its kind and coat, the seed drives the generator that places every tuft. The fur repaints the same way each time you open the game, and two Peach Kitlings hatched at different times will almost always have different clumps. Growing up reuses the same textures, drawn at 0.78 scale for a baby, 0.92 for a kid and 1.05 for a grown pet.
Painting fur one tuft at a time
The fur comes from plush() in bake/paint.js, which its comment says paints a shape “the same way an illustrator would paint a soft toy”. First it shades the outline like a ball. Each pixel, at half resolution, gets a normal as if the shape were a sphere and wrapped lighting from the upper left, so shadows stay soft. A little bounce light goes under the belly, and the colour comes from the coat’s ramp.
Then come the tufts, tapered blades of two quadratic curves that follow a flow field running out from a crown on the head. Edge tufts go down first and break the outline. The coat tufts are sorted from the bottom up, so higher fur lies over lower fur like roof shingles. Each gets a dark copy offset below it, then the clump, and about one in five a bright strand. Fur near the face is shorter and calmer.
// A tuft: a blade that tapers from a wide base to a bent tip.
function tuft(ctx, x, y, [dx, dy], len, width, bend) {
const [nx, ny] = [-dy, dx]; // across the blade
const tip = [x + dx * len + nx * bend, y + dy * len + ny * bend];
const mid = [x + dx * len * 0.5, y + dy * len * 0.5];
ctx.beginPath();
ctx.moveTo(x + nx * width, y + ny * width);
ctx.quadraticCurveTo(mid[0] + nx * width, mid[1] + ny * width, tip[0], tip[1]);
ctx.quadraticCurveTo(mid[0] - nx * width, mid[1] - ny * width, x - nx * width, y - ny * width);
ctx.fill();
}
// Lowest first, each with a shadow, so every row of fur overlaps the one below.
for (const t of tufts.sort((a, b) => b.y - a.y)) {
ctx.fillStyle = shade(t.x, t.y + 2, -0.13);
tuft(ctx, t.x + t.w * 0.4, t.y + t.w * 0.4, flow(t), t.len, t.w * 1.1, t.bend);
ctx.fillStyle = shade(t.x, t.y, jitter());
tuft(ctx, t.x, t.y, flow(t), t.len, t.w, t.bend);
}
One body takes about 5,700 fill() calls, every one a tuft or the shadow under one, and the count doesn’t change with resolution because tuft sizes are set in rig units. The belly, ears, tail, paws, feet and markings go through the same function, and a whole Peach Kitling takes more than 11,000.
Eyes and mouths are shaders, so the face stays sharp
The eyes and mouth have no textures. Two fragment shaders in gl.js draw them every frame from a handful of numbers, as signed distance functions. An eye is a glossy oval between two lids that meet on a curved line, so one shape gives wide-open eyes, a blink, happy arcs and sleepy ones. Tilting the lids makes it sad or cross. Hearts, stars, spirals and squeezed-shut crosses fade in for big reactions, and the heart is the exact one from Inigo Quilez’s 2D distance functions. The mouth is a band between two curves that morphs between a small smile, a cat’s w, an open laugh, an o and a wobble, and talking moves the curves apart.
// How much of this pixel the shape covers, softened over about 1.5 device pixels.
float cover(float d, float px) { return clamp(0.5 - d / (1.5 * px), 0.0, 1.0); }
// One eye for every mood: a disc cut by two lids that meet on a curved line.
float eye(vec2 p, float open, float smile, float px) {
float meet = 0.2 * smile - 0.6 * smile * p.x * p.x; // smile > 0 gives ^ ^, < 0 gives u u
float upper = mix(meet, 1.2, open);
float lower = mix(meet, -1.2, open);
return cover(max(length(p) - 1.0, max(p.y - upper, lower - p.y)), px);
}
The shader is told the size of one device pixel in eye units, so edges stay 1.5 pixels soft at any size. When the pet presses its nose to the glass it’s drawn up to 3.4 times its usual size, and the fur textures are magnified while the eyes and mouth are computed afresh.
Squash, stretch, blinks and breathing come from springs
The rig in pet.js is 45 damped springs, for squash and stretch, lean, head turn, lids, mouth, ears, tail, paws and more, stepped in slices of at most 1/120 of a second. An expression is a set of targets for 21 face values, so any of the 28 named expressions can follow any other. That’s 756 transitions, and none is animated by hand.
A squash pushes the height spring down and the width spring up by 0.7 times as much. With a stiffness of 240 and damping of 11, the body springs back about 30% past rest before it settles. A hop is a crouch, a stretch, a parabola and a squash on landing.
// One springy number. Faces, squashes and hops only ever change targets.
class Spring {
constructor(value, stiffness, damping) {
Object.assign(this, { x: value, target: value, v: 0, k: stiffness, c: damping });
}
step(dt) {
this.v = (this.v + (this.target - this.x) * this.k * dt) * Math.exp(-this.c * dt);
this.x += this.v * dt;
}
}
const width = new Spring(1, 240, 11);
const height = new Spring(1, 240, 11);
const squash = (amount) => { height.x -= amount; width.x += amount * 0.7; };
The body is drawn as a 22 × 22 grid of 968 triangles, and every grid point goes through one deform function. It adds the squash, wider at the feet than at the head, then breathing, a lean, a jiggle, a dent under your finger and a stretch when you pull. Bath foam and mud are placed with the same function, so they ride the squash. When the head turns, the face slides over a sphere 104 units in radius.
Blinks take 0.15 seconds, come every 1.6 to 5.2 seconds and are doubled 18% of the time. The pet breathes every 2.7 seconds awake and every 4.6 asleep. Longer actions, like a sneeze, are generator functions in actor.js that yield the seconds to wait.
Rooms, food and a park that never ends
bake/room.js paints the room to fit the screen. The window is erased from the wall with destination-out, so the sky drawn behind it shows through. The sky is four gradient strips, for day, dawn, dusk and night, blended by your local hour, and the room is tinted by the hour’s light while the pet gets only half the dark. The blanket is its own texture so it can go over the pet at bedtime, and the fairy-light painter returns its bulb positions so each one can glow after dark.
The park in bake/park.js is five layers, each a tile 1,024 units wide that repeats sideways. Hill and path outlines are sums of sine waves with whole numbers of cycles per tile, so they meet at the join, and anything crossing the edge is drawn twice. Everything wraps, so the walk never ends, and the layers scroll at 0.14, 0.3, 0.58, 1 and 1.5 times the ground’s speed. The lead is a 12-point Verlet rope drawn as a bent strip.
Each of the 12 foods in bake/food.js is painted whole, with a bite gone and nearly finished, and the bites are circles erased with destination-out. Eggs come whole and as two halves clipped along one zigzag, so the top can fly off on the sixth tap. The thought bubbles and the bath’s soap and shower head are emoji, drawn into canvases with the system’s emoji font.
Every sound is synthesised with Web Audio
audio.js opens by saying every sound is synthesised, with no recordings and nothing to download. This section describes the file as the site served it on 6 November 2026, last deployed the day before. The app’s version 1.1, out on 6 November, lists new sounds such as a coo while you stroke it and a “wheee!” when you swing it round, neither of which is in the served file. It also says the purr now comes through phone speakers more clearly.
The audio context starts on the first touch. Where the browser has the Audio Session API, the game sets its type to ambient, which the draft defines as audio that mixes with other audio. Everything goes through a compressor, and two seconds of white noise, made once with Math.random(), feed the chomps, splashes, shower and purr.
The voice is a triangle wave plus a quieter sine an octave up, through a band-pass “vowel” filter whose centre rises during the note, with a pitch contour and sometimes a 7 Hz vibrato. Each kind has its own pitch, raised for babies and kids, from about 590 Hz for a grown Cubling to 1,217 Hz for a baby Bunling. Babble strings together syllables of 70 to 140 ms through vowel filters between 800 and 2,400 Hz.
The purr is noise low-passed at 340 Hz, pulsing 24 times a second, with a slow 0.7 Hz swell and a 48 Hz hum. Herbst and colleagues got excised cat larynges to produce purr-like sounds at fundamental frequencies of 25 to 30 Hz.1 In the app, the same strokes switch on a continuous haptic purr.
// A purr: low-passed noise that pulses 24 times a second and swells slowly, like breathing.
function startPurr(ctx, noise) {
const src = new AudioBufferSourceNode(ctx, { buffer: noise, loop: true });
const low = new BiquadFilterNode(ctx, { type: 'lowpass', frequency: 340, Q: 2 });
const level = new GainNode(ctx, { gain: 0.55 });
const pulse = new OscillatorNode(ctx, { frequency: 24 });
const breath = new OscillatorNode(ctx, { frequency: 0.7 });
pulse.connect(new GainNode(ctx, { gain: 0.45 })).connect(level.gain);
breath.connect(new GainNode(ctx, { gain: 0.14 })).connect(level.gain);
src.connect(low).connect(level).connect(ctx.destination);
[src, pulse, breath].forEach((n) => n.start());
return () => [src, pulse, breath].forEach((n) => n.stop());
}
The music box wanders one or two steps at a time along a pentatonic scale over a two-note chord, in G major by day and C major at night. Its comment notes that it can’t play a wrong note. Each bell is a sine with a quieter partial at 3.01 times its pitch, and a feedback delay gives the box a little room.
Talk-back in the browser and through the app
Talk-back runs the same states everywhere, listening, hearing and speaking, with a loudness from 0 to 1 that opens the mouth. Only the recorder changes.
In a browser, talk.js opens the microphone with getUserMedia, asking for echo cancellation, noise suppression and automatic gain. It reads the sound through a ScriptProcessorNode in 2,048-sample buffers, an interface MDN marks as deprecated and replaced by AudioWorklet.2 Speech starts when the level passes 3.2 times a moving noise floor, and the five buffers before it, about a fifth of a second, are kept so the first syllable survives. A take ends after 0.65 seconds of quiet or 7 seconds of talking, and anything under 0.28 seconds is dropped. The game trims most of the trailing silence, raises the peak to 0.9 with at most six times the gain, and plays the take 1.5 times as fast, which also lifts it seven semitones. An AnalyserNode turns the playback’s loudness into mouth movement each frame.
In the app, platform.js finds window.webkit.messageHandlers.pockling and sends talkStart and talkStop instead. The native layer records, pitches up and plays back, and its talk:state and talk:level events arrive through window.__pockling.emit at the same callbacks. Either way, the recording is held in memory only until it has played, and nothing you say leaves your device.
| Job | In a browser | In the app |
|---|---|---|
| Talk-back recording | getUserMedia and a ScriptProcessorNode |
The native layer records and pitches up |
| Moving the lips | AnalyserNode loudness |
talk:level events |
| A purr you can feel | Nothing | A continuous haptic purr |
| Other haptics | navigator.vibrate, where the browser has it |
A message naming the kind of tap |
| Saving | localStorage |
The app’s own copy, written into the page before the game starts |
| Photo cards | Web Share with files, or a download | The PNG is handed to the app to share |
| Evening reminder | Not offered | Offered, and off until you turn it on |
| Tilt | deviceorientation, where no permission prompt is needed |
Motion events from the app |
How we measured
Sizes are what the site served on 6 November 2026, fetched with curl with and without Accept-Encoding: gzip. The server sends gzip and no Brotli, and KB here means 1,000 bytes.
| What | Files | Raw | Gzipped |
|---|---|---|---|
| The page, its stylesheet and its modules | 32 | 426.6 KB | 150.2 KB |
| All JavaScript modules | 30 | 402.6 KB | 142.8 KB |
| The painter modules in src/bake | 8 | 105.8 KB | 35.4 KB |
| The renderer, gl.js | 1 | 24.6 KB | 9.1 KB |
| audio.js, talk.js and platform.js | 3 | 31.0 KB | 10.7 KB |
| The favicon, a 512 × 512 PNG | 1 | 271.2 KB | 271.2 KB (not compressed) |
The runtime counts come from the public game’s demo mode, which doesn’t write a save. We ran it in a WebKit browser on a Mac at a 390 × 844 viewport and a device pixel ratio of 2, with a Peach Kitling at noon. We counted the store’s painters and baked textures, read each canvas’s size, and counted draw calls and triangles for one resting frame. We counted fill() calls while running the served painters for each kind’s first coat. Other numbers are constants in the source, and the spring overshoot, breathing periods and voice pitches are worked out from them. We didn’t time the painting, measure frame rates or memory, test on an iPhone or read the files inside the App Store build.
A checklist for drawing a game’s art in code
- Register painters by key and paint each one on first use, so start-up pays only for the first scene.
- Treat the painter as the source of truth, so a resize or a lost GPU context only means painting again.
- Seed every random choice, and save the seed with the thing it draws.
- Paint at a set number of pixels per unit, picked from the device pixel ratio.
- Paint parts that bend, like ears, tails and leads, as straight strips, and bend them with meshes.
- Draw what must stay sharp, like eyes and mouths, with distance functions in a shader.
- Put every animated number on a spring, and make expressions sets of targets.
- Premultiply alpha, so additive glows need no blend state of their own.
- Build repeating backgrounds from whole-number sine waves, and draw anything on the join twice.
- Keep platform differences behind one module that calls the same callbacks everywhere.
- Weigh the files you still serve. Pockling’s 271.2 KB favicon outweighs all of its code gzipped.
- Time each scene’s first frame on the slowest phone you support, because that’s when its art is painted.
For how the game plays, see Pockling’s launch post, and for every kind, coat and food the painters draw, the guide to every Pockling.
-
Christian T. Herbst and colleagues, “Domestic cat larynges can produce purring frequencies without neural input”, Current Biology 33(22), 4727-4732, 2023, https://doi.org/10.1016/j.cub.2023.09.014 ↩
-
MDN Web Docs, “ScriptProcessorNode”, https://developer.mozilla.org/en-US/docs/Web/API/ScriptProcessorNode ↩
Frequently asked questions
How do you make procedural art in JavaScript?
Paint it at run time with the Canvas 2D API, from paths, gradients and per-pixel shading, and seed every random choice so it repaints the same way. Pockling paints each picture the first time it’s needed, uploads it to WebGL 2 as a texture and animates it there.
Can you make a game without any image files?
Yes. Apart from its icons, Pockling loads no image or sound files. Its pets, food, rooms and park are painted in JavaScript, its eyes and mouths are shaders, and every sound is synthesised with Web Audio.
How do you draw fur on an HTML canvas?
Shade the shape like a ball, then cover it with thousands of small tapered tufts that follow a flow direction, painted from the bottom up so higher tufts overlap lower ones. One Pockling body takes about 5,700 fill() calls.
How do you do squash and stretch in code?
Drive the width and height scales with damped springs. To squash, push the height down and the width up by about 0.7 times as much, then let the springs pull back. Pockling’s body springs, with a stiffness of 240 and damping of 11, overshoot rest by about 30% before settling.
How do you make a voice squeaky in the browser?
Record it with getUserMedia, then play the buffer back through an AudioBufferSourceNode with a playbackRate of 1.5, which makes it 1.5 times as fast and seven semitones higher. Pockling’s talk-back does this in the browser, and its app records natively.
Why draw eyes with a shader instead of a texture?
A distance-function shader computes the eye fresh at every pixel, so it stays sharp at any size and can blend smoothly between expressions. Pockling’s eye shader covers open, blinking, happy and sleepy eyes, plus hearts and stars.
Building something like this?
9io is a small team of senior engineers with a fractional CTO. We work by the hour, and plans start at $5,000. Send us a note about your product. The reply comes from the person who'd do the work.