Line · colour · animation · tiles · shipping

Every pixelis a decision

Your instinct is that resolution is a quality slider you turn up. It is not one here. A 320×180 canvas is a fixed budget, and pixel art is the craft of spending it so the thing still reads at arm's length. This course starts at a blank canvas and ends with an asset set good enough to put a price on.

20 lessons 20 labs every number computed live taste marked as taste
one slope, drawn well and drawn badly runs: —
Unit 1 · The medium and the budget — Lesson 1 of 20

Pixel art is a budget, not a filter

Pixel art is not a low-resolution version of ordinary art, and it is not a filter you apply at the end. It is a fixed budget of pixels and colours, spent deliberately. The first spending decision is the canvas itself, and it has a right answer you can compute.

You'll be able to: Choose a base resolution by computing which displays it lands on exactly, rather than by taste.

The budget, and why it is not a limitation

Your instinct about resolution is that it is a quality slider. More pixels, better image; the constraint is your hardware, and you turn it up when you can.

Pixel art inverts that. The canvas is chosen first and never changes, every pixel on it is placed by hand, and the whole craft is deciding which of them earn their place. A 320×180 canvas is 57,600 pixels for the entire screen — the game world, the character, the interface. That is roughly one-thirtieth the pixels of the window it will be displayed in, and every one of them is a decision.

This is not nostalgia and it is not a limitation being worked around. It is the constraint that makes the style legible: at this size a shape must read from its silhouette and its values, because there is no room for anything else, and that is exactly what makes a good sprite readable at a glance in a busy scene.

The shift from enterprise software

You have an instinct for this already, in a domain where the budget is obvious. Nobody asks why a favicon is not 4K. It is 16 pixels because it must be recognisable at 16 pixels, and every decision in it — drop the gradient, thicken the stroke, cut the wordmark — follows from that number rather than from taste. Pixel art is that reasoning applied to an entire game, and the reason it feels unfamiliar is only that the budget is usually invisible.

Choosing the canvas, by computation

The base resolution is the one decision that is genuinely expensive to change later, because every asset you draw is sized to it. It has an objective criterion: your game will be displayed on real screens, and the scaling should be by whole numbers. A 2.5× scale puts one source pixel across two and a half screen pixels, which the display resolves by making some of your pixels bigger than others — the shimmer that makes scaled pixel art look cheap.

So the question is which base resolutions divide evenly into the displays people actually own. Measuring eight common ones:

BaseExact fitsWorst wasted area at max integer scale
320×1804 of 826%
256×1443 of 826%
640×3604 of 848%
384×2162 of 840%
480×2702 of 849%
320×2400 of 844%
160×1440 of 853%

320×180 and 640×360 tie on exact fits, and 320×180 wins decisively on what happens when the fit is not exact — 26% wasted against 48%. That is the whole argument, and it is why this course uses it.

In detail, 320×180 lands exactly on every common 16:9 target:

DisplayResult
1280×720720pexactly 4×
1920×10801080pexactly 6×
2560×14401440pexactly 8×
3840×21604Kexactly 12×
1280×800Steam Deck4×, with 80px letterboxed
3440×1440ultrawide8×, with 880px at the sides

The Steam Deck row is worth noticing rather than hiding: it is 16:10, so no 16:9 base fits it exactly and you will letterbox. Knowing that in advance is the difference between a deliberate border and a panicked rescale a week before release.

"I will draw it large and scale it down later"

This is the most expensive mistake available at this stage, and it sounds like prudence. Downscaling produces colours that were never in your palette and edges nobody placed — it is a photograph of pixel art rather than pixel art, and it loses precisely the property that makes the style work, which is that every pixel was chosen. There is no filter, no plugin and no model that recovers it. Draw at the size you will ship at, and scale up by whole numbers.

Build it — score a base resolution
const DISPLAYS = [[1280,720], [1920,1080], [2560,1440], [3840,2160], [1280,800]];

function score(bw: number, bh: number) {
  let exact = 0, worstWaste = 0;
  for (const [dw, dh] of DISPLAYS) {
    const sx = dw / bw, sy = dh / bh;
    if (Number.isInteger(sx) && Number.isInteger(sy) && sx === sy) exact++;
    const k = Math.max(1, Math.min(Math.floor(sx), Math.floor(sy)));
    worstWaste = Math.max(worstWaste, 1 - (bw * k * bh * k) / (dw * dh));
  }
  return { exact, worstWaste };
}

Add the displays your players actually use — a Steam hardware survey is public — and the answer may differ from this course's. That is fine. What is not fine is picking the number because it looked retro.

Lab 1 — a canvas against real screens
Pick a base resolution · exact fits are green, letterboxed ones show the bars you would ship
Green is an exact whole-number fit. Amber shows the largest whole scale that fits, with the bars left over.

Each row is a real display. Green means your base divides into it exactly; amber shows the largest whole-number scale that fits and the bars left over.

Sweep the base width and watch 320×180 light up four rows at once while its neighbours light up one or none. Then try 320×240 — a perfectly reasonable-looking 4:3 canvas that fits nothing at all on a modern screen.

Try it, then answer

Why does scaling a 320×180 game by 2.5× to fill a screen look worse than scaling it by 2× and letterboxing?

Check your understanding

You are targeting the Steam Deck at 1280×800 as your primary platform. What is the most sensible base resolution choice?

Unit 1 · The medium and the budget — Lesson 2 of 20

The canvas, and the setting that ruins it

Three settings stand between you and crisp pixels, and every one of them defaults to wrong. The most damaging is the scaling filter, and its cost is not a matter of taste — it converts a sixteen-colour sprite into thirteen hundred colours, which you can count.

You'll be able to: Configure a pixel-perfect pipeline, and compute what the wrong scaling filter does to a sixteen-colour sprite.

The three settings

Everything in this course assumes a pipeline that preserves your pixels exactly, from the editor to the running game. Three settings control it, and each defaults against you.

Nearest-neighbour filtering. When your 320×180 image is drawn onto a 1920×1080 screen, something decides what colour each screen pixel gets. The default, bilinear filtering, blends between neighbouring source pixels. Nearest neighbour picks the single source pixel and repeats it. You want nearest, always.

A fixed base resolution with integer scaling. Set from lesson 1, enforced by the engine, so the game renders at 320×180 and is blown up by a whole number.

No smoothing anywhere else — not on import, not on the camera, not in a post-processing pass. Each is a separate place the same mistake can be made.

In Godot these are, respectively, Rendering → Textures → Default Texture Filter set to Nearest, the viewport stretch mode with integer scaling enabled, and the per-texture import settings. The godot2d course covers the details in its lesson 4.1; this lesson is about why they matter enough to check every one.

What bilinear actually costs, counted

The blur is the visible symptom, but the measurable damage is to your palette. Take a sprite drawn in the sixteen colours from lesson 9 and scale it up:

ScalingDistinct colours in the result
The source, 16×1616
Bilinear, 2×188
Bilinear, 3×237
Bilinear, 6×1,327
Bilinear, 8×2,246
Nearest, any scale16

At the 6× that a 1080p screen uses, bilinear filtering has invented 1,327 colours in a sprite you drew with sixteen. Every one of them is a blend nobody chose, sitting between the ramp steps you spent lesson 8 constructing. The whole argument of unit 3 — that a palette is a small set of deliberate decisions — survives exactly as long as the pipeline does.

Nearest neighbour cannot invent a colour. That is not a quality judgement, it is a property: it only ever copies a source pixel, so the output palette is a subset of the input.

The shift from enterprise software

This is lossy re-encoding in the middle of your delivery pipeline, and you would catch it instantly anywhere else. It is a service that accepts your carefully structured payload, decides it knows a friendlier representation, and passes on something that is approximately the same — a JSON number quietly becoming a float, a timestamp losing its zone. The defence is the one you already use: assert the invariant at the boundary. Sixteen colours in, sixteen colours out.

"An upscaler will make it look better"

The current generation of AI upscalers is genuinely impressive at photographs and is exactly wrong here, for the same reason bilinear is. They invent detail that was not drawn, which is the one thing a style built on deliberate placement cannot survive. The result reads as a painting of a sprite: smooth where the artist chose hard, detailed where the artist chose empty, and blurred at precisely the silhouette the next lesson will argue carries the identity. There is no setting that improves on repeating each pixel exactly.

Build it — assert the invariant
/** Nearest neighbour cannot introduce a colour. Prove it on your own art. */
function paletteOf(img: number[][][]): Set<string> {
  return new Set(img.flat().map(c => c.map(Math.round).join(",")));
}

const before = paletteOf(sprite);
const after = paletteOf(scaleNearest(sprite, 6));
console.assert(after.size <= before.size, "the scaler invented a colour");

Worth running once against your actual export, because the failure is silent: a bilinear filter left on somewhere produces art that looks slightly soft and otherwise behaves normally, and it is easy to ship.

Lab 2 — the same sprite, scaled two ways
Nearest against bilinear at your chosen scale · each panel counts the colours it contains
Colour counts are taken from the pixels actually drawn, not from the formula — so the claim in the lesson can be checked here.

The same sixteen-colour sprite, scaled by whichever factor you pick, filtered both ways, with the colour count computed from the pixels actually drawn.

Drag the scale up. The nearest panel holds at sixteen forever. The bilinear count climbs past a thousand somewhere around 6×, which is the scale a 1080p screen uses.

Look at the two panels rather than the numbers for a moment. The blur is obvious here at large scale, and much less obvious in a running game — which is how it gets shipped.

Try it, then answer

Why can nearest-neighbour scaling never increase the number of colours in an image?

Check your understanding

Your game looks crisp in the editor but slightly soft when you run it, and the softness is uniform across the whole screen. Where should you look first?

Unit 1 · The medium and the budget — Lesson 3 of 20

Silhouette first

Fill any sprite with solid black and you should still know what it is. At the sizes this course works at that is not a stylistic preference — a fifth of a 16-pixel sprite's pixels are literally on its edge, so the silhouette is most of what you have got.

You'll be able to: Judge a sprite by its silhouette alone, and compute how much of a small sprite the silhouette actually is.

The test that decides whether a sprite works

Fill it with black. If you can still tell a sword from a key, a player from an enemy, the sprite works. If you cannot, no amount of shading will save it — because at the size it ships, and in the half-second a player looks at it, the silhouette is what they receive.

Professionals apply this before any colour is chosen. It is quick, it is merciless, and it catches the failure that is otherwise found late: two enemies that read as the same enemy, an item that reads as a rock, a character whose weapon disappears into their body.

Why it dominates at small sizes

There is a reason the test matters more here than in illustration, and it is arithmetic. Take a filled disc and count how many of its pixels touch the outside:

Sprite sizeLit pixelsOn the edgeShare
8px across492041%
16px1974422%
32px7978811%
48px1,7931327%
64px3,2091806%

At 16 pixels — the size of a tile, an item, a small enemy — better than a fifth of the sprite is the silhouette. There is not much interior left to shade, and what there is has perhaps three ramp steps to work with. At 64 pixels the edge is 6% and the interior genuinely becomes the subject.

That ratio is the whole reason the advice differs by scale. "Focus on the silhouette" is not a rule someone invented; it is what happens to be true when a fifth of your pixels are on the boundary.

And a disc is the best case. For a given area the circle has the shortest boundary, so those percentages are the lowest any shape of that size can manage. Anything with a limb, a tail or a weapon runs higher:

Shape, all about 190 pixelsOn the edge
Disc, 16px across22%
Square, 14×1427%
A limb or tail, 30×638%
A thin tail, 30×2100%

The last row is the useful one. A two-pixel-wide feature is entirely silhouette — it has no interior at all — which is why a tail or a held weapon is the cheapest way to make a shape distinctive. It costs almost no pixels and every one of them changes the outline.

The shift from enterprise software

The silhouette is the type signature. It is the part a reader consumes before committing to anything else, it is what makes two things distinguishable at a glance, and getting it wrong is expensive in a way that a poor implementation behind a good signature is not. You already review a function's shape before its body; this is the same move, and the black-fill test is how you force yourself to look at the shape alone.

