Next Talk: KI-Agenten als QA-Engineer: Automatisierte Qualitätssicherung mit Claude Code & GitHub Actions

November 11, 2026 — QS-Tag, Frankfurt am Main

Conference
Skip to content

The Dirty Flag Pattern, Explained with Snake

Published: at 

See all that green? Every green cell is a part of the screen the browser just repainted. The snake moves about 6 times a second, but the board below gets repainted 60 times a second.

How does the draw loop decide?

All 176 cells get repainted on every frame, even when nothing moved.

Last 60 frames (lit = redrawn)
Frames0
Draws0
Skipped0 (0%)
Cells painted0
Food in game11, 5
Food on screen–

Now switch to Dirty flag. The board goes calm. Only the cells the snake actually touched flash, and about 90% of the frames paint nothing at all.

Same game. Same picture. A fraction of the work. The trick is one of the simplest patterns in game development: the dirty flag.

Where I found it#

I’m building a Factorio-style game in the browser. While working on it, I read the Dirty Flag chapter from Robert Nystrom’s Game Programming Patterns. The book is free online and really good. The chapter explains the pattern with a pirate ship and a scene graph full of matrix math.

In this post I want to explain it in the simplest way I can: with the Snake game above.

The problem: two loops, two speeds#

A Snake game in the browser has two loops:

Notice the gap. The screen gets drawn 60 times a second, but the board only changes 7 times.

❌ The naive version: draw every frame#

Here’s how most of us write our first Snake:

// snake.ts
setInterval(() => {
  moveSnake(); // 7× per second
}, 150);

function frame() {
  drawBoard(); // 60× per second: grid, snake, food, score
  requestAnimationFrame(frame);
}
frame();

That’s the Draw every frame mode in the demo. It works. But out of every 60 frames, about 53 paint the exact same picture as the frame before.

That’s almost 90% wasted work. On a laptop you won’t notice. On a phone, the battery notices.

✅ The fix: a dirty flag#

Add one boolean. It answers a single question: is the picture on screen out of date?

// snake.ts
let dirty = true; // nothing is drawn yet, so we start out of date

setInterval(() => {
  moveSnake();
  dirty = true; // the board changed
}, 150);

function frame() {
  if (dirty) {
    drawBoard();
    dirty = false; // the screen matches the game again
  }
  requestAnimationFrame(frame);
}
frame();

That’s the whole pattern. Nystrom puts it like this:

“Dirty” means out of date. “Clean” means you can reuse what you already have.

The demo at the top goes one step further. Instead of one flag for the whole board, it keeps one dirty flag per cell. A move only touches three cells: the new head, the old head and the tail that got freed up. That’s why only those cells flash.

It only pays off when changes are rare#

Scroll back up and drag the speed slider. The faster the snake, the less the flag saves. At 1 move per second, almost every frame gets skipped.

A dirty flag only pays off when the data changes less often than you read it. If the board changed on every frame, the flag would never skip anything.

There’s also a free bonus. If the snake moves, eats and the score goes up in the same tick, all three set dirty = true. You still draw once, not three times.

Many changes, one recompute#

Snake only has one thing to redraw. Nystrom’s pirate ship is where the pattern gets interesting, because the data is a tree.

A ship carries a crow’s nest. A pirate stands in the nest. A parrot sits on his shoulder. Each one stores its position relative to its parent, and the world position is the parent’s world position combined with its own. So when the ship moves, everything on it is out of date, even though the parrot itself didn’t do anything.

This is Robert Nystrom’s example from Game Programming Patterns. I drew my own ship and added counters so you can see the work.

When does the world transform get recomputed?

Every move recomputes that node and all its children right away. The picture follows immediately.

Shipclean0
Crow's nestclean0
Pirateclean0
Parrotclean0
Total recomputes0

Nothing has moved yet.

Example from Robert Nystrom's Game Programming Patterns, Dirty Flag chapter.

