← Dev Log
Building a character creator out of AI art in a day

Building a character creator out of AI art in a day

browser strategy gameai generated artcharacter creatorai game developmentclaude code

TL;DR: Inselnova has eight fixed avatars, hard-coded in a TypeScript file, and every player picks from the same eight faces. Island stewards are coming in the next version and there aren’t enough faces to go round, so I needed a character creator: build, hair, beard, hat, colours. An image model will draw you a beautiful portrait, and it will not draw you parts of one that fit together. So I stopped asking for parts. It now draws every part on top of a flat magenta mannequin head against a pure green background, and code cuts the art off the scaffolding by colour. Eight sheets, 48 parts, and two complete implementations in the bin before that worked.

Eight faces, and some of them have transparent eyes

One of the eight fixed portraits

The avatars went in at the very start of the game and I’ve never been back to them. An AI made them and they set the house style: bold warm-brown outlines, painterly cel shading, muted colours. They’ve sat there ever since. Look closely at a few and the eyes are transparent. Replacing them, adding more, or making them customisable has been on the list for months, and a few players asked for customisable ones back then too.

What moved it up the list is a dependency in the next version. Island stewards need faces, and there are 18 character images in the game of which only about 10 work for that. If you’ve got more than 10 islands, or you want the same steward running two of them, it falls apart. I also want people to identify with their team. Customising is fun, and it’s a hook: if you feel like you built the person, you’re attached to them.

So this became a side quest to the normal game development, and an experiment, because building a character system out of AI art isn’t something I’d done before. I can’t draw either, which is most of the problem in one sentence.

Layered SVG looked obviously correct and wasn’t

The starting point was the eight images I already had, converted to SVG. That was the plan because whatever I built has to replace avatars players are already using, and SVG looked like the way to make them properly customisable. Hair as one layer, eyes as another, recombine them, recolour with a CSS variable. Tiny files, no pipeline. I built the whole thing, a rig, part modules, presets for all eight characters, before I looked properly at the output.

The first run: reference on the left of each pair, my version on the right

They look like a three year old drew them. None of them are shippable, and the moustache-as-two-dots on the builder is doing something I still can’t explain. The composition side worked fine: 96 combinations, no drift, no collisions. The layered-parts idea was never in question. Only the drawing was.

So I stopped building the engine and started running experiments instead. Numbered test directories, one method each, one face to get right before generalising anything. Hand-authoring the bezier curves from imagination got me no closer, because there’s no feedback loop: you write coordinates, render, squint, adjust, and you’re still guessing. The one thing that test did add was a comparison harness, and the difference map in it is the panel worth having.

Reference, render, 50% overlay, difference map

Everything glowing white is somewhere the proportions are wrong, which is essentially the entire face. Auto-tracing the original portraits half-worked, which is worse than failing. Flat regions of cel-shaded art trace beautifully, and the ink shredded into chains of tiny disconnected polygons, so the line weight that makes the house style was gone. Tracing also produces the wrong architecture. Hair traced off one character’s head is welded to that skull and can’t be dropped onto anyone else, so even a perfect trace wouldn’t have given me a character creator.

Hand-authored beards in SVG

By the fourth test directory there was a real parametric generator: consistent faces, jaw variants, six beards, brow and mouth shapes. Next to the original portraits it read flat and cheap. What I wrote at the time was “even the first one doesn’t feel high quality. Swapping these with our png files just won’t cut it.”

The model kept proposing better SVG when the problem was SVG

I asked for a rethink twice and got variations on the same answer both times. Tweak the curves, adjust the ramps, try another variant sheet. So I switched models mid-conversation and asked for a fresh approach to the same test, in about those words. That stretch is where the project stopped trying to draw the avatars and started trying to cut them. The chroma-key idea, the research into how people actually prompt for sprite sheets, the decision to drop hand-authored vectors: all of it landed in there, and then I moved back to Opus 5 for the building.

I don’t think the second model was smarter. It hadn’t spent the previous two hours convincing itself that SVG was going to work. Everything the first one had learned about why SVG was hard was also a set of reasons to keep going, which is sunk cost with extra steps. When a model keeps handing you variations on a failed idea, suspect the context before you suspect the model. Switching models is the bluntest way to clear it, and a new session or an explicit “forget everything we tried” does the same job. Two hours of work went in the bin at that point and it was the best decision in the project.

A green screen and a magenta mannequin