Designing a silhouette that differs

Since the silhouette carries identity, the useful question is not "is this a good shape" but "is this shape different enough from the others on screen". The practical moves are all about breaking the outline:

  • Give each character one exaggerated feature that projects — a hat, a tail, a weapon held away from the body, ears.
  • Avoid symmetry, which makes two silhouettes converge.
  • Keep the outline free of the body: a sword held against the leg is invisible; the same sword held out is the whole read.
"The silhouette test is for character art"

It is for everything on screen that has to be told apart, and the places it is skipped are where it is needed most. Two tiles that read as the same tile make a level feel repetitive for reasons the player cannot name. A pickup that shares a silhouette with a piece of scenery does not get picked up. An interface icon that resolves into a grey blob at 16 pixels is a support ticket. Anything you expect a player to identify at a glance is subject to the same test.

What to exaggerate — the course's own reading

The arithmetic says the silhouette carries the identity. It does not say which feature to push, and that is judgement. What has served this course: pick the one thing a player would say if they described the character in four words, and make that the part that breaks the outline. If the answer is "a knight with a big helmet", the helmet is the silhouette and everything else can be quiet. A silhouette with two competing features usually reads as neither.

Lab 3 — the black-fill test
Toggle features on and off · the readout measures how much each one changes the outline
Difference is the share of pixels whose silhouette differs from the plain body — how much a feature actually changes the outline a player reads.

A creature built from a body, and features you can add: ears, a tail, a held weapon. Every version is shown at 1×, 3× and 6×, and in silhouette.

Turn everything off and the silhouette is a blob — indistinguishable from any other blob in your game. Turn on one projecting feature and watch the difference from the plain body figure jump, which is the number that corresponds to being recognisable.

Note which features are cheap. The tail changes few pixels and a lot of silhouette; interior detail changes many pixels and no silhouette at all.

Try it, then answer

Why does the silhouette matter more for a 16-pixel sprite than for a 64-pixel one?

Check your understanding

Two enemies in your game keep being confused by playtesters, even though one is red and one is blue. What is the most likely cause and the fix?

Unit 2 · Line and shape — Lesson 4 of 20

Jaggies, and why your first line looks wrong

A pixel line is not a stroke. It is a sequence of horizontal runs, and the eye reads a break in that sequence as damage — instantly, and long before it can say what is wrong. Once you can see the runs you cannot unsee them, and most of what separates a beginner's line from a professional's is this one thing.

You'll be able to: Decompose a pixel line into its horizontal runs, and name the run that breaks the sequence.

The first thing you will draw, and the first thing that will look wrong

Open Aseprite, pick the pencil, drag a diagonal across the canvas. Zoom out.

It looks wrong. Not subtly wrong — visibly, annoyingly wrong, in a way you can point at without being able to describe. Every beginner draws this line, and almost every beginner concludes their hand is unsteady, or the tool is bad, or that pixel art requires some drawing talent they were not issued.

None of that is the problem. The line is wrong for a reason that is completely mechanical, and you can be taught to see it in about five minutes.

A line is a sequence of runs

Zoom in far enough and a diagonal is not a stroke at all. It is a stack of short horizontal runs, each one row lower than the last:

A diagonal decomposed into four runs of three pixels 3 3 3 3
The same diagonal, read as runs. Each row is a maximal horizontal run of lit pixels; the number is its length.

That decomposition is the whole lesson. A run has a length. The line is a sequence of those lengths. And the eye — yours, your player's — is extremely good at spotting a sequence that does not hold.

A jaggy is a run whose length breaks the sequence. Four runs of 3 read as one clean edge. Runs of 3, 3, 2, 3 read as a dent, and you will see the dent before you can count anything.

The shift from enterprise software

You already have this instinct; nobody has pointed it at pixels yet. It is the same reflex that makes a misaligned column in a config file jump off the screen, or a stray space inside a lined-up assignment block feel like a bug before you have read a word of it. In both cases the content is fine and the regularity is broken, and you notice the regularity first. Pixel art simply runs that reflex at a scale where every single character matters, because at 32 pixels wide there is nowhere for an irregularity to hide.

The rule, stated so a machine can check it

For a straight line: every interior run must have the same length. The first and last runs are exempt, because a line has to start and stop somewhere and its ends are usually clipped short.

That is precise enough to compute, which means it is precise enough to argue with — and you should argue with it, because it is deliberately strict. It calls a steady 1, 2, 1, 2 alternation a defect, and a person would often accept that. Curves break it constantly and legitimately; lesson 5 is where the rule is relaxed for them. Here, straight lines, strict rule.

Build it — decompose and judge
type Run = { x: number; y: number; n: number };

/** Group lit pixels into maximal horizontal runs, top row first. */
function runsOf(lit: Set<string>): Run[] {
  const cells = [...lit].map(k => k.split(",").map(Number))
    .sort((a, b) => a[1] - b[1] || a[0] - b[0]);
  const out: Run[] = [];
  let cur: Run | null = null;
  for (const [x, y] of cells) {
    if (cur && cur.y === y && x === cur.x + cur.n) cur.n++;
    else { if (cur) out.push(cur); cur = { x, y, n: 1 }; }
  }
  if (cur) out.push(cur);
  return out;
}

/** Interior runs must agree. Returns the indices that do not. */
function judge(runs: Run[]) {
  const inner = runs.slice(1, -1);
  const freq: Record<number, number> = {};
  inner.forEach(r => { freq[r.n] = (freq[r.n] ?? 0) + 1; });
  const modal = +Object.keys(freq).sort((a, b) => freq[+b] - freq[+a] || +a - +b)[0];
  return { modal, bad: runs.flatMap((r, i) =>
    i > 0 && i < runs.length - 1 && r.n !== modal ? [i] : []) };
}

Run it over a Bresenham line and you have written the same checker the lab below uses. Thirty lines, and it sees something you currently cannot.

Which slopes are actually clean

Here is where the folk advice goes wrong, and where computing beats remembering.

The usual guidance is to stick to "simple ratios" — 1:1, 2:1, 1:2 — and it is not quite right. Running the checker over every rise from 1 to 32, across a line 32 pixels wide, exactly ten of the thirty-two are clean:

Rise over a 32px lineRunsVerdict
152 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2clean — the classic 2:1
36 10 10 6clean
54 6 6 6 6 4clean
63 5 5 6 5 5 3one break
73 4 5 4 4 5 4 3two breaks
112 3 3 2 3 3 3 3 2 3 3 2two breaks
122 2 3 3 2 3 2 3 2 3 3 2 2five breaks

The clean rises are 1, 2, 3, 4, 5, 8, 15, 16, 31 and 32. Note that 3 and 5 are in and 6 and 7 are out, which no rule about "simple ratios" would have predicted.

And now the part that surprised me, and which is the actually useful finding: whether a slope is clean depends on how long the line is, not only on its angle. The same checker, run over every rise at several lengths:

Line lengthRises clean, of 32
16px25
24px17
32px10
48px9
64px11

A short line forgives almost any angle, because it has barely any interior for a break to appear in. A long one forgives very few. This is why a beginner's small icons look fine and their long roof-lines and sword blades look broken — the skill did not change, the length did.

"Use clean ratios and your lines will be fine"

The advice is not wrong so much as incomplete, and following it alone will still leave you with broken long edges. A 3:1 rise is clean at every length tested here, but a 7:1 is broken at 32 pixels, and the difference is not something you can eyeball from the ratio. The practical rule that falls out of the table above is about length, not angle: past roughly 24 pixels, stop trusting your hand and start counting runs. Under that, almost anything reads.

Where the rule is weak — the course's own reading

The checker is least trustworthy exactly where it is most confident. A rise of 3 produces only four runs, so just two of them are interior, and two matching numbers is a very low bar to clear. Calling that "clean" is arithmetic being agreeable rather than the line being good. Treat a verdict on a line with fewer than about six runs as no verdict at all, and use your eye.

Lab 4 — the jaggy detector
Drag the slider to generate a line, or draw one by hand · green runs hold the sequence, red ones break it
Drag on the grid to draw. The verdict counts only interior runs — the first and last are allowed to be short, because that is where the line begins and ends.

Start with the slider. Sweep it from 1 to 16 and watch the verdict flip between clean and broken — rise 15 gives you sixteen runs of 2 and a clean verdict, and rise 12 three notches down breaks five times.

Then hit clear and draw a diagonal freehand, the way you did in Aseprite five minutes ago. Watch which of your runs come up red. Then fix them by hand — add and remove pixels until every interior run is the same length — and notice how much better the 1× preview reads once they are.

Try it, then answer

A line's runs come out as 4, 6, 6, 6, 6, 4. The checker calls it clean. Why are the two 4s not counted as breaks?

Check your understanding

You are drawing a 40-pixel roof edge for a house tile and your freehand line comes out with runs 3, 4, 3, 4, 4, 3, 4, 3. What is the most useful next move?

Unit 2 · Line and shape — Lesson 5 of 20

Curves, and the diagonal that is not isometric

A curve breaks lesson 4's rule on purpose — its runs are supposed to change. What must not change is the direction they change in. The same reasoning, applied to diagonals, explains why every isometric game you have ever played is not actually isometric.

You'll be able to: Construct an arc whose runs step out smoothly, and explain why pixel "isometric" sits at 26.57° rather than 30°.

The rule has to bend, but only one way

Lesson 4 asked every interior run to be the same length. A curve cannot possibly satisfy that: an arc is precisely a line whose slope changes, so its runs must change too. Point the lesson-4 checker at a circle and it reports carnage.

The rule bends, and it bends in exactly one direction. Along an arc from its flat top toward its equator, the runs get shorter — long and shallow at the top, down to single pixels at the side. What makes a curve read as a curve is that the sequence never turns around:

RadiusQuadrant runs, top row firstReads as
83 2 2 1 1 1 1 1 1a smooth arc
164 3 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1a smooth arc
134 3 1 2 1 1 1 1 1 1 1 1 1 1a dent at the shoulder
154 3 2 1 2 1 1 1 1 1 1 1 1 1 1 1a dent at the shoulder

A run that drops and then climbs back is the curve's version of a jaggy, and your eye finds it in the same instant and with the same irritation.

The shift from enterprise software

This is a monotonicity check, and you have written a hundred of them. A sequence that should be sorted, a counter that should only ever increase, a version number that must not go backwards — you already have the reflex that a non-monotonic series is a bug rather than a value. Curves are that assertion, drawn. The only new part is that the assertion is on run lengths rather than on rows in a table.

Which circles the algorithm actually draws well

You will reach for circles constantly — heads, wheels, coins, bombs, the glow around a light. It is worth knowing in advance which radii come out clean, because the answer is not "all of them" and the failures are not where you would guess. Running the monotonicity check over the standard midpoint circle for every radius from 2 to 24, seventeen come out smooth and six do not:

Radii
Smooth2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 17, 19, 22, 24
Lumpy13, 15, 18, 20, 21, 23

Every radius up to 12 is fine. The trouble starts at 13 and is scattered unpredictably after that, which is why "draw a circle and nudge the radius until it looks right" is real advice rather than superstition — you are hunting for one of the smooth ones. If a 15-pixel circle looks subtly wrong to you, it is not your eye failing. It is lumpy, and 14 or 16 is not.

The diagonal that lied to you

Now apply the same thinking to straight lines and something falls out that most people who make isometric games do not know.

An isometric projection puts the ground axes at 30° from horizontal. To draw a 30° line in pixels you would need each run to be 1/tan(30) pixels long:

1tan30=1.7321

There is no such thing as a run of 1.7321 pixels. Any attempt to approximate it produces a sequence that never settles — runs of 2, 2, 1, 2, 2, 1, 2 — which lesson 4 already taught you to see as broken. So pixel artists do not draw 30°. They draw the nearest angle whose run length is a whole number:

ProjectionSlopeAngleRun length
True isometric0.577430.00°1.7321
The 2:1 "isometric" everyone uses0.500026.57°2

