---
title: "The Pet We Drew Three Times"
description: "How Foxl's desktop pet got its art: why 30 hand-drawn 16x16 sprites read as speckle, and why cutting one spritesheet needed palette matching, not geometry."
author: "Foxl Team"
date_published: "2026-08-09"
date_modified: "2026-08-09"
canonical_url: "https://foxl.ai/blog/pet-plush-companions"
markdown_url: "https://foxl.ai/blog/pet-plush-companions/index.md"
image: "https://foxl.ai/blog/pet-plush-cover.png"
social_image: "https://foxl.ai/blog/social/pet-plush-companions.png"
content_type: "Deep dive"
topics: ["Design","Pixel art","Asset pipeline","Measurement"]
products: ["Foxl Agent"]
---

<!-- Generated by scripts/blog-publishing.mjs; do not edit. -->

# The Pet We Drew Three Times

> Foxl has a companion that walks around your screen, and its art was rebuilt three times before it read as a toy. Thirty hand-drawn 16x16 sprites painted 118 of 256 cells and came out as speckle. A generated version fixed the family and shipped a cast that was visibly wearing sunglasses. Now it is 185 drawn frames - and getting them off the artist's spritesheet took seven failed strategies, every one of which produced perfectly plausible files holding the wrong animal.

- Author: Foxl Team
- Published: 2026-08-09
- Last reviewed: 2026-08-09
- Reading time: 12 minutes
- Canonical HTML: [https://foxl.ai/blog/pet-plush-companions](<https://foxl.ai/blog/pet-plush-companions>)

![Twelve of the fifteen Foxl companions on shelves, each drawn as pixel art at full resolution: a patchwork ragdoll, a top-hat bear, a tabby, a grey cat, a hamster, a sparrow, a midnight bunny, a frog, a calico, a cream bunny, a piglet and a hedgehog.](<https://foxl.ai/blog/pet-plush-cover.png>)

Foxl has a pet. Turn it on in Settings and a small companion wanders across whatever screen you are working on, walks with an animated step, turns to face the way it is going, and stays where you put it down. On the desktop app it can leave the window entirely and potter about on your actual desktop, across as many monitors as you have.

This is a post about its art, because the art was rebuilt three times and only the third version is any good. The interesting part is not the drawings. It is that every time we got it wrong, nothing failed: the types checked, the files existed, the counts were right, and the thing on screen was still wrong in a way only a person looking at it could see.

## Thirty animals, drawn one at a time

The first cast was thirty hand-drawn sprites, each a 16x16 grid of characters in a TypeScript file with a palette beside it. One row of one sprite looks like this:

```
'.dllwelllewlllld',
'.dlllllnlllllldm',
'.dpllllllllpldmm',
```

Measured across all thirty before we deleted them: an average of **118 of 256 cells painted, from 6.7 distinct colours**. At that density a 16-pixel-tall animal has no flat area left anywhere on it. Stitches, odd eyes, a bow, a bell, a patchwork seam and a tail all landed within two pixels of each other, so the result read as speckle rather than as a toy.

The second problem was worse than the first. Nothing was shared between those thirty grids, so they drifted. One cat had its eye glint inside the eye box and the next one had it on the cheek. There was no family, just thirty separate opinions about where an eye goes.

## Generating the cast from one shape language

So the second version generated every companion instead of drawing it. A character became a species template plus a palette plus a few flags, and one function rendered it. The template is readable as a picture, which is the point:

```
'DFLLFFFFRRFD',   // a 2x2 eye either side
'DFFFFNNFFFFD',   // the muzzle
'.BBDDYYDDBB.',   // bow wings, knot, bell at the centre
'.DFFPPPPFFDT',   // body, patchwork panel, tail
```

Every cat now has its eyes on the same two cells. Every bow is the same band. A new character is a few colours and is already on-model, and **a character cannot drift from the family, because there is no per-character grid to drift in**. Eight hundred lines of grids became a spec table.

The grid size took four attempts, and every one of them was rejected by rendering it and looking, not by argument:

- **8x8.** The dolls read as square blocks. With ears on the top two rows and paws on the bottom there is no row left for a waist, so head and body were one silhouette of the same width.
- **10x10.** Better, and it shipped for a while. But at ten cells wide an eye can only be two cells by one row, and **two flat bars on a face read as sunglasses**. The entire cast was visibly wearing them in Settings.
- **14x14.** Went backwards on the number that started all of this: 152.9 painted cells against the old cast's 118.2, so 29% more pixels, and the bodies filled their own grid until the silhouettes went blunt.
- **12x12.** A 2x2 eye with a catchlight, three-row ears, a knotted bow, two separate paws, and transparent margin around the doll - at fewer painted cells than the art it replaced.

None of those four failures is visible in the source. The strings all parse, every character has an entry, and the counts come out however you like. The only check that could reject "the whole cast is wearing sunglasses" was rendering the cast and looking at it.

## Then we got real drawings, and the grid had to go

The generated family was a good system and it is still what the office critters use. But a 12x12 grid cannot hold what an illustrator draws: fur, a stitched seam, a bow with a highlight on it, an expression. So the fifteen main companions are now imported from the artist's spritesheets at native resolution, as lossless webp - 185 frames, about 2.9 MB, roughly 13 KB each.

![The patchwork ragdoll in three versions: a hand-drawn 16x16 grid painting 118 of 256 cells, a generated 12x12 template built from one shape language, and the shipped drawn art at 112 by 132 pixels with twelve frames](<https://foxl.ai/blog/pet-three-generations.png>)

_**Figure 1.** One character at each stage. Stage 1 painted 46% of its grid, which is why the marks collided. Stage 2 made the cast consistent by construction. Stage 3 is what ships._

Switching from a 12-cell grid to real images broke three things silently, and the pattern in all three is the same: a literal that used to be correct.

Two renderers divided a size by a hardcoded `16`, left over from the original grid. At 12x12 that draws the doll at a fraction of its scale in the top-left of its own box. No crash, no type error, and on screen it reads as a layout bug rather than as art that moved. The walk cycle had a leg band hardcoded to three rows, which was right at 16 rows and catastrophic at 12, where those rows are the bow and the torso: it sheared the toy in half on every other frame.

## A walk you cannot fake

The generated version had one pose per animal, so its walk had to be inferred from that single picture by shifting pixels. Two attempts at making that look like walking were rejected by measurement rather than taste: a sideways body lean clipped **20 of 31** sprites by up to four cells, and a vertical bob clipped **all 31** by up to ten. These dolls are drawn out to their own border, so any shift pads with transparency and eats an ear, a tail or a shoulder.

What survived was moving the paws two cells, because the bottom row is the one row with a margin to move into.

![Top: a generated 12x12 doll in four frames where only the shaded bottom paw row changes. Bottom: four drawn walk frames of the ragdoll where the legs, tail and head all carry the step](<https://foxl.ai/blog/pet-walk-cycle.png>)

_**Figure 2.** A derived walk can only move pixels that have somewhere to go. A drawn walk moves the whole animal._

The probe that was supposed to catch clipping did not, at first, because it only asked whether a frame had *grown*. A frame that silently loses a tail passes that test perfectly. Once it asked about loss instead, it rejected both motion attempts immediately. The check was cheap; asking it the right question was the work.

## Seven ways to cut up a spritesheet, all of them wrong

The drawn poses arrive as one sheet holding eight poses for fifteen characters, and the job is to work out which crop belongs to which animal. This sounds like arithmetic. It is not: the characters are drawn closer together than one cell, and the spacing between them is not uniform, so anything anchored to a fraction of the sheet width is wrong however you nudge it.

Seven strategies failed, each in its own way:

- Detecting one band per character found 9 to 14 bands where there are 15, because neighbours merge.
- An even fifteen-way split gave every crop a stripe of its neighbours.
- Nearest-centre fill picked the neighbour: a cell's centre column can land between a standing animal's ears, outside its own character.
- Per-row centroids drifted, because a raised arm makes a pose densest away from its own centre.
- A grid nudged to the local density minimum split one character across two cells, and drifted differently on each row.
- Dynamic programming over density looked right on three poses and scored 1 out of 15 on a structural check - which is what sent us back to re-measure and find that the sheet genuinely has 75 empty columns.
- Run-splitting by width was the closest and still wrong, because a run's width does not tell you how many characters are in it. A sleeping animal is half the width of a cheering one.

**Every single wrong answer produced plausible files.** The counts were perfect, every file parsed, the compiler was happy - and the `tabby_cat` pose row contained the black bunny.

What worked was giving up on geometry. Each candidate crop's colour palette is compared against that character's already-verified walk frames, and the whole sheet is solved as a one-to-one assignment. A global assignment cannot hand two cells the same character, which is exactly the failure every geometric method arrived at from the other direction.

It also refuses work it is not sure about. A crop whose palette match is too weak, or which holds less than half its character's median number of opaque pixels, is discarded rather than shipped - because where a cell boundary cuts an animal, the matcher will happily assign the sliver to whatever it best resembles and write a correctly named file containing an ear.

![The grey cat in all eight poses - stand, sit, sleep, wave, eat, cry, cheer, idle - above the sparrow in the same eight columns, where four cells read none because the importer discarded those crops](<https://foxl.ai/blog/pet-pose-grid.png>)

_**Figure 3.** Why the cast has between 10 and 14 frames each rather than a uniform set. A missing pose falls back to standing and nobody notices; a floating body part would be the first thing anyone saw._

## The tool with a human in it

Palette matching gets most of the sheet right and not all of it, so there is a small local tool for fixing the rest by hand: a grid of every character against every pose, where you drag a frame onto the cell it belongs to, re-cut a crop from the source sheet, or drop a frame entirely. It exports a patch that the importer replays.

Its first version recorded pairwise swaps and replayed them in order, which is wrong in a way that takes a while to notice: the second drag operates on the first one's result, so moving A to B and then B to C rotates three cells and changes one you never touched. The patch is an absolute map now - each slot names its source - and the importer rejects any patch containing swaps. A test pins the property that a chained second drag still changes exactly two cells.

The tool also never writes to the repository. The importer stays the only writer and the patch replays on top of it, so a re-import cannot silently inherit hand-edited files that nobody can reproduce.

## A gallery instead of a dropdown

Picking a companion used to be a dropdown of nine translated names. For characters whose entire point is how they look, that asks the wrong question - and if your stored companion was not one of the nine, the control rendered full-size and showed *nothing*, so the pane said nothing was chosen while a pet was visibly walking around the screen.

It is a gallery now. Each card shows the character's original illustration large enough to read, with its pixel sprite beside it **at exactly the size it will be on screen**, so the picker answers both questions a picker has to: what is it, and what will I get. The true-size copy tracks the Size row, which means that row has a live preview instead of three words.

There is a Draw button that costs nothing, has no cooldown and no daily cap. That is deliberate: Settings is a preferences surface, and a preference cannot sit behind earned currency. It prefers a character you have not met, because with a flat roll the last one takes about twenty-five presses and the button starts reading as broken.

And the pet has a menu: right-click on a desktop, press and hold on a phone. Pet it, call it over, tell it to stay, draw a new friend, put it away. It is not the off-the-shelf context menu component, for two structural reasons. That component's trigger runs its own long-press timer on the same pointer phase as the drag's pointer capture, so the two gestures would have to share one state machine. And the pet is portaled to the document body and moves every frame, so an anchored menu would chase it across the screen. This one anchors to the pointer, freezes the animal while it is open - otherwise the items act on a pet that has walked away - and closes on scroll and resize as well as outside clicks, because a fixed menu at a stale coordinate leaves the pet unreachable behind an invisible layer.

## What we would tell ourselves at the start

Every real defect in this work was invisible to every automated check we had. The sunglasses cast, the doll drawn at 62% scale in the corner of its own box, the walk that sheared the toy in half, the pose row holding the wrong animal: types clean, files present, counts correct, tests green.

Two things caught them. Rendering the whole cast to an image and looking at it, which is the only check that could have rejected 8x8, the one-cell eye, the thin bow and both attempts at body motion. And asking probes about *loss* rather than about change, because a frame that quietly drops a tail passes any test that only asks whether something grew.

The pet is off by default, holds still when Reduce motion is on, and is a third opt-in to let it out onto your desktop. It is a small feature. We are fine with having spent this much on it: a companion that reads as a toy is doing its whole job, and one that reads as speckle is doing none of it.

## References and further reading

1. [Foxl changelog](<https://foxl.ai/changelog>) - Release
2. [Download Foxl](<https://foxl.ai/#download>) - Reference