The model is good at making the art and bad at making the art fit. So stop asking it for parts. Ask it for a sheet of parts drawn around a fixed shape, and cut them out mechanically.

The first version used a green background and a grey mannequin head for the hair to sit on. It nearly worked. The mannequin’s outline was the same dark brown as the hair’s outline, so nothing could reliably tell them apart, and my cutter kept slicing off jaw and ears. The fix was to make every non-artwork element a pure primary that appears nowhere in the art.

ColourHexRole
green#00FF00sheet background
magenta#FF00FFthe mannequin head, flat and unshaded
blue#0000FFthe mannequin’s outline

The mannequin the model draws around

Now the cut is arithmetic instead of a heuristic. Anything magenta or blue is scaffolding, everything else is art. Here’s a generated sheet, and what comes out the other end of the cutter.

A generated headwear sheet

Cut parts, alpha and all

That worked on the first serious try, and the gap in quality against the SVG line was not subtle. What was left to fix was mostly z-indexing, which parts sit over which, and that’s a layer order you set once rather than an art problem. The check that settled it was the matrix: every garment against every hairstyle, composited.

Six garments against five hairstyles

Thirty combinations from eleven parts and none of them broken. Parts generated on separate sheets, by separate prompts, dropping onto each other without argument.

Every line of the prompt is there because a sheet came back wrong without it

The prompt does most of the work in this whole pipeline. Here’s the hair sheet, trimmed. It didn’t start anywhere near this long. Each clause got added after a sheet came back broken, and once it’s in the template it stays in.

Create a wide landscape sprite sheet for a 2D game avatar system, in EXACTLY the art style of
the second attached image: same bold dark-brown outlines, same line weight, same painterly cel
shading with a lighter and a darker tone, same colours.

Layout: a 2x3 grid (2 rows, 3 columns), 6 cells. Generous empty margin around every subject,
nothing touches a cell edge, nothing overlaps between cells.

Every cell shows a blank mannequin head: filled with flat solid pure magenta #FF00FF, one single
flat colour, completely unshaded, no gradients, no highlights, no darker areas, no texture, with
no face at all, and outlined with a pure blue #0000FF line the same thickness as the outlines in
the style image.

On top of each magenta mannequin, draw one hairstyle in medium chestnut brown, fully rendered in
the normal art style with its own dark-brown outline and cel shading, overlapping the head
exactly as real hair sits on it: (1) short cropped, (2) chin-length bob, (3) ponytail, (4) long
shoulder-length, (5) short tousled with a fringe, (6) braided and pinned up.

Each hairstyle must completely cover the top of the head, no bare scalp showing anywhere between
the hairline and the crown.

The magenta head must be the same SHAPE as the head in the first attached image, same
proportions, same crown, same ears, same jaw, same neck. Do not redesign it. The magenta head's
ONLY outline is the pure blue #0000FF one, do NOT draw any brown or black line around the head,
the jaw or the neck. Every other dark outline in the cell belongs to the part itself.

How big it is drawn on the canvas does not matter. Match the shape, not the size.

Background: the ENTIRE background, around and between all cells, is one flat solid pure
chroma-key green #00FF00, no gradients, no vignette, no shadows.

Four of those clauses cost me a round each:

  • “Match the shape, not the size.” I spent a round asking it to fit inside a 267x382 mannequin. It drew 306x445 heads and ignored the instruction. Image models don’t obey absolute scale from a reference, so I stopped asking and started measuring one scale factor per sheet on import instead.
  • “The magenta head’s ONLY outline is the pure blue one.” On one beard sheet the model outlined the mannequin’s neck in brown. Brown is artwork by definition, so the cutter kept it, and every beard came out with a box drawn under the chin. No keying algorithm could have known.
  • “Each hairstyle must completely cover the top of the head.” Without it, about a third of them come back with a bald patch between the hairline and the crown.
  • “Absolutely NO clothing, no shirt, no collar.” On the heads sheet this needs saying three separate ways. The model’s default for a portrait is to dress it, and one polite mention does nothing.

Three things that broke, none of which threw an error

Four of the six mouths came out empty. I built the cutter around a binary mask, pixel is art or pixel is key colour, then a speck filter set to drop anything under 700 pixels. A mouth is a 3-pixel line with a total area of a few hundred pixels, so the filter tuned to remove keying junk was deleting the artwork.

Mouths, before and after