The 2:1 diagonal is off by 3.43°, and that error is not a compromise anyone regretted — it is the whole reason the style looks clean. A projection with two equal axes and one different is properly called dimetric, not isometric.

"Isometric pixel art is isometric"

Almost none of it is. Q\bert, Zaxxon, SimCity 2000, Diablo*, and every tile pack sold as "isometric" today are 2:1 dimetric at 26.57°, because 30° has no integer run length and would jaggy every edge in the tileset. The name stuck anyway. This matters the moment you try to combine hand-drawn tiles with 3D renders exported at a true 30° camera: they will not line up, the error compounds across the tile, and no amount of nudging fixes it. Set your renderer to 26.57°, or atan(0.5), and they snap together.

Build it — is this arc going the wrong way?
/** The runs of one quadrant of an arc, from its flat top downward. */
function arcRuns(boundary: Set<string>): number[] {
  const byRow = new Map<number, Set<number>>();
  for (const k of boundary) {
    const [x, y] = k.split(",").map(Number);
    if (x >= 0 && y <= 0) {                       // top-right quadrant only
      if (!byRow.has(y)) byRow.set(y, new Set());
      byRow.get(y)!.add(x);
    }
  }
  return [...byRow.keys()].sort((a, b) => a - b).map(y => byRow.get(y)!.size);
}

/** A curve is smooth when its runs never turn around. */
const smooth = (runs: number[]) => runs.every((v, i) => i === 0 || v <= runs[i - 1]);

Note what this does not do: it never asks how big the steps are, only that they keep going the same way. That is the entire difference between the straight-line rule and the curve rule.

Lab 5 — smooth arcs and lumpy ones
Drag the radius · the quadrant's run sequence is drawn beside the arc, red where it turns around
Runs are counted across the top-right quadrant only, from the flat top downward. A smooth arc's runs never turn back upward — the steps may shrink fast or slowly, but never grow.

Sweep the radius from 2 upward. Everything is smooth until 13, where a run drops to 1 and then climbs back to 2 — and you can see the dent in the arc at the same moment the readout goes red.

Then compare 13 against 14, and 15 against 16, at the small sizes. The lumpy ones are not subtle once you know where to look, and this is the fastest way to build the habit of checking a radius before you commit a sprite to it.

Try it, then answer

Why does the lesson-4 rule — every interior run the same length — have to be replaced for curves rather than just loosened?

Check your understanding

You are drawing a 30-pixel-wide coin that needs to read as a circle, and radius 15 comes out with a visible dent at each shoulder. What is the best fix?

Unit 2 · Line and shape — Lesson 6 of 20

Anti-aliasing, and the palette it costs

Your editor has an anti-aliasing button and you must not press it. Coverage anti-aliasing spends colours, a pixel-art palette has almost none to spend, and for a 32-pixel sprite the arithmetic is about as bad as it can possibly get. Hand anti-aliasing spends one.

You'll be able to: Compute what automatic anti-aliasing costs in palette entries, and place a hand mid-tone at a run corner instead.

The button that ruins pixel art

Every drawing tool offers anti-aliasing, and at photographic resolutions it is free and correct. Here it is neither, and the reason is not aesthetic — it is a budget you can count.

Coverage anti-aliasing works out how much of each pixel the ideal geometric line passes through and inks it proportionally. A pixel the line clips at 30% gets 30% of the ink. This produces a beautifully smooth edge and an enormous number of intermediate tones, and intermediate tones are the one thing you do not have.

Counting the cost exactly

For a line of run dx and rise dy, the coverage values repeat with a period set by the greatest common divisor. One of those values is a whole pixel rather than a part-tone, so the number of extra tones a line demands is one fewer:

part-tones=dxgcd(dx,dy)-1

That formula has a consequence worth sitting with. A sprite 32 pixels wide gives a line a run of dx=31 — and 31 is prime. Its only divisors are 1 and itself, so gcd(31,dy)=1 for every rise, and the count is 30 no matter what angle you draw:

Sprite widthdxCheapest slope availablePart-tones it needs
16px15rise 52
17px16rise 81
24px23 (prime)rise 122
32px31 (prime)rise 130
33px32rise 161
48px47 (prime)rise 146
65px64rise 321

Read the 32px row against the 33px row. One pixel of extra width takes the best available case from 30 part-tones to 1. The sprite sizes everyone reaches for first — 16, 32, 48 — are the ones where automatic anti-aliasing is most expensive, because subtracting one from a power of two lands you on a prime surprisingly often.

And the budget those tones are spending against: this course targets a palette of 16 to 32 colours for the entire game. A single anti-aliased 32-pixel diagonal asks for 30 tones of one edge colour. There is no version of that arithmetic that works.

The shift from enterprise software

You have met this trade before under a different name. Coverage anti-aliasing is lossy compression run in the wrong direction: it spends bits — here, palette entries — to smooth a signal, and it spends them without asking. At photographic resolution the budget is 16 million colours and nobody notices. At 16 colours it is the difference between shipping and not. The instinct you want is the one you already apply to a payload size or a memory budget: the feature is not free, and "just turn it on" is a decision about a resource.

What hand anti-aliasing does instead

A pixel artist anti-aliases too, but spends differently. Rather than shading every pixel by coverage, you place a single mid-tone at the corner where one run steps down to the next — the inside of the staircase, and nowhere else.

A single mid-tone pixel placed at the inside corner of a run step one mid-tone, reused three runs of 3, hard edges, plus a single extra colour
Hand anti-aliasing spends one colour. The mid-tone sits only at the inside corner where one run steps to the next; the rest of the edge stays hard.

Every corner reuses the same mid-tone, so a line with fifteen corners still costs one palette entry rather than 30. That is the entire technique, and it is why pixel art is built from a small ramp of deliberately chosen colours rather than from whatever the renderer produces.

"Anti-aliasing makes edges look smoother, so more is better"

More is not better and it is not even neutral, for a reason beyond the palette count. Anti-aliasing an edge softens the boundary between a sprite and whatever is behind it, and a sprite that must read against many backgrounds — grass, stone, sky, a lit room — has its silhouette dissolved by the very tones that smoothed it. This is why the outside edge of a character is usually left hard and unaliased even in art that anti-aliases heavily on interior detail. The smoothing is spent where the background is known and withheld where it is not.

When to spend it — the course's own reading

Nothing above tells you where the mid-tone should go, and that part is judgement rather than arithmetic. What has served this course's own work: spend hand anti-aliasing on long shallow curves where the staircase is widest and most visible, withhold it on the outer silhouette, and never use it to rescue a line whose runs are broken. A jaggy that has been blurred is still a jaggy, and now it costs a colour as well.

Lab 6 — three ways to draw the same edge
Aliased, coverage anti-aliased, and hand anti-aliased · each panel prints the number of distinct tones it needs
Tone counts are the distinct part-tones each method needs, counted from the pixels actually drawn — not from the formula, so the two can be checked against each other.

The same line, drawn three ways, with its palette cost printed underneath each.

Move the rise slider and watch the middle number. At a sprite width of 32 the coverage count sits at 30 for every slope you can pick, because 31 is prime — the formula above, happening in front of you. Switch the width to 33 and watch it collapse.

Then look at the three panels at 1×, which is the only size that matters. The hand-anti-aliased edge is spending one colour and, at the size it ships, is very hard to tell from the one spending 30.

Try it, then answer

Why does a sprite 33 pixels wide need dramatically fewer anti-aliasing tones than one 32 pixels wide?

Check your understanding

You are anti-aliasing a character sprite by hand for a game where the player walks across grass, stone and snow. Where should you deliberately not place mid-tones?

Unit 3 · Colour — Lesson 7 of 20

Value does the work

Form is carried almost entirely by value — how light or dark a colour is — and hue is what you decorate it with afterwards. The trouble is that the lightness slider in your colour picker is not measuring what your eye measures, and across hues it is wrong by four ramp steps.

You'll be able to: Judge a ramp by measured lightness rather than by the picker's number, and explain why that number cannot be compared across hues.

Squint, and the hue disappears

Take any sprite you admire, desaturate it to greyscale, and it still reads. Take the same sprite, flatten its values so every colour has the same lightness, and it collapses into an unreadable smear no matter how carefully the hues were chosen. Form — the sense of a shape occupying space, lit from somewhere — is carried by value, and hue is what you spend afterwards on mood and material.

This is why every pixel art tutorial tells you to squint at your work. Squinting throws away high-frequency detail and most of your colour discrimination, leaving roughly the value structure. If it reads squinted, it reads.

So the practical question of this unit is: how do you build a set of values that step evenly? And the answer starts with a warning about the tool you will reach for first.

The picker's lightness number is not your eye's

Aseprite's colour picker gives you an HSL lightness slider, and it is tempting to build a ramp by moving it in equal steps. Within a single hue that is a decent approximation. Taking a blue at hue 210, saturation 60%, and stepping lightness 20 → 80% in equal increments of 15, then measuring what the eye actually receives:

Picker lightnessColourMeasured lightnessStep
20%#1433520.315
35%#24598F0.4570.142
50%#3380CC0.5880.132
65%#70A6DB0.7080.120
80%#ADCCEB0.8330.125

Equal picker steps of 15% produce measured steps of 0.142, 0.132, 0.120 and 0.125. The largest is 1.19× the smallest — uneven, but not badly so. If you build a single-hue ramp with the lightness slider you will get something usable.

("Measured lightness" throughout this course means OKLab L, a scale built so that equal numeric differences are roughly equal perceived differences. It is what "evenly spaced" can actually mean as a number.)

Across hues, it falls apart completely

Now the failure that will wreck your first palette. Put six fully saturated hues at the same picker lightness of 50% — the slider insists these are equally light — and measure them:

HueColourMeasured lightness
60°yellow#FFFF000.968
180°cyan#00FFFF0.905
120°green#00FF000.866
300°magenta#FF00FF0.702
red#FF00000.628
240°blue#0000FF0.452

Yellow measures 0.968 and blue measures 0.452, a spread of 0.516. From the table above, one step of a ramp is worth about 0.13. So yellow and blue at identical picker lightness are four full ramp steps apart in the only measurement that matters.

The shift from enterprise software

This is a units bug, and you have shipped one. HSL lightness and perceived lightness are different quantities that share a name, a range, and a slider — exactly the setup that produces a mile/kilometre mix-up in production. The number is not wrong; it is answering a different question than the one you are asking. Your defence is the same one you already use: stop trusting the label and measure the thing you actually care about.

"Set both ramps to the same lightness range and they will match"

This is the single most common way a first palette goes wrong, and it looks like diligence. You build a yellow ramp running 20–80% and a blue ramp running 20–80%, the numbers line up, and the result is a sprite whose yellow parts glare while its blue parts sink into the background. The numbers matched; the values never did. The fix is not to abandon the picker but to stop comparing its lightness figure across hues — check the values by desaturating instead, where four ramp steps of difference is impossible to miss.

Build it — measure what the eye receives
const toLinear = (c: number) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;

/** OKLab lightness of an sRGB colour, each channel in 0..1. */
function lightness(r: number, g: number, b: number): number {
  const R = toLinear(r), G = toLinear(g), B = toLinear(b);
  const l = Math.cbrt(0.4122214708*R + 0.5363325363*G + 0.0514459929*B);
  const m = Math.cbrt(0.2119034982*R + 0.6806995451*G + 0.1073969566*B);
  const s = Math.cbrt(0.0883024619*R + 0.2817188376*G + 0.6299787005*B);
  return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s;
}

Thirty seconds of arithmetic buys you a number you can sort a palette by. Every figure in this unit comes from this function, and so does every lab.

Lab 7 — the picker's lightness against your eye's
Every swatch sits at the same picker lightness · the bar under each is what the eye actually receives
Bars are OKLab lightness, the scale on which equal differences are roughly equal perceived differences.

The top row is six hues, all at whatever picker lightness you choose. The slider says they match. The bars beneath show measured lightness, and they do not.

Press desaturate to throw the hue away, which is what squinting approximates. The yellow swatch stays nearly white and the blue nearly black, at a setting that claimed both were exactly half way.

