# anidoodle: a drawing skill that is 3.3 MB of TypeScript, and what I could check without running it

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/anidoodle
> date: 2026-11-01
> tags: agent-skills, creative-tools, generative-art, canvas

A post by @cutetoxicguy pointed me at [anidoodle](https://github.com/alexgreensh/anidoodle), by Alex Greenshpun: an open-source **Agent Skill** that makes hand-drawn illustrations, animations and films from code, with 32 visual styles. This site already vendors a skill in the same space, `hand-drawn-canvas-animation` by Alexey Fateev. So the useful question is not whether code can draw. It is what a much larger skill buys over a small one, and which of its numbers you can check without trusting the README.

I could not render a single frame. Before I explain why, here is how I label things below. **Measured** means I counted or ran it myself. **Reported** means the repo or the post says so and I did not verify it. **Reasoned** means I derived it from code I read.

<RepoCard repo="alexgreensh/anidoodle" />

## What I did not do

My plan was to scaffold a project, render two styles on CPU through the repo's own `render.mjs` in headless Chromium, and time them. The first command, `node skills/anidoodle/engine/tools/scaffold.mjs`, was refused by the sandbox's permission layer as running code from an external source. I did not retry it through another route. So there is **no render time, no output size and no frame I produced with their engine** in this article. Every claim about what the engine writes comes from reading its TypeScript; every number I give is a count, a grep, or the output of a small program I wrote myself.

That small program is a Python port of one of the 32 styles' core algorithm, written from the repo's source, not a copy of its output. It is the only thing here that actually ran, and I say what it does and does not show.

I also did not read the original post. Its claims, as given to me, are the one-line summary above. The README makes the longer ones, and those I can address.

## What it is

The repo is a Claude Code plugin (also packaged for Codex and Grok Build, **reported**). You install it with `/plugin marketplace add alexgreensh/anidoodle` and `/plugin install anidoodle@alexgreensh-anidoodle`. The skill's `SKILL.md` is 13,985 bytes (**measured**) and it is not a prompt that draws. It is an intake and a process.

The process is the product. A request starts with an intake, one question at a time: what, which style, which shape, how long, what sound. A still gets one approval. A film gets three at most, and between them the skill requires that "a fresh critic reviews, never you": a new subagent that saw none of the building returns ranked, timestamped problems. The skill has a name for the failure it is trying to prevent, which is work thrown away because it had no point, or a style that was only a palette swap.

The engine under that is a **pure function of the frame**. `renderFrame(frame, env)` draws one frame to a 2D canvas, with no state carried between frames and no clock. A film is a list of shots, each with a `draw(ctx, local, env)` function. The same source "rebuilds the same pixels", **reported**, and there is a gate tool that hashes frames to check it.

Here is the contract from `core.ts`, which I read:

- randomness only from `rng(seed)`;
- no `ctx.filter`, because Safari lacks it, so blur is built by halving and scaling a canvas;
- surfaces come from `env.canvas()`, never from the core;
- every frame number lives in one cue table, with cuts on multiples of 15 and events on multiples of 5, checked when the module loads.

## The 32, counted

The README says 32 styles. I counted, because a style count is easy to inflate.

**Measured, by reading the files:** 34 modules in `engine/src/canvas-core` export a `STYLE` literal, and `styles.legacy.json` declares 11 more, for 44 distinct ids (one id appears in both). The repo's `registry.mjs` then applies two rules, which I read rather than ran. A module named `<hand>Draw` whose parent exists is folded into its parent, because it is the same hand drawn on. An entry that carries `house`, `recreationOf`, `study` or `sceneOf` is dropped, because it uses a hand rather than adding one.

Eight modules fold (`foxDraw`, `koiDraw`, `lighthouseDraw`, `moonPhasesDraw`, `pocketWatchDraw`, `ranunculusDraw`, `storybookDraw`, `wrenDraw`). Four are dropped: `adaptAlmond`, `adaptAquarelle` and `adaptScrapbook` carry a `house` path to a JSON file, and `brickBalloon` is a `sceneOf: "toyBrick"`. That leaves 32, and it matches the 32 rows in `references/styles/INDEX.md`.

<StyleFold />

So the number is honest, with a caveat about what counts. It is 32 distinct *hands*, each defined by its own module, not 32 values of a `style` parameter. That is the claim worth checking, because the repo's third law reads: "A style is a way of making marks. Change the medium, the edge and the order of the marks, beyond the palette." The module headers bear this out. `stipple.ts` opens with a paragraph on the nib, the paper, the light direction and the order of the strokes. `wren.ts` describes how a wet wash floods past the line and settles on the bottom edge of each shape. Each style is code that reproduces a specific physical process, not a filter over a shared renderer.

<Figure
  src="https://media.thesatyajit.com/ai/articles/anidoodle/styles-sheet.jpg?v=76388f465279a9b2"
  alt="A four-by-eight grid of thirty-two square illustrations, each in a different medium: pencil and watercolour flower, coloured pencil robot, marker koi, scrapbook, pixel cat, sumi-e birds, woodblock bridge, toy-brick ship, ballpoint pocket watch, broken-colour sunset, scratchboard lynx, isometric bakery, crayon balloon, oil still life, embroidery, halftone viaduct, ink wren, gouache kite, stipple nautilus, low-poly lake, chalkboard moon phases, charcoal tree, rubber-hose kettle, flat-vector harbour, cut-paper fox, toy theatre, risograph lighthouse, blueprint, single-line moon, storybook robot, folk-tale, dream glaze."
  caption="The repo's own gallery of its 32 styles; I did not render this, it is the sheet the README shows (anidoodle, skills/anidoodle/assets/styles.jpg, Apache-2.0)."
/>

The grid is the claim in picture form. Look at the pixel-art cat, the stippled nautilus and the engraved moon: these are not one renderer with a different palette. Whether each would hold up as a *moving* piece is something the sheet cannot show, since it is stills.

## The determinism claim, checked where I could

The README leans on determinism: the same source gives the same pixels and the same sound on any machine. That is a claim about a running system and I could not run it. What I could check is the discipline underneath.

**Measured.** `grep` for `Math.random` across `engine/src/canvas-core` and `three-core` finds five matches, and all five are comments saying it is banned. `grep` for `Date.now`, `performance.now` and `new Date` finds none. The only `ctx.filter` match is a comment explaining why it is avoided. The repo's own `gate.mjs` runs the same scan on every build and prints a count of seeded call sites, so the rule is enforced by a tool rather than a convention.

The vendored skill takes the same position by convention. Its `core.js` carries one `Math.random` match, also a comment ("Everything is seeded. Math.random is banned in films"), and it uses the same PRNG family. Both files contain the constant `0x6D2B79F5`, the mulberry32 generator. The difference is enforcement: anidoodle's gate fails a build that breaks the rule; the vendored skill states it and trusts the author.

There is one honest line in the repo's own doctrine file that I found more convincing than the claim. Packaging the skill, the first gate run in a fresh workspace reported 0 of 12 frames identical between a one-page and a three-page session. Two further full runs gave 12 of 12. The author could not reproduce the failure and wrote it down as an unexplained first-run failure rather than re-running until green. That is the right way to treat a flaky determinism test, and it is also the reason I would not call determinism *established* from the README alone.

## Against the skill this site already has

The vendored `hand-drawn-canvas-animation` is a single HTML file on a 66,505-byte `core.js` (**measured**), with sibling modules for sand, paper and found motion, five looks and one render script. It writes a 10 to 30 second film. anidoodle is a different size of thing.

<EngineLedger />

What the counts say, **measured**: anidoodle's drawing code is 3,291,767 bytes across 191 TypeScript modules against 177,880 bytes across four JavaScript files, about 18 times more. Its documentation is 44,695 words against 17,445. It ships 104 test and fixture files; the vendored skill ships none. And its look count is 32 against five.

What the counts do not say is whether the bigger skill is better. The honest comparison has three axes.

**Scope.** The vendored skill makes a film from one HTML file. anidoodle also does stills, loops, 3D scenes through a vendored three.js 0.170.0, a composed score with an optional sound pack, narration with word timing and subtitles, launch videos for a product, and a `ship.mjs` that runs every check on the delivered file. That breadth is real, and it comes with a build step (`npm install`, `npx playwright-core install chromium`) where the vendored skill needs a Chrome and ffmpeg.

**What an agent has to read.** A 13,985-byte `SKILL.md` that routes to 18 workflow documents is a good shape for an agent: load the one that applies. But the total is large, and a model that loads the wrong workflow gets confident about the wrong process. The vendored skill's smaller surface is a cost you pay up front in features and get back in how few ways there are to go wrong.

**Licence and provenance.** The vendored skill is MIT. anidoodle is Apache-2.0, with copyright held by Alex Greenshpun, and the `NOTICE` records third-party material: a sound pack of instruments from the Versilian Community Sample Library (CC0) and room impulse responses from OpenAIR (CC BY 4.0, attribution required). The sound pack is not in the repository; `soundfetch.mjs` installs it on request (**reported**, 233 MB for the piano, 373 MB for all). Apache-2.0 permits reuse with the notice preserved, which is why the gallery sheet above can appear here.

The sibling article on this site, [Qwen3-30B-A3B paints in p5.brush](/articles/paint-with-code), is about the same premise from the training side: make the picture an editable program. That work trains a model to *write* the code. anidoodle does not train anything. It gives an existing agent a large, well-documented engine and a review process. The two are complementary and do not compete: one asks whether a model can learn to paint with code, the other asks how an agent should be given the tools to.

## "Pay once, draw forever," as arithmetic

The README makes the claim that a picture model spends compute on every frame, where code spends it once, and that its 47-second example film takes "about sixty tokens of code per frame on the first pass, and zero on every pass after".

I can check the sixty. The example film is 1,410 frames (47 s at 30 fps, **reasoned** from the README; the frame count matches). Its source under `example/src` is 40 TypeScript files totalling 336,042 bytes, **measured**. That is 238 bytes per frame. At the usual rough four bytes per token, a figure that depends on the tokenizer and on code being denser than prose, it is about 84,000 tokens, or 60 per frame. The README's number is consistent with its repo.

What the arithmetic leaves out is the cost on the way there. The skill's own process runs critic subagents for up to three rounds per stage, and keeps a ledger of them. The tokens in the finished source are the *output*; the tokens spent iterating to it are not in the sixty. The claim is also about repeat renders, which is correct and is a genuine advantage over regenerating a video with a model: the hundredth render of the same film produces the same bytes (**reported**) at the cost of CPU time only. But "free every time it runs" means free in tokens, and a render still takes machine time that I could not measure.

## Porting the stipple sampler

To get at least one real number out of the engine's craft, I ported the sampler from `stipple.ts`, the pen-and-ink style where a 0.25 mm technical pen touches the paper thousands of times and tone is nothing but dot density. The file's comment says the spacing between dots is about 1.5 px divided by tone to the power 0.64, built as variable-radius Poisson-disk sampling in the manner of Bridson, with 18 candidates per point, drawn from the annulus between one and two radii.

I wrote it again in Python from that description and the code, keeping the constants (the 0.022 floor on tone, the 1.35 px grid, the 1.22 px pen radius) and nothing else. I ran it on a tone field of my own, a lit sphere above a gradient bar, 540 pixels square: 16,073 dots, 17 seconds on CPU. This is my port on my picture, not their engine on their shell, so it tests the *algorithm*, not the film.

<Figure
  src="https://media.thesatyajit.com/ai/articles/anidoodle/my-stipple.png?v=f530e2687fe83bd5"
  alt="A black-and-white stippled sphere lit from the upper left, with a horizontal bar beneath it that runs from sparse dots on the left to solid black on the right."
  caption="My own render: a Python port of anidoodle's stipple sampler, 16,073 dots, the repo's constants, my tone field. Not the repo's output."
/>

Two results, both **measured**.

**The spacing law is followed, with a constant factor.** In every tone band the mean distance from a dot to its nearest neighbour is about 1.08 to 1.10 times the law's value: 7.89 px against 7.26 at tone 0.075, 2.73 against 2.49 at 0.45, 1.64 against 1.52 at 0.95. That is what blue-noise sampling does: the law sets the *minimum* spacing, and the nearest neighbour in a packed field sits a little beyond it. Dot counts scale with area as expected: 7,199 dots at 360 px and 16,073 at 540, a ratio of 2.23 against the 2.25 that area predicts. I never ran it at 1080, so the count the film would hold is an extrapolation of about 64,000.

**Dot density is not tone.** Coverage, the fraction of paper the dots blacken, runs above the tone it is meant to represent through the middle of the range. At tone 0.45 the dots cover 55.5% of the paper; at 0.55 they cover 70.4%; at 0.65, 80.5%. At the light end it runs a little under: 6.0% at tone 0.073. The cause, **reasoned** and then checked against the table, is that tone sets dot *density* and density saturates. Spacing goes as `t^-0.64`, so dots per unit area go as `t^1.28`. At full tone my field holds about 0.43 dots per square pixel (nearest neighbour 1.64 px on a hex-like packing), and each dot blackens about 4.7 px², so the expected number of dots over any point is roughly 2. Ink coverage then follows `1 - exp(-n)` with `n` scaling as `t^1.28`. At tone 0.45 that gives 0.52 against 0.555 measured; at 0.15, 0.16 against 0.138. The model is crude, since it ignores that Poisson-disk dots overlap less than random ones, but it explains the shape: a mid-tone is already more than half covered because the pen is dense at the dark end.

<DotDensity />

This is a small thing, and it is the kind of thing the repo's third law is about. A stipple style that was "a palette swap" would map tone to opacity. This one maps tone to a density with a particular response curve, and the curve is a visible design decision. The caveat is mine to state plainly: my dots are discs of radius 1.22 px, and the repo's are slightly oval, ragged seven-sided shapes with a few that run dry, so its coverage will differ by a little. I did not measure that.

## What a second look would need

Things I would run next, if the sandbox allowed the engine to execute:

1. `node tools/still.mjs` on two styles at `--scale 2`, to time a frame warm (the doctrine file says to measure draw cost warm, on one page) and to look at the detail the repo says breaks at 2x.
2. `gate.mjs` on the example film, to see the 12-frame identical-hash check pass or fail on this machine.
3. `render.mjs --out x.webm` on a 6-second film, for the one number a reader most wants: seconds of CPU per second of video at 1080p, and the file size.
4. The `narrate.mjs` and `music.mjs` paths, which I did not touch. The README says you cannot hear the score or the voice from a script, and the skill's own honest-limits section says the same: "Every claim about them comes from a measurement or from a human; say which."

## Verdict

**Reported, not verified:** the render output, the film quality, the sound, the determinism across machines. **Measured:** the 32 is a real count of distinct hands; the randomness ban is enforced and visible in the code; the size comparison with the vendored skill; the sixty-tokens-a-frame figure is consistent with the example's source; the stipple algorithm follows its stated law and has a mid-tone gamma that the README does not mention.

If you want one short film from one HTML file, the smaller vendored skill is the lower-effort start. If you want a still in a particular physical medium, a series of consistent stills, or a launch video with a score and subtitles checked by a tool, anidoodle is aimed at that, and its process (intake, one approval, a fresh-eyes critic, a ship gate) is worth reading even if you never run its engine. I would want to see its render times before I used it on a deadline.