The top half is what a binary key does to a thin line: shredded, discontinuous, stained pink from the magenta behind it. Nearly all of a thin line is anti-aliasing, and a hard threshold keeps only the darkest core. The fix was proper chroma keying with fractional alpha, unmixing the key colour back out of each pixel by solving p = a·F + (1−a)·K for F. I’ve lifted that sentence straight out of what the model wrote for me and I couldn’t explain it to you if you paid me. I know what it does. The bottom half is the same mouths afterwards, continuous and with no pink in them, which is as far as my understanding goes and as far as it needs to.

Black hair had no outline. The colour system derives shade and highlight from a picked base colour using ramps fitted from the original art, with the outline pinned at lightness 0.20 and the deepest shade a fixed 0.130 below the base. For any base darker than about 0.35 the shade goes darker than the outline and the silhouette dissolves. Four of the ten shipped hair swatches already failed that, and pure black failed completely. The fix treats the ramp as a contrast budget placed inside available headroom rather than as fixed offsets, so dark colours read via their highlight and light colours via their shade, which is what the art does anyway.

Recolouring hair, beard and skin

The same hairstyle sat differently on every head. I was registering hair against each head’s own neck, and crown-to-neck runs 234px on the average head against 262px on the thin one. The heavy heads have no measurable neck at all, because the jaw runs straight into it. The rule I landed on is to register a part against the anatomy it attaches to, and only against the parts of that anatomy that are genuinely shared. Hair attaches to the cranium, which every head shares by design, so it registers against a reference head. Garments attach to shoulders, whose height differs per head but whose width doesn’t, so y comes from the head being drawn and x from the reference. Taking both from the current head slid every garment sideways by 28px.

One set of parts, six head shapes

None of those three threw an error. They all looked like art problems, which is why the pipeline now reports residue counts, drift percentages and scale factors on every import. If the residue count jumps on a sheet, I see it in the import output instead of finding it three weeks later in someone’s blonde hair.

From scripts to a studio

Finished avatars, every layer composited

Eight independently generated sheets, head, garment, eyes, nose, mouth, beard, hair and headwear, cut by colour key, normalised per sheet, composited. That’s 48 parts and roughly 6⁸ combinations before colour.

Up to that point it was a pile of Python scripts and a single-page alignment tool that saved by downloading a JSON file you then moved into a directory by hand. Fine for proving the method, miserable for living with. So the last phase was the actual tool: a build tab that rolls a grid of 24 random avatars, because a library this combinatorial will never show you the broken combination if you roll one at a time; an align tab where offsets compose as sheet, then part, then per-head; an items tab that previews every part on a real head with a palette strip.

The build tab, 24 random avatars

Then a create tab, which closes the loop: describe what you want, get a prompt, paste the generated sheet back, cut, review, commit.

The create tab

The prompt templates live in version-controlled markdown and the tool writes them back. Every sheet teaches you something, and if the tool only reads the prompts then those lessons end up in a chat log and get relearned six sheets later. The brown-outline rule, the wide-hood-opening rule and the two-tone shading demand all live in the template now.

Where this actually is

Twenty-four rolls from the current library

That’s the current library rolled at random, and it’s the answer to the eight faces I started with. Crowns, santa hats, helms, hoods, aprons, beards, every skin and hair colour in the palette, and no two of them the same.

It’s a standalone tool. It builds avatars, it doesn’t ship them, and the game still loads the same eight portraits it always has. Wiring the two together is next and the eight will be gone after that.

What the run actually looked like

I kept the transcripts, so none of this is a vibe. One long conversation in Claude Code, mostly Opus 5, with one deliberate stretch on a different model. It compacted four times along the way, which is its own kind of stress test, because the context is gone and what survives is whatever got written down.

Sitting one, Sunday evening20:37 to 23:23, 2.8 h
Sitting two, Monday07:48 to 19:32, 11.7 h
Elapsed, both14.5 h
Actual back-and-forth, idle gaps removed10.4 h
Things I asked for121
Tool calls the model made1,223
Times I interrupted it11
Context compactions4
Plan mode3
Implementations thrown away2

That’s about ten tool calls for every request I made. Almost none of this was “write me some code”, it was look, measure, adjust, measure again. A fair amount of it was questions rather than instructions, and those are not the same tool. “Why is the hair drifting?” gets you something you can act on. “Fix the hair drifting” gets you a patch you now have to evaluate.

ToolCalls
Bash47538.6%
Edit32426.3%
Read23619.2%
Write1159.3%
PowerShell615.0%
everything else191.6%