Then drop the saturation slider toward zero and watch the bars converge. The disagreement is a function of how saturated the colours are — which is why muted palettes are more forgiving, and why a beginner's first bright palette is the one that falls apart.

Try it, then answer

Why does a yellow and a blue at the same HSL lightness look so different, when the picker reports one number for both?

Check your understanding

You are building a 4-step ramp for a golden coin and a 4-step ramp for a blue gem, and you want the coin to read as clearly brighter than the gem at every step. What is the most reliable way to set them up?

Unit 3 · Colour — Lesson 8 of 20

Hue shifting, and what it actually buys

Every pixel art tutorial tells you to rotate the hue toward blue in the shadows and toward gold in the lights. Almost all of them explain it wrongly. The usual reason — that it makes your steps read as more distinct — is measurable, and it is worth about two percent. The real reason is a gamut.

You'll be able to: Build a hue-shifted ramp, and compute the chroma headroom it buys over a fixed hue at identical lightness.

The advice, and the explanation that does not survive measurement

The advice is sound and universal: do not build a ramp by darkening one colour. Rotate the hue as you go — shadows cooler, toward blue and purple; highlights warmer, toward orange and gold. Ramps built this way look alive, and ramps built by dragging a lightness slider look like plastic.

The usual explanation is that hue shifting makes adjacent steps easier to tell apart, adding separation that lightness alone cannot. That is a claim about perceptual distance, and perceptual distance is a number. So here is the test: five ramps, every one with the identical lightness sequence and the identical chroma, differing only in how far the hue rotates across them.

Hue rotationMean distance between adjacent stepsAgainst the flat ramp
none0.1225
30°0.1230+0%
60°0.1244+2%
120°0.1300+6%
200°0.1416+16%

A realistic shift of 60° buys two percent. That is not what anybody is seeing. The common explanation for the most repeated piece of advice in the subject does not survive contact with a measurement.

How this course found that out — and nearly did not

The first version of this table reported +26% for a realistic shift, which would have made a tidy lesson. The number was wrong: the ramps being compared had been pushed outside what a monitor can display, and the clipping quietly changed their lightness, so the comparison was not measuring hue at all. Recomputing with every ramp inside the displayable range dropped the figure to +2% and destroyed the lesson that had already been drafted around it. That is the second time in this unit that the tidy result was the wrong one.

What is actually going on: the gamut is not a cylinder

Colour pickers draw the space as a cylinder — hue around, saturation out, lightness up — which quietly implies that any hue can be as vivid as any other at any lightness. That is false, and not by a little. Measuring the most chroma a monitor can actually display, at each lightness, for each hue:

LightnessWidest hueChroma available thereChroma at a fixed blue (250°)
0.30266°0.2040.085
0.50284°0.2940.142
0.70328°0.3220.164
0.90134°0.2500.051

At a lightness of 0.90 a green-cyan can hold nearly five times the chroma of that blue. The displayable space is a lumpy solid, widest in the purples low down and in the greens and yellows high up.

Now the consequence. A ramp that holds one hue is limited by its worst step — the chroma it can carry everywhere is whatever its most cramped end allows. A ramp that rotates its hue can follow the wide part of the solid as it climbs:

StrategyHuesUsable chromaThe ramp
Fixed blue250° throughout0.083#113C64 #37618B #5984B0 #7CA9D7 #A1CFFF
Fixed orange60° throughout0.082#592E00 #7F522A #A5744C #CB9970 #F3BE94
Purple to gold285° 320° 355° 25° 60°0.109#372E70 #784783 #B36383 #E18982 #FFBA80
Chasing maximum266° 276° 312° 328° 142°0.238#0D00B3 #4539DF #AE42E4 #EF5FEE #59EF4C

Every ramp there has exactly the same lightness at every step. The classic purple-to-gold shift carries 1.31× the chroma of the fixed blue — it is a richer ramp for free, because it is spending its lightness where the space is wide rather than where it is pinched.

And the last row is the honest limit of the argument. Chasing maximum chroma gives 2.87×, and gives you a ramp that goes purple to green and reads as two different objects. The classic shift is a moderate move toward the wide part, stopped well short of it so the ramp still reads as one material lit by one light.

The shift from enterprise software

You are picking values under a constraint whose shape you cannot see from the API. The picker exposes hue, saturation and lightness as three independent sliders, exactly the way a configuration surface exposes three independent knobs — and, exactly as in that case, whole regions of the product of those ranges are invalid. Hue shifting is what following the real feasible region looks like when the interface is still pretending it is a box.

"Hue shifting makes your steps easier to tell apart"

Measured at a realistic 60° rotation, with lightness and chroma held constant, it adds about 2% to the perceptual distance between adjacent steps. Your eye is not detecting 2%. What it is detecting is that the shifted ramp is more chromatic overall — 1.31× — and that the shift makes the ramp read as a lit surface, where warm light and cool shadow is the everyday experience of daylight. Neither of those is extra step separation, and if you go looking for separation by shifting hue you will over-rotate and end up with the purple-to-green ramp in the last row of the table.

Where the shift should point — the course's own reading

The arithmetic says the ramp should rotate toward the wide part of the solid. It does not say which way is right for the scene, and that is judgement. What has served this course: pick the hue of your light source and rotate highlights toward it, pick the hue of the ambient fill — usually the sky, usually blue — and rotate shadows toward that. Doing so happens to move you toward the wide part of the space in most cases, which is probably why the convention settled where it did rather than anyone computing a gamut.

Lab 8 — a fixed ramp and a shifted one, matched exactly
Both ramps have identical lightness at every step · only the hue rotation differs
Both ramps are held at the same lightness sequence. Usable chroma is the most either can carry with every step still displayable on a monitor.

Two ramps, the same lightness sequence in each, so anything you see between them is the hue doing it.

Drag hue span and watch three numbers. Usable chroma climbs as the ramp follows the wide part of the space. Separation from hue alone — measured by rebuilding the shifted ramp at the flat one's chroma, so the rotation is the only difference — barely moves. Set the span to 60° and it reads +2%, the figure from the table above. Separation with the chroma gain included moves a great deal.

That gap between the last two numbers is the whole argument. Compare the ramps as you would actually build them and hue shifting looks like it buys separation. Hold chroma equal and the separation almost entirely disappears, because it was the chroma doing it.

Push the span past about 150° and the ramp stops reading as one material. That point is not in the arithmetic; it is the judgement the numbers hand back to you.

Try it, then answer

Two ramps have identical lightness at every step, identical chroma, and differ only in that one rotates its hue by 60°. What does the measurement say the rotation buys?

Check your understanding

You are building a ramp for a red brick wall and want it as rich as you can get while keeping it readable as one material. Which approach follows the lesson?

Unit 3 · Colour — Lesson 9 of 20

A palette that works

A palette is not sixteen colours. It is four or five ramps that happen to total sixteen, and the single decision that makes or breaks it is one nobody mentions — whether the ramps share their lightness values or step past each other.

You'll be able to: Assemble sixteen colours as ramps whose lightness values interleave, and audit the result for collisions.

Sixteen colours is four ramps

The instinct on being handed a budget of sixteen colours is to pick sixteen colours. That produces a palette you cannot shade with. What you actually need is a handful of ramps — a material and its light and shade — and sixteen is the total, not the vocabulary. Four ramps of four steps, or five of three with one spare, is the shape to aim for.

Each ramp is built the way lesson 8 described: fixed lightness steps, hue rotating toward the wide part of the displayable space, chroma set by whichever step is most cramped.

So far this is bookkeeping. The interesting decision is what lightness values the ramps use, and it turns out to matter more than any of the colours.

The trap: four ramps that share four values

The obvious construction gives every ramp the same lightness sequence — say 0.30, 0.48, 0.66, 0.84 — so the ramps line up neatly. Audit the result and it is much worse than it looks:

ConstructionDistinct lightness levelsClosest pair in the palette
Aligned — every ramp on the same four values4 of 160.0045
Staggered — each ramp offset by a quarter step16 of 160.0572

Sixteen colours resolving to four distinct values is the failure. Lesson 7 established that value carries form; a palette with four values has four levels of form available no matter how many hues sit on them. Worse, the closest pair in the aligned palette is 0.0045 apart, which is two colours from different ramps at identical lightness and similar chroma — they will read as the same colour in anything smaller than a title screen.

Offsetting each ramp by a quarter of a step costs nothing, changes no hue, and takes the palette from four usable values to sixteen. The closest pair moves out by nearly thirteen times.

The shift from enterprise software

This is hash collision, and the fix is the one you already know. Sixteen keys mapping onto four buckets is not a palette problem or a colour problem, it is a distribution problem, and it degrades in exactly the way you expect: lookups that should be distinct come back indistinguishable. Staggering the ramps is choosing a better hash. It is also why the audit below is a measurement rather than a matter of taste — you can compute the collision, so you should.

The palette this course uses

Four ramps, staggered, hue rotating toward the wide part of the space, built by the method above:

RampColoursLightness
Skin / warm#0F042B #4C2A4D #8E5765 #C8907C0.16 0.34 0.52 0.70
Stone / neutral#0E172B #464059 #856E84 #C5A2AC0.21 0.39 0.57 0.75
Foliage#10262A #3F5651 #78897B #B9BEAA0.25 0.43 0.61 0.79
Sky / water#2F1E60 #365C9B #379DC8 #62E0E60.30 0.48 0.66 0.84

Sixteen distinct lightness levels, closest pair 0.0572 apart, covering 0.16 to 0.84. Note what it does not contain: no pure black, no pure white, and no single colour that is obviously "the red one". Every entry is a step of some material, which is what makes it shadeable.

"A bigger palette is a better palette"

Doubling to 32 colours does not double what you can draw, and usually makes the result worse. The constraint that produces a coherent image is that everything on screen is lit by the same light, and a palette expresses that by reusing the same few ramps everywhere. Adding colours is how you get a sprite whose parts do not belong together. The exception worth knowing is that extra entries buy more ramps, not longer ones — a fifth material is usually worth more than a fifth step on an existing ramp.

Which four materials — the course's own reading

The audit checks the structure, not the choice. Nothing above says your four ramps should be skin, stone, foliage and water; that came from a side-scrolling platformer set outdoors, and a game set in a spaceship wants different ones. The practical method: list the surfaces that will cover the most pixels in your game, take the four biggest, and give those the ramps. Everything else borrows from them.

Build it — audit a palette
/** Two things a palette must not do: collapse onto few values, and
    contain a pair nobody can tell apart. */
function audit(colours: [number, number, number][]) {
  const labs = colours.map(oklab);
  const levels = new Set(labs.map(l => l[0].toFixed(2))).size;
  let closest = Infinity;
  for (let i = 0; i < labs.length; i++)
    for (let j = i + 1; j < labs.length; j++)
      closest = Math.min(closest, deltaE(labs[i], labs[j]));
  return { levels, closest, span: [
    Math.min(...labs.map(l => l[0])), Math.max(...labs.map(l => l[0]))] };
}

Run it before you start drawing rather than after. A palette with four levels is not a palette you can fix later by being careful.

Lab 9 — a palette, and its collisions
Swatches sorted by measured lightness · the stagger slider is the whole lesson
Distinct levels counts how many different measured lightnesses the sixteen colours resolve to. Closest pair is the smallest perceptual distance between any two entries.

The top block is the palette as four ramps. Beneath it, the same sixteen colours sorted by measured lightness, which is how your eye will sort them.

Drag stagger to zero. The sorted row collapses into four tight clusters, the distinct-level count drops to 4, and the closest-pair figure falls off a cliff — that is the aligned palette, and it is what you get by default.

Bring it back up and the sorted row spreads into an even staircase. Nothing about the hues changed.

Try it, then answer

Why is a sixteen-colour palette whose ramps all share four lightness values worse than one whose values interleave, given both contain sixteen colours?

Check your understanding

You have a 16-colour palette and your sprites keep reading as flat, even though each individual ramp looks correct in the swatch row. What should you check first?