Try this:

  1. Pick Eager, press Move all four. The ship recomputes itself and all its children, then the nest does the same, and so on. The parrot ends up at 4, the total at 10.
  2. Press Reset counters, pick Dirty flag, press Move all four. Four dots turn red and nothing is recomputed. The picture is still old. Press Render and every node counts 1, the total is 4.
  3. Reset, then press only Move ship, then Render. The total is still 4. Only the ship got flagged, but the render passes the dirt down, so the nest, pirate and parrot recompute too. That’s correct. They really were out of date.

The trap: forgetting to set the flag#

This is the bug you’ll actually ship. Say you add a power-up where the food teleports every five seconds:

// snake.ts
setInterval(() => {
  food = randomFreeCell();
  // forgot: dirty = true
}, 5000);

Nothing crashes. No error in the console. The food moved in the game state, but the screen still shows the old spot until the snake happens to move. The player steers toward food that isn’t there.

Try it in the demo at the top: switch to Forgot the flag, pause the snake and click Teleport food. The old apple stays on screen, the new one is invisible, and the stats turn red.

That’s what makes the bug nasty. A dirty flag is a cache, and the hard part of any cache is invalidation. Miss one place and you get a quietly wrong screen.

✅ The better fix: one door for every change#

Nystrom’s advice is to put all changes behind a narrow API. If the only way to change the board also sets the flag, forgetting becomes impossible:

// snake.ts
let dirty = true;

function setFood(pos: Cell) {
  food = pos;
  dirty = true;
}

function setSnake(next: Cell[]) {
  snake = next;
  dirty = true;
}

Game code never writes food = ... directly again. It calls setFood(...).

I like this much more than the plain flag. Remembering things is a bad strategy, for humans and for coding agents.

Why a number and not a boolean#

Snake uses true/false. That works as long as only one thing reads the flag.

Now add a minimap next to the board. Both the board and the minimap check dirty. The board redraws first and sets dirty = false. The minimap looks next, sees a clean flag and never updates.

The fix is a counter instead of a boolean:

// snake.ts
let version = 0;

function setFood(pos: Cell) {
  food = pos;
  version++; // never reset, only counts up
}

let boardSeen = -1;
let minimapSeen = -1;

function frame() {
  if (boardSeen !== version) { drawBoard(); boardSeen = version; }
  if (minimapSeen !== version) { drawMinimap(); minimapSeen = version; }
  requestAnimationFrame(frame);
}

Each reader remembers the last number it saw. Nobody clears anything, so readers can’t steal the update from each other.

Rule of thumb: one reader, use a boolean. Several readers, use a counter.

You already use this every day: computed#

If you write Vue, you’ve used dirty flags for years without seeing them.

const items = ref<Item[]>([]);
const total = computed(() => items.value.reduce((sum, i) => sum + i.price, 0));

Changing items doesn’t recompute total. It only marks total as dirty. The reduce runs the next time someone reads total.value, and only once, no matter how many times items changed in between. That’s exactly the Snake draw loop.

Vue 3.5 has a DIRTY bit on every computed, plus version counters next to it. It’s the boolean and the counter from the section above, in one place.

alien-signals, the signal library behind Vue 3.6’s reactivity rewrite, takes it one step further with two flags:

Remember Nystrom’s pirate ship, where a parent moving makes every child out of date? Pending is that idea for a graph of signals. Let’s look at the actual code. The snippets are from alien-signals v3.2.0 (MIT), shortened, with my comments.

The flags are bits#

All flags live in one number. Each flag is one bit:

// src/system.ts
export const enum ReactiveFlags {
  None = 0,
  Mutable = 1,
  Watching = 2,
  RecursedCheck = 4,
  Recursed = 8,
  Dirty = 16,
  Pending = 32,
}

That’s the “dirty bit” from the book, literally. flags & ReactiveFlags.Dirty checks the bit, flags | ReactiveFlags.Pending sets it, flags & ~ReactiveFlags.Pending clears it. One number per node, no extra objects.

Writing a signal: mark, don’t compute#

When you write a new value, the signal marks itself Dirty and hands off to propagate:

// src/index.ts — signalOper, the setter branch
if (this.pendingValue !== (this.pendingValue = value[0])) {
  this.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty;
  const subs = this.subs;
  if (subs !== undefined) {
    propagate(subs, !!runDepth); // walk down to everyone who reads me
    // ...
  }
}

propagate walks down the graph, and every subscriber that’s still clean only gets the Pending bit:

// src/system.ts — propagate
if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed | ReactiveFlags.Dirty | ReactiveFlags.Pending))) {
  sub.flags = flags | ReactiveFlags.Pending; // "maybe out of date"
  // ...
}

Notice what’s missing: no getter runs here. Writing a signal is just setting bits. Same as dirty = true in Snake.

Reading a computed: check, then maybe recompute#

The work happens when someone reads a computed:

// src/index.ts — computedOper
const flags = this.flags;
if (
  flags & ReactiveFlags.Dirty                  // definitely stale: recompute
  || (
    flags & ReactiveFlags.Pending              // maybe stale:
    && (
      checkDirty(this.deps!, this)             //   ask my sources if they really changed
      || (this.flags = flags & ~ReactiveFlags.Pending, false) // they didn't: just clear Pending
    )
  )
) {
  updateComputed(this);                        // run the getter, once
}
// ...
return this.value!;

That’s the if (dirty) from the Snake draw loop, with one extra step. A Pending computed first runs checkDirty, which walks up its sources. If none of them actually produced a new value, it clears the bit and returns the cached value. The getter never runs.

Here’s when that pays off:

const count = signal(1);
const isOdd = computed(() => count() % 2 === 1);
const label = computed(() => (isOdd() ? "odd" : "even"));

count(3); // isOdd and label become Pending
label();  // isOdd recomputes: still true. label stays cached.

With a single Dirty bit, label would have to recompute too. The Pending bit lets the library stop as soon as a value turns out unchanged.

Here’s that example with the flags visible. I rebuilt the idea as a tiny TypeScript model so you can poke at it. It’s a re-implementation, not the library itself, but I checked that its run counts match alien-signals 3.2.0.

Writing a signal only flips flags. A getter runs when someone reads, and only if a source really changed.

countsignal
value1
flagClean1
getterno getter

the source of truth

isOddcomputed
valuetrue
flagClean1
getterran 1×

reads count

labelcomputed
value"odd"
flagClean1
getterran 1×

reads isOdd

Press a button and watch the flags.

A simplified model of alien-signals' Dirty and Pending flags. Run counts match the real library.

Try this:

  1. Press count + 2. count goes from 1 to 3 and both computeds turn Pending. Nothing re-ran. Press Read label: isOdd runs again and is still true, so label stays cached.
  2. Press count + 1, then Read label. Now isOdd flips to false, so label has to run too.
  3. Press count + 1 a few times without reading, then Read label once. isOdd runs once, no matter how many writes you made.

Source links: ReactiveFlags, propagate, computedOper, signalOper.

So the pattern from a 2014 game programming book is sitting in the core of a modern frontend framework.

When not to use a dirty flag#

The pattern adds code and a new way to have bugs. So don’t reach for it by default.

💪See it in your own code

Put console.count("draw") at the top of whatever expensive function you think is cached. Stand still, then change one thing. If the counter keeps climbing while nothing happens, you’ve found your wasted work.

Summary#

A dirty flag is one bit, or one number, that says “this result is out of date”. Set it when the source changes. Check it when you need the result. Redo the work only when it’s set.

The pattern itself takes five minutes to learn. The discipline is the hard part: every change has to set the flag. So don’t rely on discipline. Route every change through one function and let the code remember for you.

If you want to go deeper, read the Dirty Flag chapter in Game Programming Patterns. It covers the parts I skipped, like when to clean the flag and how fine-grained your tracking should be.

Press Esc or click outside to close

Stay Updated!

Subscribe to my newsletter for more TypeScript, Vue, and web dev insights directly in your inbox.

  • Background information about the articles
  • Weekly Summary of all the interesting blog posts that I read
  • Small tips and trick
Subscribe Now