Writing new files was 9% of the work. The other 91% was running things, reading what came back, and changing one line. If you picture agentic coding as a machine that emits files, that ratio is the correction: it’s a machine that runs experiments, and the files are a side effect. What came out the end was 5,567 lines of TypeScript, 1,302 lines of Python, and 91 cut parts across 8 layers.

What it cost

I paid $200, which is the Claude Code Max subscription for the month, and not for this project. The project used some indeterminate slice of a bill I was already paying.

What it would have cost on the API is arithmetic, because the token counts are in the transcript:

Tokens
Fresh input3,714
Cache writes49.6 M
Cache reads734.4 M
Output2.9 M
  • At $5/MTok in and $25/MTok out, that’s about $750. At the older $15/$75 tier, about $2,250.
  • Output, meaning every line of code it wrote, is 10% of that bill. The other 90% is re-reading the conversation. You don’t pay for what the model writes, you pay for what it remembers.
  • By the end the transcript was 138 MB, and every turn re-reads it.
  • Those 734 M cache reads bill at a tenth of the input rate. Priced as fresh input the same run is roughly $3,990 instead of $750, so caching is what makes a session this long possible at all.

Then there’s what it would cost to have someone build it. The pipeline, the studio, the cutting and the colour maths is maybe a week for a competent mid-level developer, so call it 40 hours. US mid-level engineers earned somewhere between $132k and $170k in 2026 depending on the survey, so split it at $147k and that’s about $71/hour, or about $92 once benefits, payroll tax and overhead go on.

Cost
Mid-level dev, one week$2,840 salary, **$3,690 loaded**
This, subscription share of two days~$13
This, billed through the API instead$750 to $2,250
My own 10.4 hours, at that same $71~$740

Don’t read too much into the $13. A subscription is a bet the provider makes on average usage and I had an unusually heavy two days, so the API column is the truer measure of what the compute was worth. Even the pessimistic $2,250 comes in under a loaded developer week and arrived in an evening plus a day. The one line I won’t write is that it was free. My ten and a half hours are the largest real cost in that table once you price them at all, which puts the whole thing nearer $750 to $950 than $13. That’s still a third of the developer-week figure.

None of that means nobody needs developers, because nothing here replaced one. What it says is narrower. Nobody had to write a chroma keyer from scratch, or work out the unmixing formula I’ve already admitted I can’t explain, or remember that distance transforms are how you tell an outline from a dark material. That knowledge was available on demand and applying it was cheap. What stayed expensive was everything the model couldn’t do for me: noticing that the mouths looked wrong, deciding the SVG line was dead, knowing that “it looks terrible” is more useful feedback than a bug report. Two implementations went in the bin because I made a bad call, not because the model did.

A real developer week would have produced a better structured artefact without the dead ends. It would not have produced someone who understands why four of six mouths vanished, or why black hair loses its outline. The person who learns that is the person who watched it fail.

Three things I’d carry to the next one:

  • Let the model do what it’s good at. It makes good art and it cannot make that art fit a spec. Every hour spent trying to get precisely sized, precisely placed output was wasted. Every hour spent building machinery to measure and correct what it produced paid off.
  • Give it scaffolding to draw around. Ask an image model for “a hairstyle” and you get a shapeless blob. Ask for “a hairstyle on this head” and you get something that fits a head. Then you key the head out.
  • Write down what does not work. The most useful file in that repo is a list of twelve things that failed and why. It survived two complete rewrites, because every rule in it was paid for.

The agent wrote this post while it was building the thing

I didn’t write most of this from memory, because I didn’t have to. Once the mannequin idea started working I thought the whole thing would be worth sharing, so I told the agent to log it as we went. That instruction sits in the project’s START.md, the brief it reads at the start of every session, as one line about writing everything into the blog as it goes.

So it did. It kept the running write-up, saved the images into a blog folder beside it, and took its own screenshots of the studio whenever there was something worth showing. The build tab, the cut parts, the shredded mouths, all captured by the thing that had just built them. The cost arithmetic came the same way: I asked it to go back through the transcripts and work out what the run would have billed, and the token counts and tables above came out of that.

The useful part is that the failures got written down on the day they happened rather than reconstructed afterwards. The 700-pixel speck filter, the 28px sideways slide, the 234px against 262px neck measurement. I’d have remembered that the mouths broke. I would not have remembered the numbers.