Unit 3 · Colour — Lesson 10 of 20

Dithering, and the one that tiles

Dithering buys tone resolution you have not got palette entries for. Measured pixel by pixel it makes the image worse, and at the scale the eye actually works it makes it sixteen times better. Then there is a second criterion, which decides the matter for anything in a tileset.

You'll be able to: Compute what a Bayer matrix buys in apparent tones, and choose ordered dithering over error diffusion for anything that repeats.

Buying tones you cannot afford

You have sixteen colours and a wall that needs to fade from lit to shadowed across forty pixels. The ramp gives you four steps. Dithering is how you get more apparent tones out of the entries you already own: interleave two neighbouring colours in a fixed pattern and, from any distance at all, the eye averages them into something between.

The classic pattern is an ordered dither driven by a Bayer matrix — a small grid of thresholds, tiled across the image. Its size sets how many mixtures are available:

MatrixThresholdsApparent levels between two colours
2×245
4×41617
8×86465

A 4×4 matrix turns two palette entries into seventeen apparent tones. Buying seventeen real tones would cost seventeen entries — the entire palette from lesson 9, for one gradient.

The 4×4 matrix itself, which is worth recognising because you will see it everywhere:

The 4 by 4 Bayer threshold matrix 08210 124146 31119 157135
The 4×4 Bayer matrix. A pixel is drawn in the lighter colour when its value exceeds the threshold at its position, so the thresholds spread the mixture as evenly as possible rather than clumping it.

Dithering makes the image worse, and better

Here is the measurement that explains what dithering actually does. A gradient quantised down to two colours three ways, with the error measured per pixel and then over the neighbourhoods the eye integrates:

MethodError per pixelOver 4×4Over 8×8
Nearest colour0.24900.24900.2490
Ordered, 4×4 Bayer0.33070.01590.0112
Floyd–Steinberg0.33080.02840.0109

Read the first column and dithering is a mistake: it is a third worse than simply picking the nearest colour. Read the third and it is a different technique entirely — nearest colour is stuck at 0.2490 no matter how far back you stand, because its error is a bias, while dithering's error is spread so it cancels. At a 4×4 window ordered dithering is sixteen times more accurate than nearest.

That is the whole trade, and it is why dithering is worth doing and also why it is worth doing sparingly: you are paying in per-pixel correctness, which at 1× is visible as texture.

The shift from enterprise software

Nearest-colour quantisation has bias; dithering has variance. Averaging kills variance and cannot touch bias, which is why standing back fixes one and not the other — the same reason you can average away jitter in a latency measurement but not a clock that is simply set wrong. Dithering is deliberately trading a systematic error you cannot remove for a random-looking one you can.

The criterion that actually decides it

Floyd–Steinberg wins the accuracy table at the largest window, and only just: 0.0109 against 0.0112, an edge of three percent. Nearly every general-purpose tool defaults to it anyway. For a tileset it is almost always the wrong choice, and the reason has nothing to do with accuracy.

Error diffusion carries state: each pixel's error is pushed into its neighbours, so what a pixel becomes depends on everything drawn before it. Ordered dithering is a pure function of position. Render the same 16×16 tile of flat mid-grey twice, changing only what sits to its left:

MethodPixels of the tile that differ
Ordered, 4×4 Bayer0.0%
Floyd–Steinberg100.0%

The Floyd–Steinberg tile comes out perfectly inverted. Flat mid-grey dithers to a checkerboard, and the error arriving from the left flips its phase, so every pixel lands the other way. Place that tile twice in a level and the two copies do not match; scroll past it and the pattern crawls.

Ordered dithering gives a bit-identical result wherever the tile is placed, because the threshold depends only on (x, y). That single property is why tilesets, and almost all pixel art, use ordered dithering despite losing the accuracy table.

"Floyd–Steinberg is better, so use it"

It is better by the measure that general-purpose image tools care about, which is faithfulness of a single static image. Pixel art has two requirements that measure does not capture: the result must be stable under repetition, because tiles repeat, and stable under motion, because sprites move. Error diffusion fails both — its output depends on context, so a repeated tile is not repeatable and a moving sprite shimmers. The default in your image editor was chosen for photographs.

How much to dither — the course's own reading

The arithmetic says dithering buys tones cheaply. It does not say how much to use, and the honest answer is much less than the arithmetic tempts you toward. What has served this course: dither only where a gradient is genuinely wanted and a ramp step is genuinely missing, keep the pattern to the largest features on screen — skies, big walls, ground planes — and never dither a sprite that will be seen at 1× against a moving background. A dithered 16-pixel character does not read as shaded, it reads as noisy.

Lab 10 — three quantisers, and what happens when you tile them
Error is reported per pixel and over the neighbourhoods the eye integrates · the tile switch is the deciding test
Error is mean absolute difference from the true gradient, measured on the pixel and on the local average — the second is what the eye receives.

The gradient is quantised to two colours three ways, with the error table from above computed live beneath each.

Watch the three numbers under each strip disagree about which method is best. Per pixel, nearest colour wins. Over an 8×8 neighbourhood it is the worst by a factor of twenty-two, and the two dithers are within three percent of each other.

Then hit tile it. The same patch is rendered six times over. The ordered copies are identical to one another; the Floyd–Steinberg copies all differ, because each one inherited different residue from the patch before it. That is the criterion that settles it, and no accuracy figure shows it.

Try it, then answer

Ordered dithering is less accurate than Floyd–Steinberg over an 8×8 window, yet pixel art uses it almost exclusively. Why?

Check your understanding

You are shading a large sky that fades from deep blue at the top to pale at the horizon, and your palette has four blues. Where does dithering help, and where should you leave it alone?

Unit 4 · Characters and animation — Lesson 11 of 20

A character that reads at 32 pixels

Pixel characters have big heads, and it is not a stylistic quirk inherited from anywhere. A face needs about five pixels of head height before it can have eyes and a mouth at all, and at 32 pixels tall that rules out every realistic proportion.

You'll be able to: Choose head proportions from the pixel budget rather than from anatomy, and verify the result at the size it ships.

Why every pixel character looks like that

Realistic human proportions are about seven and a half heads tall. Apply that to a 32-pixel sprite and the head is four pixels — two eyes and a mouth do not fit in four pixels, so the character has no face. That is the entire explanation for a convention people usually attribute to taste:

Proportion16px24px32px48px64px
Realistic, 7.5 heads2px3px4px6px9px
Heroic, 6 heads3px4px5px8px11px
Stylised, 4 heads4px6px8px12px16px
Chibi, 3 heads5px8px11px16px21px
Super-deformed, 2 heads8px12px16px24px32px

Five pixels is the working floor for a face — two for the eyes, a gap, one for a mouth — so anything below it is a head with nothing in it. The rule that falls out is arithmetic rather than art direction:

Sprite heightLargest head ratio that still gives a 5px head
16px3.2 heads
24px4.8 heads
32px6.4 heads
48px9.6 heads
64px12.8 heads

At 32 pixels you can just afford heroic proportions and nothing more realistic. At 16 pixels you are in chibi territory whether you wanted to be or not. This is why a 16-pixel character in a game you admire has a head a third of its height — the artist was not making a stylistic statement, they were fitting a face into the pixels available.

The shift from enterprise software

This is a layout constraint propagating upward into a design decision, which is the most familiar thing in the world once you name it. A column that must display a currency value to two decimal places has a minimum width, and that minimum eventually decides how many columns the table can have and then what the page is for. Nobody calls that a stylistic choice. The character's head is the currency column: it has a minimum size set by what must be legible inside it, and everything else in the composition gives way to it.

Checking it at the size it ships

The mistake that survives longest is looking at your sprite zoomed in. Aseprite opens at a comfortable working zoom, everything reads beautifully at 800%, and the sprite is illegible in the game. Three checks, all cheap:

  • View it at 1×. Not 2×, not 3×. The size a player sees.
  • Fill it black — lesson 3's test, which is about identity rather than legibility but catches different failures.
  • Squint, or blur it. Anything that survives heavy blurring is carried by value structure rather than by detail, which is what survives motion too.
"I will add the detail and then shrink it to fit"

Detail added above the budget does not compress, it disappears — and it takes adjacent pixels with it, because shrinking blends. A five-pixel face drawn as five pixels is a face; the same face drawn at 40 pixels and reduced is a grey smudge with a hint of eyes. The order that works is the opposite: establish the silhouette and the head ratio at final size, confirm it reads, and only then spend whatever pixels are left over on detail.

Which proportion to pick — the course's own reading

The table gives you a ceiling, not an answer. Below the ceiling the choice is tone, and the pattern this course has followed: heroic proportions for a character meant to be taken seriously in combat, chibi for one meant to be liked, and consistency above either — a cast that mixes 3-head and 6-head characters reads as a mistake rather than as a contrast, unless the difference is the point.

Lab 11 — proportions against the pixel budget
Set the height and the head ratio · the readout is how many pixels the face actually gets
Five pixels of head height is the working floor for a face — two for eyes, a gap, one for a mouth.

A figure built from the two numbers, drawn at the size you choose and shown at 1×, 2× and 6× beside it.

Drag the head ratio toward realistic at 32 pixels and watch the face run out of room — the readout goes red the moment the head drops below five pixels, and the 1× preview stops being a person.

Then raise the sprite height with the ratio held. Realistic proportions become available somewhere past 48 pixels, which is exactly why bigger sprites can afford to look more like people.

Try it, then answer

Why do pixel-art characters at small sizes almost always have exaggerated head proportions?

Check your understanding

Your 32-pixel character reads perfectly while you draw at 600% zoom, and is unreadable in the running game. What is the most useful first move?

Unit 4 · Characters and animation — Lesson 12 of 20

The walk cycle, and the feet that slide

A walk cycle is not judged by how it looks in the animation preview. It is judged against the speed the character actually moves, because those two numbers together decide the length of the step — and if the step is longer than the legs, the feet slide.

You'll be able to: Compute the step length a walk cycle implies from movement speed and frame timing, and tell whether the feet can reach it.

The four poses, and then the arithmetic

A walk cycle is built from four poses per step: contact, where the leading foot lands; down, the lowest point as weight transfers; passing, where the rear leg swings through; and up, the highest point of the push-off. Mirror them for the other leg and you have eight frames, which is the standard walk. A four-frame walk keeps contact and passing only, and reads as a simplification rather than as a mistake.

That much is craft. The part that is arithmetic, and that almost nobody checks until the animation is finished, is what the cycle implies about distance.

A full cycle is two steps. In the time it takes to play, the character moves speed × cycle time. Split that between two steps and you have the length of each stride — a number the animation must depict, because the planted foot has to stay planted relative to the ground:

SpeedCycleDistance per cycleStep length
60 px/s8 frames × 100ms = 800ms48.0px24.0px
60 px/s8 × 50ms = 400ms24.0px12.0px
120 px/s8 × 100ms = 800ms96.0px48.0px
120 px/s4 × 50ms = 200ms24.0px12.0px
220 px/s8 × 50ms = 400ms88.0px44.0px

A 32-pixel character has roughly fourteen pixels of leg. Any step longer than that cannot be reached, so the foot must slide along the ground to keep up — the skating that makes an otherwise good animation look wrong without the viewer being able to say why.

What actually fits

Working the constraint backwards, the cycle has a ceiling: cycle time ≤ 28000 / speed milliseconds for a step to stay within reach.

SpeedLongest cycle that keeps feet plantedA cycle that fits
60 px/s466ms8 frames × 50ms — step 12.0px
90 px/s311ms6 × 50ms — step 13.5px
120 px/s233ms4 × 50ms — step 12.0px
160 px/s175msnothing with 4+ frames at 50ms
220 px/s127msnothing

Read the bottom two rows carefully, because they are the honest finding: past about 150 px/s, a 32-pixel character cannot have a planted walk. There is no frame count that works. The options are to accept the slide, to animate a run rather than a walk — a run has an airborne phase, so nothing is planted and the constraint dissolves — or to make the character taller.

The shift from enterprise software

