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.
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:
| Base | Exact fits | Worst wasted area at max integer scale |
|---|---|---|
| 320×180 | 4 of 8 | 26% |
| 256×144 | 3 of 8 | 26% |
| 640×360 | 4 of 8 | 48% |
| 384×216 | 2 of 8 | 40% |
| 480×270 | 2 of 8 | 49% |
| 320×240 | 0 of 8 | 44% |
| 160×144 | 0 of 8 | 53% |
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:
| Display | Result | |
|---|---|---|
| 1280×720 | 720p | exactly 4× |
| 1920×1080 | 1080p | exactly 6× |
| 2560×1440 | 1440p | exactly 8× |
| 3840×2160 | 4K | exactly 12× |
| 1280×800 | Steam Deck | 4×, with 80px letterboxed |
| 3440×1440 | ultrawide | 8×, 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.
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.
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.
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.
Why does scaling a 320×180 game by 2.5× to fill a screen look worse than scaling it by 2× and letterboxing?
You are targeting the Steam Deck at 1280×800 as your primary platform. What is the most sensible base resolution choice?
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:
| Scaling | Distinct colours in the result |
|---|---|
| The source, 16×16 | 16 |
| Bilinear, 2× | 188 |
| Bilinear, 3× | 237 |
| Bilinear, 6× | 1,327 |
| Bilinear, 8× | 2,246 |
| Nearest, any scale | 16 |
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.
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.
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.
/** 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.
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.
Why can nearest-neighbour scaling never increase the number of colours in an image?
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?
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 size | Lit pixels | On the edge | Share |
|---|---|---|---|
| 8px across | 49 | 20 | 41% |
| 16px | 197 | 44 | 22% |
| 32px | 797 | 88 | 11% |
| 48px | 1,793 | 132 | 7% |
| 64px | 3,209 | 180 | 6% |
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 pixels | On the edge |
|---|---|
| Disc, 16px across | 22% |
| Square, 14×14 | 27% |
| A limb or tail, 30×6 | 38% |
| A thin tail, 30×2 | 100% |
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 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.
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.
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.
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.
Why does the silhouette matter more for a 16-pixel sprite than for a 64-pixel one?
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?
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:
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.
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.
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 line | Runs | Verdict |
|---|---|---|
| 15 | 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 | clean — the classic 2:1 |
| 3 | 6 10 10 6 | clean |
| 5 | 4 6 6 6 6 4 | clean |
| 6 | 3 5 5 6 5 5 3 | one break |
| 7 | 3 4 5 4 4 5 4 3 | two breaks |
| 11 | 2 3 3 2 3 3 3 3 2 3 3 2 | two breaks |
| 12 | 2 2 3 3 2 3 2 3 2 3 3 2 2 | five 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 length | Rises clean, of 32 |
|---|---|
| 16px | 25 |
| 24px | 17 |
| 32px | 10 |
| 48px | 9 |
| 64px | 11 |
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.
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.
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.
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.
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?
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?
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:
| Radius | Quadrant runs, top row first | Reads as |
|---|---|---|
| 8 | 3 2 2 1 1 1 1 1 1 | a smooth arc |
| 16 | 4 3 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 | a smooth arc |
| 13 | 4 3 1 2 1 1 1 1 1 1 1 1 1 1 | a dent at the shoulder |
| 15 | 4 3 2 1 2 1 1 1 1 1 1 1 1 1 1 1 | a 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.
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 | |
|---|---|
| Smooth | 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 17, 19, 22, 24 |
| Lumpy | 13, 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 pixels long:
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:
| Projection | Slope | Angle | Run length |
|---|---|---|---|
| True isometric | 0.5774 | 30.00° | 1.7321 |
| The 2:1 "isometric" everyone uses | 0.5000 | 26.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.
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.
/** 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.
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.
Why does the lesson-4 rule — every interior run the same length — have to be replaced for curves rather than just loosened?
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?
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 and rise , 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:
That formula has a consequence worth sitting with. A sprite 32 pixels wide gives a line a run of — and 31 is prime. Its only divisors are 1 and itself, so for every rise, and the count is 30 no matter what angle you draw:
| Sprite width | dx | Cheapest slope available | Part-tones it needs |
|---|---|---|---|
| 16px | 15 | rise 5 | 2 |
| 17px | 16 | rise 8 | 1 |
| 24px | 23 (prime) | rise 1 | 22 |
| 32px | 31 (prime) | rise 1 | 30 |
| 33px | 32 | rise 16 | 1 |
| 48px | 47 (prime) | rise 1 | 46 |
| 65px | 64 | rise 32 | 1 |
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.
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.
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.
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.
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.
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.
Why does a sprite 33 pixels wide need dramatically fewer anti-aliasing tones than one 32 pixels wide?
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?
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 lightness | Colour | Measured lightness | Step |
|---|---|---|---|
| 20% | #143352 | 0.315 | — |
| 35% | #24598F | 0.457 | 0.142 |
| 50% | #3380CC | 0.588 | 0.132 |
| 65% | #70A6DB | 0.708 | 0.120 |
| 80% | #ADCCEB | 0.833 | 0.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:
| Hue | Colour | Measured lightness | |
|---|---|---|---|
| 60° | yellow | #FFFF00 | 0.968 |
| 180° | cyan | #00FFFF | 0.905 |
| 120° | green | #00FF00 | 0.866 |
| 300° | magenta | #FF00FF | 0.702 |
| 0° | red | #FF0000 | 0.628 |
| 240° | blue | #0000FF | 0.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.
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.
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.
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.
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.
Why does a yellow and a blue at the same HSL lightness look so different, when the picker reports one number for both?
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?
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 rotation | Mean distance between adjacent steps | Against the flat ramp |
|---|---|---|
| none | 0.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.
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:
| Lightness | Widest hue | Chroma available there | Chroma at a fixed blue (250°) |
|---|---|---|---|
| 0.30 | 266° | 0.204 | 0.085 |
| 0.50 | 284° | 0.294 | 0.142 |
| 0.70 | 328° | 0.322 | 0.164 |
| 0.90 | 134° | 0.250 | 0.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:
| Strategy | Hues | Usable chroma | The ramp |
|---|---|---|---|
| Fixed blue | 250° throughout | 0.083 | #113C64 #37618B #5984B0 #7CA9D7 #A1CFFF |
| Fixed orange | 60° throughout | 0.082 | #592E00 #7F522A #A5744C #CB9970 #F3BE94 |
| Purple to gold | 285° 320° 355° 25° 60° | 0.109 | #372E70 #784783 #B36383 #E18982 #FFBA80 |
| Chasing maximum | 266° 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.
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.
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.
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.
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.
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?
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?
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:
| Construction | Distinct lightness levels | Closest pair in the palette |
|---|---|---|
| Aligned — every ramp on the same four values | 4 of 16 | 0.0045 |
| Staggered — each ramp offset by a quarter step | 16 of 16 | 0.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.
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:
| Ramp | Colours | Lightness |
|---|---|---|
| Skin / warm | #0F042B #4C2A4D #8E5765 #C8907C | 0.16 0.34 0.52 0.70 |
| Stone / neutral | #0E172B #464059 #856E84 #C5A2AC | 0.21 0.39 0.57 0.75 |
| Foliage | #10262A #3F5651 #78897B #B9BEAA | 0.25 0.43 0.61 0.79 |
| Sky / water | #2F1E60 #365C9B #379DC8 #62E0E6 | 0.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.
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.
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.
/** 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.
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.
Why is a sixteen-colour palette whose ramps all share four lightness values worse than one whose values interleave, given both contain sixteen colours?
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?
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:
| Matrix | Thresholds | Apparent levels between two colours |
|---|---|---|
| 2×2 | 4 | 5 |
| 4×4 | 16 | 17 |
| 8×8 | 64 | 65 |
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:
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:
| Method | Error per pixel | Over 4×4 | Over 8×8 |
|---|---|---|---|
| Nearest colour | 0.2490 | 0.2490 | 0.2490 |
| Ordered, 4×4 Bayer | 0.3307 | 0.0159 | 0.0112 |
| Floyd–Steinberg | 0.3308 | 0.0284 | 0.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.
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:
| Method | Pixels of the tile that differ |
|---|---|
| Ordered, 4×4 Bayer | 0.0% |
| Floyd–Steinberg | 100.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.
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.
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.
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.
Ordered dithering is less accurate than Floyd–Steinberg over an 8×8 window, yet pixel art uses it almost exclusively. Why?
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?
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:
| Proportion | 16px | 24px | 32px | 48px | 64px |
|---|---|---|---|---|---|
| Realistic, 7.5 heads | 2px | 3px | 4px | 6px | 9px |
| Heroic, 6 heads | 3px | 4px | 5px | 8px | 11px |
| Stylised, 4 heads | 4px | 6px | 8px | 12px | 16px |
| Chibi, 3 heads | 5px | 8px | 11px | 16px | 21px |
| Super-deformed, 2 heads | 8px | 12px | 16px | 24px | 32px |
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 height | Largest head ratio that still gives a 5px head |
|---|---|
| 16px | 3.2 heads |
| 24px | 4.8 heads |
| 32px | 6.4 heads |
| 48px | 9.6 heads |
| 64px | 12.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.
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.
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.
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.
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.
Why do pixel-art characters at small sizes almost always have exaggerated head proportions?
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?
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:
| Speed | Cycle | Distance per cycle | Step length |
|---|---|---|---|
| 60 px/s | 8 frames × 100ms = 800ms | 48.0px | 24.0px |
| 60 px/s | 8 × 50ms = 400ms | 24.0px | 12.0px |
| 120 px/s | 8 × 100ms = 800ms | 96.0px | 48.0px |
| 120 px/s | 4 × 50ms = 200ms | 24.0px | 12.0px |
| 220 px/s | 8 × 50ms = 400ms | 88.0px | 44.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.
| Speed | Longest cycle that keeps feet planted | A cycle that fits |
|---|---|---|
| 60 px/s | 466ms | 8 frames × 50ms — step 12.0px |
| 90 px/s | 311ms | 6 × 50ms — step 13.5px |
| 120 px/s | 233ms | 4 × 50ms — step 12.0px |
| 160 px/s | 175ms | nothing with 4+ frames at 50ms |
| 220 px/s | 127ms | nothing |
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.
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.
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 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.
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.
A walk cycle animates correctly in the preview but the feet visibly skate in the game. What has gone wrong?
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?
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:
| Duration | Ticks |
|---|---|
| 50 ms | 3 |
| 100 ms | 6 |
| 150 ms | 9 |
| 200 ms | 12 |
| 250 ms | 15 |
| 300 ms | 18 |
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:
| Duration | Ticks | Error per frame |
|---|---|---|
| 33 ms | 1.980 | +0.33 ms |
| 66 ms | 3.960 | +0.67 ms |
| 83 ms | 4.980 | +0.33 ms |
| 133 ms | 7.980 | +0.33 ms |
| 166 ms | 9.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.
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.
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.
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.
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.
Why is 83 ms — the closest whole millisecond to twelve frames per second — a poor choice of frame duration for a 60 Hz game?
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?
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 length | Frames | Per frame | Refreshes each |
|---|---|---|---|
| 100 ms | 6 | 16.7 ms | 1.0 |
| 200 ms | 6 | 33.3 ms | 2.0 |
| 300 ms | 6 | 50.0 ms | 3.0 |
| 200 ms | 12 | 16.7 ms | 1.0 |
| 400 ms | 12 | 33.3 ms | 2.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.
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 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.
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.
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.
Why does doubling an attack from six frames to twelve, without changing its 200 ms duration, fail to make it read more clearly?
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?
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 size | Pixels | On the border | Share |
|---|---|---|---|
| 8×8 | 64 | 28 | 44% |
| 16×16 | 256 | 60 | 23% |
| 24×24 | 576 | 92 | 16% |
| 32×32 | 1,024 | 124 | 12% |
| 48×48 | 2,304 | 188 | 8% |
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)⁴:
| Variants | A cell with an identical neighbour | Identical pairs in an 8×8 field |
|---|---|---|
| 1 | 100% | 112 |
| 2 | 94% | 56 |
| 3 | 80% | 37 |
| 4 | 68% | 28 |
| 8 | 41% | 14 |
| 16 | 23% | 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 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.
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.
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.
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.
Why does going from one tile variant to four remove much less repetition than it seems it should?
Your stone wall tiles seamlessly, but the wall still reads as an obvious grid. What is the most effective fix?
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 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 configurations | 256 |
| Distinct tiles once invisible corners are collapsed | 47 |
| Edge-only variant, ignoring corners entirely | 16 |
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 served | Tiles |
|---|---|
| 1 | 16 |
| 4 | 16 |
| 8 | 8 |
| 16 | 7 |
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.
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 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.
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.
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.
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.
Why do 256 possible neighbour configurations require only 47 distinct tiles?
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?
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 layer | Gap between layers | Layers that fit |
|---|---|---|
| 0.15 | 0.08 | 3 |
| 0.12 | 0.06 | 4 |
| 0.10 | 0.05 | 4 |
| 0.08 | 0.04 | 6 |
| 0.06 | 0.03 | 7 |
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.
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.
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.
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.
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.
Why can a parallax background with six layers read as less deep than one with four?
Your three-layer background reads as flat despite correct scroll factors. What should you check first?
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:
| Factor | Lightness range | Distinct levels | Closest pair |
|---|---|---|---|
| 1.0 | 0.16 to 0.84 | 16 | 0.0565 |
| 0.8 | 0.13 to 0.67 | 16 | 0.0452 |
| 0.6 | 0.10 to 0.50 | 16 | 0.0339 |
| 0.5 | 0.08 to 0.42 | 16 | 0.0282 |
| 0.4 | 0.06 to 0.34 | 16 | 0.0226 |
| 0.2 | 0.03 to 0.17 | 15 | 0.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.
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.
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.
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.
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.
Why can a palette that passes the lesson 9 audit still produce a muddy night scene?
Your cave level uses a CanvasModulate at 0.35 and everything reads as brown sludge. What is the most direct fix?
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:
| Glyph | Characters per line | Lines down 180px |
|---|---|---|
| 3×5 | 80 | 25 |
| 4×6 | 64 | 22 |
| 5×7 | 53 | 20 |
| 6×8 | 45 | 18 |
| 8×10 | 35 | 15 |
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:
| Target | Result |
|---|---|
| 640×360 | exactly 2× |
| 960×540 | exactly 3× |
| 1280×720 | exactly 4× |
| 1920×1080 | exactly 6× |
| 630×500 | not an integer multiple |
| 460×215 | not 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.
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.
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.
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.
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.
Why does interface art need more contrast discipline than sprite art?
You need a 630×500 cover image for your itch.io page. What should you do?
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) − 1part-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:
| Threshold | Used in | What it actually is |
|---|---|---|
| A face needs 5px of head | Lesson 11 | A judgement about the smallest legible eye-gap-mouth |
| A 32px character has 14px of leg | Lesson 12 | An assumption about proportion, used to define "reachable" |
| A frame needs 2 refreshes to register | Lesson 14 | A rule of thumb, not a perceptual constant |
| Below ΔE 0.03 a scene reads as mud | Lesson 18 | This course's working threshold |
| Interior runs must all be equal | Lesson 4 | A deliberately strict rule that calls legitimate patterns defects |
| 12% of outline changed means it reads | Lesson 3 | A 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
godot2dlesson 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.
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.
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.
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.
Which of these figures from the course is a decision it made rather than a measurement?
You are about to start your own game's art and want to spend your first hour well. What does this course suggest?