Two subsystems each look correct and disagree at the boundary. The animation is right, the movement code is right, and the artefact only exists in the relationship between them — a classic integration bug, and one nobody owns because each side passes its own review. The fix is the one you already reach for: make the shared quantity explicit. Here it is the step length, and once it is written down, both sides can be checked against it.

"The animation looks fine, so the walk is fine"

It looks fine in the preview because the preview does not move the character. Every walk cycle looks correct playing in place; the defect only appears once the animation and the movement speed are composed, which is in the game and usually late. This is why the step length is worth computing before the animation is drawn — it is a property of two numbers you already know, and it tells you how long the stride in your drawing has to be.

The companion course's own character slides

The godot2d course builds its character controller in lesson 3.4 with @export var speed := 220.0. Run that through the table above: at 220 px/s the cycle must finish inside 127ms to keep a 32-pixel character's feet planted, and no walk of four or more frames at a sensible duration does. That character should be running rather than walking, or be drawn taller. It is a good illustration that the number in the movement script is an animation decision that nobody labelled as one.

Lab 12 — a cycle against a speed
Set the speed and the timing · the step length and whether the feet can reach it are computed live
Step length is speed × frames × ms ÷ 2000. A 32px character has roughly 14px of leg to reach with.

The figure walks against a scrolling ground at the speed you set, with its computed step length shown beside it. When the step exceeds what the legs can reach, the planted foot is drawn in red and the ground slides under it.

Start at 60 px/s with 8 frames at 50ms and the feet hold. Raise the speed without touching the timing and watch the step length climb past the reach — the animation has not changed at all, and it is now wrong.

Try it, then answer

A walk cycle animates correctly in the preview but the feet visibly skate in the game. What has gone wrong?

Check your understanding

Your character moves at 200 px/s and is 32 pixels tall, and no walk cycle you try keeps the feet planted. What is the best response?

Unit 4 · Characters and animation — Lesson 13 of 20

Frame timing against a 60 Hz tick

Aseprite lets you set a frame's duration in whole milliseconds. A 60 Hz game advances in steps of 16.667 of them. Only six whole-millisecond values under a third of a second land exactly on that grid, and they are all multiples of fifty.

You'll be able to: Choose frame durations that land exactly on the engine's physics tick, and compute the drift when they do not.

Two clocks that do not divide

Your animation has a duration per frame, set in milliseconds. Your game advances in ticks — Godot's physics step defaults to 60 per second, which is one tick every 16.667 ms. Nothing forces those to agree, and when they do not, an animation frame is held for the wrong number of ticks and the error accumulates.

Aseprite stores frame durations as whole milliseconds. So the question is which whole numbers of milliseconds are an exact number of ticks. Between 1 and 300, there are exactly six:

DurationTicks
50 ms3
100 ms6
150 ms9
200 ms12
250 ms15
300 ms18

Every one is a multiple of 50 ms — which is to say a multiple of three ticks, because three ticks is the smallest whole number of ticks that is also a whole number of milliseconds. That is the practical rule this lesson exists to deliver: set frame durations in multiples of 50 ms and the two clocks agree forever.

What the near misses cost

The durations people reach for instead are the ones that look like round fractions of a second:

DurationTicksError per frame
33 ms1.980+0.33 ms
66 ms3.960+0.67 ms
83 ms4.980+0.33 ms
133 ms7.980+0.33 ms
166 ms9.960+0.67 ms

A third of a millisecond sounds like nothing. Over an eight-frame cycle at 80 ms per frame the drift is 26.7 ms — most of two ticks — and about two and a half seconds per minute of continuous animation. What you see is not a smooth error; it is a frame that is occasionally held one tick longer than its neighbours, so a walk cycle develops an intermittent limp that appears and disappears.

The shift from enterprise software

This is a clock-domain crossing, and the symptom is the one you already know. Two components each keep correct time by their own reckoning, the periods do not divide, and the artefact is not a steady error but an occasional discontinuity — a dropped sample, a duplicated row, a frame held twice. The fix is the same as it always is: make one clock a whole multiple of the other rather than trying to correct the drift after the fact.

"Set it to 12 frames per second, like traditional animation"

Twelve frames per second is 83.33 ms per frame, which Aseprite cannot express — you get 83, which is 4.98 ticks. The convention is inherited from film, where the projector ran at 24 fps and twelve meant holding each drawing for exactly two frames. That whole-number relationship is the entire point of the convention, and it does not survive being transplanted onto a 60 Hz clock. The nearest thing that keeps the spirit is 100 ms — ten frames per second, six ticks each, exact.

Build it — check a cycle against the tick
const TICK = 1000 / 60;

function driftOf(msPerFrame: number, frames: number) {
  const ticks = msPerFrame / TICK;
  const held = Math.round(ticks);                 // what the engine can do
  const perFrame = held * TICK - msPerFrame;      // what it costs
  return { ticks, exact: Number.isInteger(ticks), perCycle: perFrame * frames };
}

Run it over the durations already in your project. The output is a list of the animations that will develop a limp, ordered by how quickly.

Lab 13 — a duration against the tick grid
The grid is the engine's ticks · a frame that does not land on one is drawn where it actually falls
One tick is 16.667 ms. A frame boundary that does not land on one is held for the nearest whole number of ticks instead.

The row of marks is the engine's tick grid at 16.667 ms. Above it, your frame boundaries at the duration you choose.

At 50, 100, 150 or 200 ms every boundary lands on a mark and the two rows stay locked together forever. Move one step off and watch the boundaries walk gradually out of phase, then snap back — that snap is the limp.

The drift-per-minute figure is the one to take away. Anything above about a tenth of a second per minute is visible in a cycle a player watches continuously.

Try it, then answer

Why is 83 ms — the closest whole millisecond to twelve frames per second — a poor choice of frame duration for a 60 Hz game?

Check your understanding

You have inherited a project whose animations use 40 ms, 60 ms and 80 ms frame durations, and the animation occasionally stutters. What is the smallest change that fixes it?

Unit 4 · Characters and animation — Lesson 14 of 20

Anticipation, impact and the frames nobody sees

An attack that reads is not one with more frames. At 60 Hz a frame held for a single tick is on screen for 16.7 milliseconds, and a six-frame attack packed into a tenth of a second gives every frame exactly that — which is to say the player sees a blur and a result.

You'll be able to: Budget an attack across anticipation, impact and recovery, and compute how many refreshes each frame actually receives.

Three phases, and only one of them is the hit

A readable action has three parts, and the one everybody draws first is the least important of the three.

Anticipation is the wind-up: the character pulls back before striking. It tells the player what is about to happen, which is what makes an attack feel telegraphed rather than arbitrary — and in a game with a dodge button, it is the entire mechanism by which the player is given a chance.

Impact is one or two frames at the moment of contact, usually the most extreme drawing in the sequence, often paired with a flash and a freeze.

Recovery is the follow-through and the return to idle. Cutting it makes an attack feel like it was cancelled rather than completed.

The frames nobody sees

Here is the arithmetic that decides how many frames the sequence can usefully have. At 60 Hz the screen refreshes every 16.7 ms, so a frame's visibility is however many refreshes it survives:

Attack lengthFramesPer frameRefreshes each
100 ms616.7 ms1.0
200 ms633.3 ms2.0
300 ms650.0 ms3.0
200 ms1216.7 ms1.0
400 ms1233.3 ms2.0

A frame that gets one refresh is, for practical purposes, not seen — it contributes to a sense of motion and communicates nothing on its own. So the two one-refresh rows are the same animation as far as the player is concerned: a blur. Doubling from six frames to twelve at a fixed 200 ms bought nothing at all, because it halved the time each frame was visible.

The consequence is that frame count and duration are one decision, not two. If an attack must complete in 200 ms for the game to feel responsive, it has room for six frames at two refreshes each, and drawing twelve is wasted work that makes the result no clearer.

The shift from enterprise software

This is sampling, and you would not make the mistake in a domain where the signal was a number. Doubling the resolution of a log while halving how long each entry is retained does not give you more information; it gives you the same information in smaller pieces, and past a point the pieces are too small to read individually. Animation frames are samples of a motion, and the display's refresh rate is a hard ceiling on how finely the player can receive them.

"More frames make an animation smoother, so more is better"

More frames make an animation smoother only when the extra frames get screen time to be smooth in. At a fixed action length, every frame you add subtracts visibility from all the others, and below about two refreshes each the sequence stops reading as a sequence. The way to spend an animation budget usefully is to lengthen the action or to move frames from the parts that do not communicate — almost always the recovery — into the anticipation, where they do.

How to divide the three phases — the course's own reading

The arithmetic gives you the total number of usable frames; it says nothing about how to allocate them, and that is judgement. What has served this course: roughly half the sequence in anticipation, one or two frames of impact, and the remainder in recovery. That feels wrong when written down — half the animation happens before anything is hit — and it is what makes an attack readable. The reliable smell is that an attack that feels weak almost always has too little anticipation and too many recovery frames.

Lab 14 — the same attack, budgeted two ways
Two sequences of identical length · one spends its frames on anticipation, the other distributes them evenly
A frame needs about two refreshes before it registers as a pose rather than as motion.

Two versions of one attack, running at the length you set. The upper spends half its frames winding up; the lower distributes them evenly.

The readout under each is refreshes per frame. Drag the length down toward 100 ms and watch both fall to 1.0 — at which point the two are indistinguishable, and no amount of redrawing will separate them.

Then raise the frame count at a fixed length. The bars get thinner and the refresh figure drops. That is the trade this lesson is about: at a fixed length, frames and visibility are the same budget spent twice.

Try it, then answer

Why does doubling an attack from six frames to twelve, without changing its 200 ms duration, fail to make it read more clearly?

Check your understanding

Your attack animation has twelve frames over 300 ms and players say it feels weak and hard to react to. Which change is most likely to help?

Unit 5 · The world — Lesson 15 of 20

Tiles that tile

Nearly a quarter of a 16-pixel tile is border, and every border pixel is judged twice — once on its own and once against whatever sits next to it. Then there is the second problem, which is that a wall built from one tile announces itself, and the obvious fix does not work as well as you expect.

You'll be able to: Author a tile whose borders wrap, and compute how many variants repetition actually needs.

A quarter of the tile is seam

A tile is drawn once and placed hundreds of times, so its edges carry a requirement no other artwork has: the right-hand column must continue into the left-hand column of the copy beside it, and the same top to bottom. Get it wrong and a grid appears across your level that no amount of good interior work hides.

The proportion of a tile subject to that requirement is larger than it feels:

Tile sizePixelsOn the borderShare
8×8642844%
16×162566023%
24×245769216%
32×321,02412412%
48×482,3041888%

At the 16×16 this course targets, 23% of the tile is border, and each of those pixels is doing two jobs — reading correctly in place, and reading correctly against its neighbour. This is the same arithmetic as lesson 3's silhouette finding, and it produces the same advice in a different domain: at small sizes the edges are most of the work.

The other repetition, and why more variants disappoints

A seamless tile still repeats. Place one tile across a field and the eye finds the period immediately — not from the seams, which are now invisible, but from the content: a distinctive crack or highlight appearing on a perfect grid.

The obvious fix is more variants, and it works far less well than intuition suggests. With V equally likely variants placed at random, a cell has four neighbours, so the chance at least one of them is the identical tile is 1 − (1 − 1/V)⁴:

VariantsA cell with an identical neighbourIdentical pairs in an 8×8 field
1100%112
294%56
380%37
468%28
841%14
1623%7

Four variants — already four times the work — still leave two thirds of your tiles touching a copy of themselves. Sixteen variants gets you to 23%, at sixteen times the drawing.

So variants are not the answer; they are a small part of it. What actually breaks up a tiled field is that most of the tile should be quiet, with the distinctive content moved out into sparse decoration placed on top — a crack here, a tuft there, at a density you control rather than one the grid dictates. Three or four quiet variants plus a handful of scattered details beats sixteen busy variants and costs less.

The shift from enterprise software

The variant table is a birthday problem, and it disappoints for the reason birthday problems always disappoint: collisions scale with the number of pairs, not with the number of cells. Four variants feels like it should quarter the repetition and instead removes a third of it, in the same way that a four-character suffix does not make your identifiers feel unique. The fix in both cases is not more entropy in the same place — it is putting the variation somewhere the collisions are not being counted.

"A seamless tile is a tile that repeats invisibly"

Seamlessness and non-repetition are two different properties and only the first is about edges. A perfectly seamless tile with a bright highlight in it produces an unmistakable grid of highlights; a tile with a visible seam but a completely flat interior often reads fine at a distance. Checking your tile by placing four copies in a square catches the seam problem and hides the repetition problem, because four copies is too few to show a period. Tile it eight by eight.

Where to spend the quiet — the course's own reading

The arithmetic says keep the base tile quiet and move the interest to sparse decoration. It does not say how quiet, and the judgement this course has settled on: if you can identify an individual tile in an 8×8 field of them without looking for it, the tile is too busy. The test is deliberately about a large field rather than a small one, because a tile always looks characterless on its own and that is usually correct.

Lab 15 — one tile, tiled
Draw on the tile at left · the field at right is it repeated, with seam mismatches counted
Drag on the tile to draw. Seam mismatches count border pixels that disagree with the pixel they will sit against.

Draw on the tile. The field beside it is the same tile repeated, so anything you add appears on a grid immediately.

The seam count is the number of border pixels that do not agree with the pixel they will sit against when tiled. Getting it to zero is what "seamless" means, and it is a mechanical check rather than a matter of eye.

Then raise the variant count and watch the repetition figure fall much more slowly than you would like — the table above, happening.

Try it, then answer

Why does going from one tile variant to four remove much less repetition than it seems it should?

Check your understanding

Your stone wall tiles seamlessly, but the wall still reads as an obvious grid. What is the most effective fix?

Unit 5 · The world — Lesson 16 of 20

Autotiling, and why the number is 47

Godot's terrain painting hands you a tile set and expects you to fill it in. The number of tiles it wants looks arbitrary until you count what it is distinguishing — at which point 256 possibilities collapse to exactly 47, and you can say why each one exists.

You'll be able to: Derive the size of a blob terrain set from the neighbour configurations it must distinguish.

Counting what a tile has to know

A terrain tile has to look right against its surroundings, so what it needs to know is which of its neighbours are also terrain. There are eight neighbours — four edges and four corners — so there are 28=256 possible surroundings.

Drawing 256 tiles would be absurd, and you do not have to, because most of those 256 are indistinguishable from each other. The reason is a small piece of geometry: a corner is only visible when both of its adjacent edges are filled. If the tile to the north is empty, it makes no difference whether the north-east diagonal is filled or not — the corner is not part of the terrain's outline there, and nothing you could draw would differ.

Collapse every configuration by that rule and count what is left:

Raw neighbour configurations256
Distinct tiles once invisible corners are collapsed47
Edge-only variant, ignoring corners entirely16

Forty-seven is not a convention someone settled on. It is what you get, and the collapse is uneven in a way worth seeing:

Configurations servedTiles
116
416
88
167

Sixteen tiles do exactly one job each — those are the fully-surrounded and near-surrounded cases where every corner matters. Seven tiles each cover sixteen different situations — those are the sparse cases, where so few edges are filled that no corner can show at all. The seven doing the most work are the ones you draw first.

The shift from enterprise software

You have written this collapse. It is a state machine whose specification lists 256 input combinations, most of which are unreachable or equivalent, and the useful first move is always to quotient the state space by what actually distinguishes behaviour before implementing anything. Doing it here saves 209 drawings. Not doing it is why some tilesets ship with a hundred tiles and gaps in the middle.

"Sixteen tiles is enough for terrain"

Sixteen is the edge-only system, and it is genuinely enough for terrain that never forms an inside corner — a platform, a strip, a wall. The moment two arms of terrain meet, the edge-only set has nothing to draw: the cell knows its neighbours to the north and west are filled, but not whether the north-west diagonal is, and those two cases need different art. That is exactly the notch you see in tilesets built on sixteen tiles. If your terrain has enclosed regions, you need the 47.

Which of the 47 to draw first — the course's own reading

The counting says nothing about drawing order, and the order this course has used: the seven sixteen-configuration tiles first, because they cover the sparse cases and let you paint anything immediately; then the fully-enclosed interior tile, which covers most of the visible area of a filled region; then the edges; and the sixteen single-configuration corner cases last, because most levels contain very few of them and some contain none at all.

Build it — collapse the space yourself
const N=1, NE=2, E=4, SE=8, S=16, SW=32, W=64, NW=128;

/** Clear each corner bit whose two adjacent edges are not both filled. */
function canonical(mask: number): number {
  let r = mask;
  if (!((mask & N) && (mask & E))) r &= ~NE;
  if (!((mask & E) && (mask & S))) r &= ~SE;
  if (!((mask & S) && (mask & W))) r &= ~SW;
  if (!((mask & W) && (mask & N))) r &= ~NW;
  return r;
}

const distinct = new Set<number>();
for (let m = 0; m < 256; m++) distinct.add(canonical(m));
console.log(distinct.size);        // 47

Ten lines, and it answers a question that is usually met with "because that is how many there are". Run it before you start drawing, and use the canonical mask as the filename of each tile so the lookup at runtime is a table rather than a pile of conditionals.

Lab 16 — 256 configurations, 47 tiles
Click the eight neighbours · the tile below is the one that gets drawn, and the count is how many configurations share it
Click a neighbour to toggle it. A corner drawn hollow is one the tile cannot see, because its two adjacent edges are not both filled.

The centre cell is the tile being decided. Click any of the eight neighbours to fill or clear it, and watch the canonical mask below update.

Turn off the north neighbour while the north-east corner is filled and notice that the mask does not change — that corner has become invisible, which is the entire collapse in one interaction.

The counter tracks how many of the 256 configurations map to the tile currently shown. Sparse arrangements land on tiles shared by sixteen configurations; nearly-surrounded ones land on tiles that serve exactly one.

Try it, then answer

Why do 256 possible neighbour configurations require only 47 distinct tiles?

Check your understanding

You have built your terrain on a 16-tile edge-only set, and it looks correct everywhere except where two arms of ground meet, which shows an ugly notch. What is happening?

Unit 5 · The world — Lesson 17 of 20

Backgrounds, layers and the depth budget

Parallax is usually explained as a scroll-speed trick, and the scroll speed is the easy half. The half that decides whether depth reads is value: each layer needs its own band of lightness, the palette has a finite range, and dividing it gives you a hard number of layers.

You'll be able to: Assign each parallax layer a lightness band, and compute how many layers a palette can actually separate.

Scroll speed is the easy half

A parallax background moves its layers at different rates — distant layers slower, near layers faster — and Godot's Parallax2D node reduces that to a scroll factor per layer. Setting those factors is five minutes of work, and doing only that produces a background that moves convincingly and still reads flat.

What makes depth read is atmospheric perspective: distant things are lower in contrast and closer in value to the sky, because there is more air between them and the viewer. In a photograph this happens for free. In a sixteen-colour palette you have to allocate it, and allocation means arithmetic.

The depth budget

Each layer needs an internal range of lightness — otherwise it is a silhouette with no form — and it needs a gap from its neighbours, or the layers merge. Those two requirements consume the palette's total lightness range, which for the palette built in lesson 9 measures 0.16 to 0.84, a span of 0.68.

Span per layerGap between layersLayers that fit
0.150.083
0.120.064
0.100.054
0.080.046
0.060.037

Four layers is the practical answer for a palette like this one: enough internal range in each that the layer has form, enough separation that the eye assigns them to different distances. Going to six or seven means each layer has a span of 0.08 — roughly one ramp step — so it can be a shape but not a shaded shape.

This is why background art in games you admire tends to have three or four distinct planes rather than a smooth recession. It is not restraint; it is what the palette affords.

The shift from enterprise software

This is capacity planning against a fixed resource, and the shape of the answer is the familiar one. You have 0.68 of range, each consumer needs an allocation plus headroom to avoid interfering with its neighbours, and the number of consumers falls out of the division. The failure mode is familiar too: over-subscribing produces a system where everything technically has an allocation and nothing has enough to do its job, which here looks like six background planes that all read as the same grey distance.

"Parallax is about the scroll speeds"

The scroll speeds tell the player that layers are at different distances once they are already distinguishable. If two layers occupy the same value band, no amount of differential scrolling separates them — they read as one plane with a strange shimmer, which is the most common way a first parallax background fails. Set the value bands first, confirm the layers are separable in a still frame, and only then set the scroll factors.

Which way round the bands go — the course's own reading

The arithmetic gives you the bands; which layer takes which is judgement and depends on the scene. The convention this course has used is the daylight one: distant layers sit high in the range and low in contrast, approaching the sky, while the play layer takes the dark end so the player and the things they can touch are the highest-contrast objects on screen. At night this inverts — distance becomes darker, not lighter — and the one thing that does not change is that the play layer keeps the widest band, because it is the layer that has to show form.

Lab 17 — layers against a value budget
Set the layer count and how much range each one gets · overlapping bands are drawn in red
The range is the lesson 9 palette's measured span, 0.16 to 0.84. Each layer needs a band plus a gap of half its span.

The bar is the palette's lightness range from lesson 9. Each layer claims a band of it, with a gap either side.

Raise the layer count and watch the bands tighten, then collide. Once two bands overlap, the scene beside it shows what the player sees: the two layers stop being at different distances and become one plane.

Four layers at a span of 0.12 is the configuration the lesson recommends, and it is worth setting the sliders there and then trying to add a fifth.

Try it, then answer

Why can a parallax background with six layers read as less deep than one with four?

Check your understanding

Your three-layer background reads as flat despite correct scroll factors. What should you check first?

Unit 6 · Shipping — Lesson 18 of 20

Effects, light, and the palette under a dimmer

Lighting a 2D scene is a multiplication, and multiplying a palette does not preserve the thing the palette was built for. Every separation you carefully arranged in unit 3 scales down with the light, and at a convincing night setting it is a fifth of what you designed.

You'll be able to: Compute what darkening a scene does to a palette's value separation, and budget effect frames against the refresh rate.

Effects are frames, and you already know their budget

A hit flash, a dust puff, an impact spark — these are animations, so lesson 14's arithmetic applies unchanged. At 60 Hz a frame needs about two refreshes to register as anything, which is 33 ms. A "one-frame flash" set to a single tick is 16.7 ms and reads as a glitch rather than as a hit; two ticks reads as a flash.

The one thing effects do differently is that they are usually drawn from the existing ramps rather than from new colours, because a spark in a colour that appears nowhere else on screen is the fastest way to make an image incoherent. The rule from unit 3 holds: the palette is the set of decisions, and an effect is spending them, not adding to them.

Lighting is a multiplication, and it costs you separation

Godot darkens a 2D scene with a CanvasModulate — every pixel multiplied toward black — and lights then raise regions back up. It is a clean model and it has a consequence for your palette that is easy to miss.

Take the sixteen colours from lesson 9 and apply a darkening factor. The figures below are measured from the palette's shipped hex values, which round slightly from the construction lesson 9 quotes — 8-bit rounding costs about 0.0007 of separation before the lighting touches it:

FactorLightness rangeDistinct levelsClosest pair
1.00.16 to 0.84160.0565
0.80.13 to 0.67160.0452
0.60.10 to 0.50160.0339
0.50.08 to 0.42160.0282
0.40.06 to 0.34160.0226
0.20.03 to 0.17150.0113

The separation scales with the light. At a factor of 0.2 — a convincing night — the closest pair is 0.0113, a fifth of what it was, and two colours have merged into the same measured level. Every distinction you spent lesson 9 arranging is compressed by exactly the amount you dimmed the scene.

This is why a palette that looks well-separated in the editor produces a night scene that reads as mud. The palette was audited at full brightness and the game never displays it that way.

The shift from enterprise software

You are auditing a system under conditions it never runs in. The palette check in lesson 9 is a test against the unlit case, the game ships lit, and the property being tested — separation — is not preserved by the transformation in between. It is the shape of every bug found only in production: the invariant held where it was measured and the measurement was taken in the wrong environment. The fix is the same one too. Audit the palette through the lighting you actually ship.

"Add lights to make the scene look better"

Lights subtract before they add. A CanvasModulate that darkens to 40% has taken away more than half your value range before a single light is placed, and the lights give it back only where they fall. A scene lit this way needs a palette with more separation than an unlit one, not the same palette with lamps on it — which usually means fewer, wider-spaced ramps rather than more colours.

How dark to go — the course's own reading

The table says what darkening costs; it does not say how much is right, and that is judgement. The pattern this course has followed: pick the darkest the scene will ever be, audit the palette at that factor, and treat the closest-pair figure there as the real one. If it falls below roughly 0.03 the scene will read as mud no matter how good the individual sprites are. The number 0.03 is this course's own working threshold rather than a measured constant, and lesson 20 says more about which of these figures are which.

Lab 18 — the palette under a dimmer
Drag the light down and watch the audit from lesson 9 degrade
The two swatches ringed in red are the closest pair — the first two colours that will become hard to tell apart.

The palette from lesson 9, shown at the darkening factor you choose, with the same audit that lesson ran.

Drag toward a night setting and watch the closest-pair figure fall. The swatches stay sixteen distinct colours the whole way — nothing is lost in the file — and they become progressively harder to tell apart on screen, which is the only place that matters.

The pair the audit is measuring is highlighted, so you can see which two colours are about to merge and decide whether you mind.

Try it, then answer

Why can a palette that passes the lesson 9 audit still produce a muddy night scene?

Check your understanding

Your cave level uses a CanvasModulate at 0.35 and everything reads as brown sludge. What is the most direct fix?

Unit 6 · Shipping — Lesson 19 of 20

Interface, type, and the art that sells it

A HUD is drawn in the same sixteen colours as everything else and read under worse conditions. Type has a hard floor set by the screen width, and the art a storefront wants is almost never an integer multiple of your canvas — which makes it the one thing in the project you draw at a different size.

You'll be able to: Size a pixel font from the characters a 320-pixel screen must fit, and identify which store images your base resolution cannot produce.

Type, sized by what has to fit

A pixel font is described by its glyph box: a 5×7 font has glyphs seven pixels tall in a box five wide, advancing six pixels including the gap. The choice looks aesthetic and is mostly determined by how many characters a line has to hold on a 320-pixel screen:

GlyphCharacters per lineLines down 180px
3×58025
4×66422
5×75320
6×84518
8×103515

Three by five is the smallest that can hold a distinguishable alphabet — below it, letters that differ by one pixel start colliding — and eighty characters is a generous line. Five by seven is comfortable to read and still fits a fifty-character line, which is a sentence. Anything larger is a display face for titles rather than a body face for dialogue.

The practical decision is to pick the smallest face your longest necessary string fits into, and then never use a second body face. Two pixel fonts on one screen almost always read as an accident.

The HUD reads under worse conditions than anything else

Everything unit 3 established about value applies to the interface with less margin, because the interface has to be legible over whatever the game is doing underneath it. A health bar sitting on grass, stone and sky in the same level cannot rely on contrast against any one of them.

The reliable moves are the ones that do not depend on the background: a hard outline or a solid plate behind the element, values taken from the extreme ends of the palette rather than the middle, and — the one people skip — checking it against the brightest and darkest background in the game rather than against the one on screen when you drew it.

The art you cannot scale up

Everything else in the project is authored at 320×180 and blown up by a whole number. Store art is the exception, and the arithmetic says so:

TargetResult
640×360exactly 2×
960×540exactly 3×
1280×720exactly 4×
1920×1080exactly 6×
630×500not an integer multiple
460×215not an integer multiple

Storefront images are sized by the storefront, and the sizes they ask for are chosen for layout rather than for your canvas. A 630×500 cover is not 320×180 times anything, so scaling your screenshot into it produces exactly the blurred, invented-colour result lesson 2 measured at 1,327 colours — on the single image that most determines whether anyone plays the game.

So capsule and cover art is drawn at its own size, as its own asset, using the same palette and the same discipline. It is the one place in the project where you author at a resolution the game never renders.

The shift from enterprise software

This is the asset that does not go through the pipeline, and every project has one. The whole system is built on the invariant that art is authored at 320×180 and scaled by integers, and the store image violates it — not through an oversight but because an external party owns the requirement. The failure mode is also familiar: because it is the only exception, it is the thing that gets generated by the quickest available method at the last minute, which here means a blurry upscaled screenshot.

"The store image can just be a screenshot"

A screenshot is 320×180, and the sizes storefronts want are neither that nor a whole multiple of it, so a screenshot has to be resampled to get there. That is the one operation this entire course exists to prevent. Beyond the arithmetic, a screenshot is composed by whatever the player happened to be doing, whereas a capsule has one job — communicating what the game is in the width of a browser tile — and wants a deliberate composition at its own scale.

What to put on the capsule — the course's own reading

The arithmetic says author it separately. What goes on it is judgement, and the pattern this course has followed: the silhouette test from lesson 3, applied to the whole image. A capsule is seen at thumbnail size beside a hundred others, so the question is whether its shape and value structure are distinguishable at that size — not whether the detail is good. Most first capsules fail because they are a busy scene rather than one readable shape.

Lab 19 — a HUD over the worst background it will meet
The same interface drawn over the lightest and darkest backgrounds in the palette
Worst case is the lowest lightness difference between the interface and any background colour in the palette.

A health bar and a score readout, drawn in the palette, shown over each of the palette's sixteen colours in turn.

Switch the treatment between plain, outlined and plated, and watch the worst-case contrast figure. Plain type has a background somewhere in the game that destroys it; an outline fixes the worst case at the cost of two pixels of weight around every glyph.

The font size control shows what fits: drag it up and watch the characters-per- line figure fall below what a sentence needs.

Try it, then answer

Why does interface art need more contrast discipline than sprite art?

Check your understanding

You need a 630×500 cover image for your itch.io page. What should you do?

Unit 6 · Shipping — Lesson 20 of 20

Where this goes next

This course has quoted a lot of numbers. Some are properties of arithmetic or of the sRGB gamut and will be true wherever you check them. Others are thresholds it chose, which sound identical in a table and are not. Here is which is which, and what the course did not cover at all.

You'll be able to: Separate the figures in this course that are measurements from the ones that are its own assumptions, and name what it did not teach you.

Which numbers are which

Everything quoted here was computed rather than remembered, and its labs recompute it at runtime so the prose cannot drift. That says nothing about whether a number is a fact about the world or a decision this course made, and the two have been sitting in the same tables.

Measurements. These follow from arithmetic or from the sRGB gamut. Check them anywhere and you will get the same answer:

  • 47 distinct tiles from 256 neighbour configurations
  • 320×180 landing exactly on 720p, 1080p, 1440p and 4K
  • Coverage anti-aliasing needing dx/gcd(dx,dy) − 1 part-tones, and 30 of them for every slope in a 32-pixel sprite because 31 is prime
  • Bilinear scaling turning 16 colours into 1,327 at 6×
  • Only multiples of 50 ms landing exactly on a 60 Hz tick
  • Yellow and blue at the same HSL lightness measuring 0.968 and 0.452
  • A 60° hue rotation buying 2% of step separation and 1.31× the chroma
  • 22% of a 16-pixel disc's pixels lying on its edge
  • Four variants leaving 68% of tiles touching a twin

This course's own thresholds. These are choices. They are reasonable, they have served the work, and they are not measurements of anything:

ThresholdUsed inWhat it actually is
A face needs 5px of headLesson 11A judgement about the smallest legible eye-gap-mouth
A 32px character has 14px of legLesson 12An assumption about proportion, used to define "reachable"
A frame needs 2 refreshes to registerLesson 14A rule of thumb, not a perceptual constant
Below ΔE 0.03 a scene reads as mudLesson 18This course's working threshold
Interior runs must all be equalLesson 4A deliberately strict rule that calls legitimate patterns defects
12% of outline changed means it readsLesson 3A number chosen to make the lab's verdict useful

If you disagree with one of those, you are not disagreeing with a measurement, and the labs will happily show you the consequences of a different value.

There is a third category, and it is the largest: everything about taste. Which four materials get ramps, which feature to exaggerate, how much to dither, where to spend anti-aliasing, how dark a night should be. Those appear throughout marked as the course's own reading, and they are the parts most worth disagreeing with.

What this course does not have

It has no citation gate. The eight other courses in this collection check their excerpts against a pinned source; this one cannot, because Aseprite publishes no open licence and there is nothing to pin. What stands in for it is that every figure is computed by the lesson's own lab, so prose and lab cannot drift — a discipline that caught six errors while this course was being written, including one where a tidy result turned out to be an artefact of colours pushed outside the displayable range.

That is a real gate and it is a narrower one. It proves the prose matches the computation. It cannot prove the computation was the right thing to compute, and three times during writing it was not: a circle metric that measured nothing, an error measured per pixel where the eye integrates locally, and a tiling test whose inputs produced no residual error to detect. Each looked like a working measurement and answered the wrong question.

Perceptual distance deserves the same caveat. OKLab is a model of perception, and a good one, but a ΔE of 0.057 is a number about a colour space rather than about your eye. It is used here because it is far better than the alternatives and because it can be computed — not because it is truth.

What it did not cover

An honest list, roughly in the order you will want them:

  • Faces and expressions at portrait sizes, which is a different craft from 32-pixel characters and shares almost no technique with it.
  • Idle, jump, hurt and death animations. The course covers a walk and an attack; a shipping character needs six or seven cycles.
  • Isometric authoring beyond the projection arithmetic in lesson 5 — tile shapes, stacking order, and the depth sorting that goes with them.
  • Shader work. 2D shaders are how hit flashes, dissolves and palette swaps are actually implemented at runtime, and godot2d lesson 9.4 covers them from the engine side.
  • Normal maps and engine lighting. Lesson 18 treats light as a multiplication, which is the model, not the tooling.
  • Level and encounter design. Making art for a level is not designing one.
  • Colour-blind safe palettes. Everything in unit 3 assumes typical colour vision. A palette separated only by hue fails for roughly one man in twelve, and the fix — separating by value, which this course already argues for — is necessary but not sufficient.
  • Atlasing, import pipelines and asset naming at the scale where a project has thousands of files.
  • Working with an artist, which is what most shipping games do, and where the useful skill is specifying and reviewing rather than drawing.
The one that matters most

If you take one thing further, take the colour-blind case. It is the only item on that list that makes a game unplayable rather than less good, it is cheap to check, and unit 3 has already done most of the work — a palette whose ramps are separated by measured value rather than by hue degrades gracefully. Audit yours by desaturating it, which is lesson 7's test, and by simulating the common deficiencies, which is a tool rather than a skill.

"I have finished the course, so I can make the art now"

You can make defensible decisions now, which is a different and more durable thing. Every objective test here — silhouette, run lengths, palette audit, step length, tick alignment — tells you whether something is wrong. None of them tells you whether it is good, and the gap between those closes with drawing rather than with reading. The value of the checks is that they stop you spending that drawing time on problems that were arithmetic all along.

Lab 20 — audit your own palette
Paste your hex list · the checks from lessons 7, 9 and 18, run against your colours
Paste any hex list. The audit is the one from lesson 9, run at the light level from lesson 18.

The tool the course has been building toward, pointed at whatever you have made.

Paste your palette as hex values. The audit reports what lesson 9 measured — distinct lightness levels, the closest pair, the range covered — and the darkening slider from lesson 18 shows what those figures become in the darkest scene you intend to ship.

A palette that holds up at your darkest setting is one you can light. That is the last objective check this course has to offer, and everything after it is drawing.

Try it, then answer

Which of these figures from the course is a decision it made rather than a measurement?

Check your understanding

You are about to start your own game's art and want to spend your first hour well. What does this course suggest?