---
title: "How we keep it right"
description: "How Professor Oops stays right: tests that pour, hammer and burn a real page, compiled loops held to their readable twins, and promises written into code."
source_name: "Game Tailors"
source_url: "https://professoroops.com/en/inside/craft/"
site_home: "https://professoroops.com/"
source_description: "The drawing app where the paper is real. No ads. No accounts. No personal data: only anonymous counts, which you can switch off."
locale: "en"
legal:
  - "Game Tailors · Schieweg 15Y-40, 2627 AN Delft (NL)"
  - "KvK 92437702 · VAT NL866044474B01"
  - "info@gametailors.com · +31 6 4227 5919"
copyright: "© 2026 Game Tailors"
published_at: "2026-11-10T12:03:12.866Z"
modified_at: "2026-11-10T12:03:12.866Z"
---

# How we keep it right

## Thousands of automatic tests do what children do, then check the page still comes out right

Honey poured on a drawing today should behave as it did yesterday. Behind Professor Oops sit thousands of automatic checks that do what children do: pour paint, bring a hammer down, set a fire, wait for mould. Then they look at the page. The promises a family cares about, such as "no animal is ever hurt" and "no ads", are written down as checks too, so breaking one fails the run.

[For engineers](<#hood-engineers>)

![Painted illustration of the painter crouching by an oven, watching a crayon drawing toast golden inside, with an ice-cube tray and a lemon.](<https://professoroops.com/_assets/entity_3kuAoHIjjj2uBbqOOhSg.webp>)

## For every 100 lines of game there are nearly 60 lines of tests

At the release, the code held 270 test files, holding 2,614 tests and 8,933 individual checks. That's 56 lines of test for every 100 lines of game. The next update has 353 files and 60 lines for every 100 next update. The tests grew faster than the game: on the second day of building they were 12 % of its size.

Most of them aren't the usual small checks on one function. Many set out a real page, work a real tool on it, run the simulation for a few seconds and read what happened. Their names are sentences you could check at a kitchen table: *"a slap squashes a tomato but not a banana; a slammed rolling pin bursts an orange but not an apple"*. About two in three test files step the real engine.

In a test, the simulation moves in fixed steps, usually a sixtieth of a second, never by the clock. So a busy computer gets exactly the same answer as a quiet one. A fast tier leaves out the 700 or so tests that take more than a second, for use while working. The full run adds a type check of every file, a production build and a browser run in Chrome.

Here is a seam, shown plainly. The checks run on our own computers. There's no separate build server that refuses a release, so keeping a failing change out of the game is a working rule we keep by hand. Real phones and tablets are tried by hand too: the automated runs use a desktop Chrome, plus one test of the purchase flow on an iPhone simulator.

Thousands of automatic tests do what children do, then check the page still comes out right

For engineers

### Thousands of automatic tests do what children do, then check the page still comes out right

At the release the code held 270 test files with 2,614 tests and 8,933 assertions against 100,923 lines of TypeScript; about two in three of the next update's test files step the real engine in fixed steps, mostly of 1/60 s. Seven WebAssembly SIMD kernels have readable JS twins whose copied source is fingerprinted, so a changed loop fails the run until its kernel is ported and reviewed; five, each tested alone, match their twins bit for bit. Rules such as "the screen never touches engine state" and "every object is a painting" are syntax-tree scans that fail on a new violation.

[Under the hood](<#hood-suite>)

For every 100 lines of game there are nearly 60 lines of tests

Under the hood

### For every 100 lines of game there are nearly 60 lines of tests

| count | release | next update |
| --- | --- | --- |
| test files | 270 | 353 |
| it/test sites | 2,614 | 3,458 |
| expect\( calls | 8,933 | 11,715 |
| test lines | 56,263 | 72,142 |
| src lines | 100,923 | 119,523 |

Counted over the test files and the game's TypeScript, generated kernels left out. Counting test cases undercounts: many tests are generated in loops.

What the next update's 353 files touch \(they overlap\): 258 use the real engine, 240 step it, 173 drive the real tools, 60 the draw code, 39 the UI, 38 the worker host, 38 the creatures, 34 purchases and licences, about 33 read source code as data, 8 run a kernel against its readable twin.

Tiers: a test that takes more than about a second moves to the slow tier, with a margin so it doesn't flip back and forth \(716 tests in 160 files on the next update's list\). The full run builds the kernels and runs every test once, with a type check and a production build beside it, then the browser run in both thread modes. A failed file gets one solo retry, named in the summary.

Types: TypeScript's strict mode, tests type-checked too; zero `any` and zero `@ts-ignore` at both revisions. The honest counter: 86 `as unknown as` casts in the next update's source.

The tests grew faster than the gameThe release was on 2 Nov. Test lines went from 12 % of the source to 60 %.

From 18 Oct to 5 Nov the game source grew from 21.3 to 119.5 thousand lines, and the tests from 2.5 to 72.1 thousand.

- game source
- tests

Data table

The tests grew faster than the game

| date \(2026\) | game source | tests |
| --- | --- | --- |
| 18 Oct | 21.3 | 2.5 |
| 20 Oct | 35.7 | 7.7 |
| 22 Oct | 48.6 | 20.2 |
| 24 Oct | 71.6 | 33.9 |
| 26 Oct | 85.9 | 44.4 |
| 28 Oct | 86.1 | 45.5 |
| 30 Oct | 99.4 | 54.4 |
| 1 Nov | 106.9 | 60.7 |
| 3 Nov | 118.2 | 69.1 |
| 5 Nov | 119.5 | 72.1 |

Source: Lines of game and test code, counted on one version a day; after 2 Nov the numbers are the next update's.

Where the code is, in thousands of lines

The simulation grew from 41.1 to 49.1 thousand lines and the tests from 57.3 to 73.3 thousand; the rest of the areas hold between 3.6 and 13.9 thousand each.

- release
- next update

Data table

Where the code is, in thousands of lines

|  | release | next update |
| --- | --- | --- |
| sim | 41.1 | 49.1 |
| engine | 3.6 | 5.0 |
| animals | 6.9 | 7.6 |
| tools | 6.4 | 7.3 |
| render | 10.8 | 13.9 |
| UI | 8.8 | 11.5 |
| worker | 4.8 | 4.9 |
| wasm | 4.0 | 4.3 |
| tests | 57.3 | 73.3 |
| scripts | 11.5 | 12.8 |

Source: Lines of TypeScript and scripts per area of the code, tests with their helpers; counted at the release and the next update.

## The fast code a tablet runs is checked against the readable code, and five of seven parts must match exactly

The busiest loops of the simulation exist twice. These are where goop flows, paper soaks, fire spreads, a hand bulldozes paint and the air finds its pressure. One copy is readable TypeScript. The other is compiled to WebAssembly with SIMD, so a cheap tablet keeps up. The tests run the readable copy and the apps run the compiled one, so the tests only speak for the apps if the two agree.

Three layers hold them together. First, each compiled kernel names the exact functions and constants it copies, and the build stores a fingerprint of their source. Change one line of a copied loop and the test run fails, naming the function, until its twin is ported and someone has recorded that they checked it. Comments and spacing don't count; the code does.

Second, each kernel runs a set of test pages twice, with and without it, and every field of every cell is compared. For goop, fire, the bulldozing hand, soap on milk and the air's pressure solve, each tested on its own with the other kernels off, the answer must match to the last bit. They manage that by doing their sums in the same order and the same precision as the readable copy. The paper and floating-layer kernels use 32-bit lanes for speed, so they're held to a small tolerance instead: one part in 100,000 for paper, one in 10,000 for the floating layer.

Third, one page with every material on it runs with all kernels on and with all off, on six page shapes, and must agree within a stated tolerance. If the kernels can't start on a device, the game falls back to the readable loops, and a test checks that this gives exactly the same goop and wetness as the readable engine.

Two seams, shown plainly. On one tilted page, six cells along the bottom row drift up to 3.4 parts in 10,000 between the paper kernel and its twin. The test is written to expect that failure, so it will fail the day someone fixes it and force them to make it a normal test next update. And on lower detail settings the readable air does a cheaper pressure solve, while the compiled air always does the full one. The parity test sets them equal first. In the apps the wind at those settings is the fuller one, on purpose.

[Under the hood](<#hood-twins>)

The fast code a tablet runs is checked against the readable code, and five of seven parts must match exactly

Under the hood

### The fast code a tablet runs is checked against the readable code, and five of seven parts must match exactly

Each compiled kernel declares which readable functions and constants it copies: 141 of them at release, 152 in the next update. The build fingerprints their source with SHA-256, comments and spacing left out, and stores the fingerprint in the kernel; a test recomputes it on every run. A second fingerprint ties the generated glue code to its source.

| kernel | standard | tested on |
| --- | --- | --- |
| goop | to the bit | 10 pages |
| fire | to the bit | 9 pages |
| bulldoze | to the bit | 14 pages \+ 4 strokes |
| marangoni | to the bit | 8 pages |
| air | to the bit | 12 grids, 4 scenes |
| paper, wick | within 1 part in 100,000 | 13 cases |
| float | within 1 part in 10,000 | 10 cases |

"To the bit" is strict: NaN must meet NaN and \+0 must meet \+0. The bit-exact kernels do their sums in the same precision and order as the readable code; each is tested alone, the others kept off in both engines. The whole-page check runs every material, fire included, and allows only a small share of cells to differ beyond its tolerance.

These tests can't pass by doing nothing: they first check that the kernel attached and did real work. The kernels' shared memory is sized exactly from what each one declares, and a test holds that on many page sizes.

Try it: two copies of one loop The game's own ruleLeft, the readable loop; right, its copy. Both run the game's paper-wetting rule on the same 64 × 48 cells, with drops landing every second or so. Cells where the copy differs from the original in even one bit turn red. A fingerprint shows as its 32-bit number. **What to look for:** with the same sums in the same order, the two fingerprints match exactly, forever. Add the same numbers in another order, or do the sums in 32-bit floats as SIMD lanes do, and the fingerprint breaks at once, though the cells stay very close: hundred-millionths apart for another order, millionths for 32-bit sums. That's why the game holds five kernels to the bit, and paper and floating layers to a tolerance. The clock runs 3× fast.

![Two copies of the paper-wetting loop side by side, with cells that differ by even one bit marked red](<https://professoroops.com/_assets/entity_EHp7cIAbZEkOz1SpraVH.png>)

- the readable loop
- its copy

The copy doesthe same sums, same orderthe same sums, another order32-bit sums

fingerprint, original—

fingerprint, copy—

cells not equal to the bit—

worst relative difference—

to the bit—

within 1e-5—

How far each compiled loop may stray from its readable twin"5 exact": goop, fire, bulldoze, marangoni and air, each alone, must be identical \(0, drawn at the floor\). Paper: within 1e-5 of its twin; floating layer: within 1e-4; whole page with every material: 2e-3, in a small share of cells.

Five kernels must be identical; paper may differ by 1e-5, the floating layer by 1e-4, the whole page by 2e-3. The known gap is 3.4e-4, and one 32-bit rounding is about 6e-8.

Data table

How far each compiled loop may stray from its readable twin

|  | rounding | 5 exact | paper | known gap | float | whole page |
| --- | --- | --- | --- | --- | --- | --- |
| rounding | 60 | — | — | — | — | — |
| 5 exact | — | 1 | — | — | — | — |
| paper | — | — | 10 000 | — | — | — |
| known gap | — | — | — | 340 000 | — | — |
| float | — | — | — | — | 100 000 | — |
| whole page | — | — | — | — | — | 2 000 000 |

Source: Allowed or seen differences between each compiled loop and its readable twin. Known gap: 3.4e-4 seen in six cells of one tilted page, pinned as an expected failure next update. "Rounding": one 32-bit float rounding, about 6e-8, for scale. The measures differ, so compare each on its own, not across.

## Change one speck of a test page and a check fails, and almost every deliberate change comes with a note

Some test pages are pinned. A busy page is worked by a whole set of tools, and at the end its fields are boiled down to an eight-digit fingerprint. If paint, water or oil end up different on that page, even in one cell by one bit of the fields it covers, the fingerprint changes and the test fails.

Behaviour does change on purpose. Then the fingerprint is updated, and the note beside it says what moved and why, often with what the fingerprint would be with the new behaviour switched off. Over the life of these files, 108 of the 112 changes to a fingerprint came with a note in the same change. Read in order, the notes are a logbook of the game's physics: *"a pigment's grains ride the water's flow to a drying edge"*.

A fingerprint says "as before". It can't say "right": that's what the scene tests are for. And they mostly check behaviour a child would recognise, not real-world figures.

The same idea, to the bit, guards several promises that are easy to break by accident:

- Recorded play, replayed from a saved moment, ends on exactly the same page. Without the recorded random numbers and clock it goes another way, and the test checks that too.
- A time skip gives the same page at three detail levels, each with its own work budget.
- Opening the measuring panel changes nothing on the page.
- The picture data sent tile by tile is exactly what packing the whole page afresh would send.
- Undo by small differences puts back exactly what whole copies did.
- Mirror the page and the wind mirrors, to the bit.

[Under the hood](<#hood-fingerprints>)

Change one speck of a test page and a check fails, and almost every deliberate change comes with a note

Under the hood

### Change one speck of a test page and a check fails, and almost every deliberate change comes with a note

A pin hashes the raw bytes of chosen fields with the standard 32-bit FNV-1a hash. Six pins in four test files, all at release \(their values have moved since\):

| pin | what it covers |
| --- | --- |
| layers | a busy page worked by every tool that reads layers; creatures and toys through paint, juice, water |
| oils | paint, ketchup, honey, milk, juice and water worked by hand; wax and butter on paint and ketchup |
| marbling | oil on a bath, combed with a toothpick, soaking away |
| floating layer | what the renderer receives |

Counted over the whole history of the four files: 112 value changes, 108 with a new comment in the same commit. Pins hold for the readable loops only.

Same-input tests: a replay hashed over every saved field \("a replay that did nothing wouldn't pass"\), a time skip at three detail levels with their own budgets, the measuring panel switched on and off, tile-by-tile packing against a full repack, undo by differences with the old undo running alongside as reference, and the wind solved on a mirrored page.

Try it: change one cell by one bit**The tests' own hash.** A small page of paint, 40 × 30 cells, boiled down to a fingerprint with the same FNV-1a hash the pinned tests use. One cell, ringed in red, already differs from the pinned page by the smallest step a 32-bit number can take, about one part in ten million. **What to look for:** the picture can't show it, yet the fingerprint is a quite different number and the pin fails. Tap the ringed cell, or press Put it back, and the pin passes; tap any other cell to change that one.

![A small page of paint and its fingerprint; tapping a cell changes it by one bit and the fingerprint changes completely](<https://professoroops.com/_assets/entity_QwQpgXY0gxzjP1j6Mn6K.png>)

pinned—

now—

cells changed—

last change—

pin—

Nearly every change to a fingerprint came with a note saying why

Of 112 changes to a pinned value, 108 came with a note in the same change and 4 without.

Data table

Nearly every change to a fingerprint came with a note saying why

|  | changes to a pinned value |
| --- | --- |
| with a note in the same change | 108 |
| without | 4 |

Source: Each change to a pinned value in the four files, over their whole history.

## Mistakes we made became rules the computer checks on every run

The simulation runs in a Web Worker, a separate thread, so drawing and touch stay smooth while the page is worked out. The screen's side sees a copy of what it needs to draw. The split has one nasty trap. Screen code that reaches into the engine directly still works when everything runs on one thread, and silently does nothing in the worker. *"It has happened four times,"* says the test that now stops it.

That test reads the screen side's source with the TypeScript compiler and lists every value it takes from the engine's side. Each must be on a list with a reason: a constant, a pure function, a class whose copy lives on the screen side, or state the two share on purpose. A new one fails the run with a message saying where it belongs. The test also checks that it notices a new import, so it can't quietly go blind.

A browser run covers what code reading can't. It opens the real game in headless Chrome twice, with the simulation in its worker and in the same thread, and runs the same checks in both. Sand jumps on a speaker. Sliders reach the simulation. A saved picture is a real 3200-pixel JPEG. A colouring page loads. A line of scenes runs: a fat fire under a lid, oil on water, folded dough, a battery lighting a bulb. Every tool's name fits under its picture in every language. Each check is there because that thing *"broke silently in worker mode once"*.

[Under the hood](<#hood-rules>)

Mistakes we made became rules the computer checks on every run

Under the hood

### Mistakes we made became rules the computer checks on every run

A test parses all the screen-side code with the TypeScript compiler and checks every value it imports from the engine's side against an allow list with reasons: 156 names at release, 198 in the next update. It also rejects lazy imports of engine code, checks that "shared" names really are shared, and tests its own detector.

New drawn state must be classified: a second test fails on any new piece of engine state until it's marked as saved, worked out again or momentary. The copy the screen draws from grew from 47 engine fields and 40 classes at release to 53 and 46 next.

The browser run: 39 checks at release, 71 next, the same in both modes. It doesn't diff the two modes' pages against each other; within a mode it compares the worker's state with the screen's copy.

### Worker thread

The engine, materials, water, fire, air, creatures and tools, and the seven WebAssembly kernels.

### What crosses between them

- **→ messages.** Every touch, slider and button becomes a named call the worker handles.
- **← replica.** A copy of what's drawn: 47 engine fields and 40 classes at release.
- **← textures.** Only the changed tiles of the page, handed over, not copied.
- **allow list.** Values the screen side may import, each with its reason: 198 in the next update next update.

### Main thread

Menus and buttons, the WebGL2 paper, the paintings and the creatures on it.

What the screen side may import from the engine, by reason

Of 198 values on the allow list in the next update, 103 are constants, 68 pure functions, 22 shared state, 3 replica classes, 1 a hook and 1 a config.

Data table

What the screen side may import from the engine, by reason

|  | values on the allow list |
| --- | --- |
| constants | 103 |
| pure functions | 68 |
| shared | 22 |
| replica | 3 |
| hook | 1 |
| config | 1 |

Source: The allow list in the next update next update, each value with its reason.

### Each rule card says what went wrong once, and that a test now stops it

Checked by a test on every run

#### The screen never reaches into the engine

*An import "works in-thread and silently does nothing in the worker".*

Every engine value the screen side imports is on a list with its reason.

Checked by a test on every run

#### Every piece of state has a known place

*"Saved, or worked out again, or only for the moment."*

Nothing new is quietly lost on undo, on reload or on its way to the screen.

Checked by a test on every run

#### Every module loads on its own

*A load-order error "once stopped the app in the browser".*

Each simulation module, the engine and the tools load first, alone, in plain Node.

Checked by a test on every run

#### Objects are paintings

*"Code-drawn objects kept coming back."*

Any function that draws shapes or text must say why it isn't drawing an object: 92 at release.

Checked by a test on every run

#### The app's name has one home

*A name written in many places drifts.*

The source, configs, native projects and legal pages take it from one setting.

Pinned by a test

#### The advertising permission stays out

*The manifest's comment calls it "a promise to families".*

Android's merged manifest may ask for the internet and store billing; the advertising ID and storage permissions are removed.

Checked by a test on every run

#### The hidden test panel can't unlock anything

*App Store rule 2.3.1.*

The page can't reach it by static import, and none of its files may grant, report or write a purchase.

Checked by a test on every run

#### Don't claim chemistry we don't do

next update *Salt water split by a battery bubbles and turns sour and soapy, but the simulation makes no oxygen or chlorine.*

"No word of the electrolysis, in the code or the catalogue, says oxygen or chlorine."

## The picture is lit from the simulation's own numbers, so the light and the water read the same heights

The simulation doesn't make a picture. It keeps a stack of numbers for every spot on the paper, each about half a millimetre across: how much colour has soaked in, how wet it is, how thick the ketchup lies, how hot and how charred. On every frame a small program on the graphics chip turns those numbers into a lit surface for each pixel on screen.

Colour is kept the way light passes through it. Each spot stores how strongly it absorbs red, green and blue, and the paper shows through as e to the minus that. Two washes over each other add their absorbance, so yellow over blue gives green, as glazes do. That's the Beer-Lambert law <sup>[Swinehart 1962](<https://professoroops.com/en/inside/research-simulation/#swinehart-1962>)</sup>, which we found described our model after we'd built it, in three channels. Real paint also scatters light <sup>[Kubelka 1931](<https://professoroops.com/en/inside/research-simulation/#kubelka-1931>)</sup>; the game leaves that out.

The paper has real relief. A dent from a pencil pressed hard, the raised outline of a colouring page, the waves wet paper dries into, a dollop of ketchup and a puddle on top all add up to one height. The light reads that height to shade slopes, cast short shadows and darken hollows. The water reads the same dents and outlines, so by the code it runs into a groove and stays inside a raised line. A pencil groove is shallow, though, and in the game neither it nor water running into it shows. A blob of ketchup or thick paint shows its relief plainly.

Shine follows the height too. A glossy surface throws the lamp back only where it tilts toward it, so a big, flat puddle has almost no highlight in its middle, and its edges and ripples shine. A single pour soaks in within a second or two, before it can show that. Wet paper gets darker, and only paper soaked right through starts to shine.

The paper's grain is noise made once for each sheet: a fine tooth plus longer fibres. Wet paper waves up as it soaks and never quite flattens again as it dries, because its waves stop shrinking at a low floor instead of going flat. An iron or a rolling pin presses them flat. Burn a hole and the painted wooden table shows through.

[Under the hood](<#hood-picture>)

The picture is lit from the simulation's own numbers, so the light and the water read the same heights

Under the hood

### The picture is lit from the simulation's own numbers, so the light and the water read the same heights

The worker packs the page's fields into a handful of half-float and byte textures; the graphics program reads them for every pixel on every frame. The page is 512 × 384 cells.

Height: one sum of the paper's dents, its waves, its fibre, the goop and the water on top, plus floating layers, dough, wire, char and holes, clamped so a half float can't overflow into a black pixel. The normal comes from central differences of that height.

Lighting: one fixed key light about 45° up from the upper left; diffuse light, a Blinn-Phong highlight whose sharpness grows with gloss, soft shadows from a short march toward the light that soften with distance, and ambient occlusion from the heights around. Fewer shadow steps on slower devices.

Only changed tiles go up, under a per-frame budget, the ones near the hand first. With no budget, what's sent equals a full repack to the bit after every frame; a test checks that on a small page, without a GPU.

Try it: light the paper from its numbers The game's own ruleA corner of the page, 120 × 80 cells, lit by the game's own formulas: the same height sum, key light, shadow march, occlusion and shine. Water has just been poured on a pressed groove, with ketchup and honey nearby and a colouring page's raised outline. Pick something and drag on the paper, or press Add it, or pour again. **What to look for:** the water levels out, fills the groove and soaks in within a few seconds; the wet paper stays darker and waves up as it dries. The middle of the honey stays dull while its rim shines. Turn shadows or shine off to see what each adds, and fewer shadow steps to see what a slow tablet gives up. In the game a pencil groove is too shallow to see, and we never saw poured water run into one.

![A corner of paper with a groove, a raised outline, ketchup, honey and water, lit by the game's formulas](<https://professoroops.com/_assets/entity_F5sO1gWVhDpH7sTzN2fX.png>)

Drag to addWaterPress a grooveRaised lineKetchupHoneyShadow steps1076

water on top—

soaked in—

shadow step—

brightest shine—

![A round blob of ketchup on the paper in the game, bright on its upper left with a small glint and shaded on its lower right, above a yellow pencil line and a grey patch where water has just soaked in.](<https://professoroops.com/_assets/entity_B4YEvYhZVGeB8He1HTmU.webp>)

In the game. A blob of ketchup lit by the room's key light from the upper left: the side facing the light is bright, with a small glint, and the far side falls into shade. Below it, a pencil line, and paper gone darker where water has just soaked in.

Shine shows only where a surface tilts toward the lampOn flat water \(0°\) the highlight is about a hundred-thousandth of its peak.

Every highlight peaks at a 22.5° tilt toward the key light: 0.85 for water or honey, 0.77 for ketchup, 0.51 for mustard and 0.07 for dry paper.

- dry paper
- mustard
- ketchup
- water or honey

Data table

Shine shows only where a surface tilts toward the lamp

| tilt toward the key light \(degrees\) | dry paper | mustard | ketchup | water or honey |
| --- | --- | --- | --- | --- |
| 0 | 0.01347 | 0.00047 | 0.00003 | 0.00001 |
| 0.5 | 0.01449 | 0.00065 | 0.00005 | 0.00002 |
| 1 | 0.01556 | 0.00088 | 0.00008 | 0.00003 |
| 1.5 | 0.01668 | 0.00119 | 0.00012 | 0.00006 |
| 2 | 0.01784 | 0.00159 | 0.00018 | 0.00009 |
| 2.5 | 0.01906 | 0.00211 | 0.00028 | 0.00014 |
| 3 | 0.02032 | 0.00278 | 0.00041 | 0.00021 |
| 3.5 | 0.02162 | 0.00364 | 0.00061 | 0.00033 |
| 4 | 0.02297 | 0.00472 | 0.00089 | 0.00049 |
| 4.5 | 0.02436 | 0.00609 | 0.00128 | 0.00074 |
| 5 | 0.02579 | 0.00779 | 0.00183 | 0.0011 |
| 5.5 | 0.02726 | 0.00989 | 0.00258 | 0.0016 |
| 6 | 0.02876 | 0.01246 | 0.00361 | 0.00232 |
| 6.5 | 0.0303 | 0.01559 | 0.00499 | 0.00331 |
| 7 | 0.03186 | 0.01937 | 0.00682 | 0.00467 |
| 7.5 | 0.03345 | 0.02389 | 0.00923 | 0.00652 |
| 8 | 0.03505 | 0.02925 | 0.01236 | 0.009 |
| 8.5 | 0.03668 | 0.03556 | 0.01639 | 0.01229 |
| 9 | 0.03831 | 0.04293 | 0.02151 | 0.01658 |
| 9.5 | 0.03996 | 0.05146 | 0.02793 | 0.02212 |
| 10 | 0.0416 | 0.06125 | 0.03591 | 0.02917 |
| 10.5 | 0.04325 | 0.07238 | 0.0457 | 0.03806 |
| 11 | 0.04488 | 0.08495 | 0.05758 | 0.0491 |
| 11.5 | 0.0465 | 0.09901 | 0.07182 | 0.06264 |
| 12 | 0.0481 | 0.11458 | 0.08868 | 0.07903 |
| 12.5 | 0.04968 | 0.1317 | 0.10842 | 0.09863 |
| 13 | 0.05123 | 0.15033 | 0.13122 | 0.12173 |
| 13.5 | 0.05274 | 0.17041 | 0.15726 | 0.14861 |
| 14 | 0.05421 | 0.19186 | 0.1866 | 0.17945 |
| 14.5 | 0.05563 | 0.21453 | 0.21924 | 0.21434 |
| 15 | 0.057 | 0.23825 | 0.25505 | 0.25325 |
| 15.5 | 0.05831 | 0.26279 | 0.29381 | 0.296 |
| 16 | 0.05956 | 0.28789 | 0.33516 | 0.34223 |
| 16.5 | 0.06073 | 0.31325 | 0.3786 | 0.39144 |
| 17 | 0.06184 | 0.33855 | 0.4235 | 0.44292 |
| 17.5 | 0.06286 | 0.36342 | 0.46913 | 0.4958 |
| 18 | 0.0638 | 0.3875 | 0.51463 | 0.54907 |
| 18.5 | 0.06466 | 0.4104 | 0.55908 | 0.60157 |
| 19 | 0.06542 | 0.43172 | 0.60149 | 0.65207 |
| 19.5 | 0.06609 | 0.45112 | 0.64087 | 0.69929 |
| 20 | 0.06667 | 0.46822 | 0.67623 | 0.74194 |
| 20.5 | 0.06714 | 0.48273 | 0.70667 | 0.77884 |
| 21 | 0.06751 | 0.49435 | 0.73136 | 0.80889 |
| 21.5 | 0.06778 | 0.50287 | 0.74962 | 0.83118 |
| 22 | 0.06794 | 0.50812 | 0.76094 | 0.84502 |
| 22.5 | 0.068 | 0.51 | 0.76499 | 0.84999 |
| 23 | 0.06795 | 0.50846 | 0.76166 | 0.84592 |
| 23.5 | 0.0678 | 0.50354 | 0.75105 | 0.83293 |
| 24 | 0.06754 | 0.49534 | 0.73346 | 0.81145 |
| 24.5 | 0.06718 | 0.48401 | 0.70938 | 0.78213 |
| 25 | 0.06672 | 0.46978 | 0.67948 | 0.74587 |
| 25.5 | 0.06615 | 0.45292 | 0.64456 | 0.70373 |
| 26 | 0.06549 | 0.43374 | 0.60554 | 0.65691 |
| 26.5 | 0.06474 | 0.41258 | 0.56338 | 0.60668 |
| 27 | 0.06389 | 0.38983 | 0.51909 | 0.55432 |
| 27.5 | 0.06296 | 0.36585 | 0.47365 | 0.50107 |
| 28 | 0.06194 | 0.34104 | 0.42799 | 0.4481 |
| 28.5 | 0.06085 | 0.31576 | 0.38298 | 0.39644 |
| 29 | 0.05968 | 0.29039 | 0.33937 | 0.34698 |
| 29.5 | 0.05844 | 0.26525 | 0.2978 | 0.30042 |
| 30 | 0.05713 | 0.24064 | 0.25876 | 0.25732 |
| 30.5 | 0.05577 | 0.21684 | 0.22264 | 0.21802 |
| 31 | 0.05435 | 0.19405 | 0.18968 | 0.18272 |
| 31.5 | 0.05289 | 0.17248 | 0.16001 | 0.15148 |
| 32 | 0.05138 | 0.15225 | 0.13365 | 0.12422 |
| 32.5 | 0.04984 | 0.13348 | 0.11053 | 0.10075 |
| 33 | 0.04826 | 0.11621 | 0.0905 | 0.08082 |
| 33.5 | 0.04666 | 0.10048 | 0.07337 | 0.06413 |
| 34 | 0.04504 | 0.08627 | 0.05888 | 0.05032 |
| 34.5 | 0.04341 | 0.07356 | 0.04678 | 0.03905 |
| 35 | 0.04177 | 0.06229 | 0.03679 | 0.02997 |
| 35.5 | 0.04012 | 0.05237 | 0.02865 | 0.02274 |
| 36 | 0.03848 | 0.04372 | 0.02208 | 0.01707 |
| 36.5 | 0.03684 | 0.03624 | 0.01684 | 0.01266 |
| 37 | 0.03521 | 0.02983 | 0.01272 | 0.00929 |
| 37.5 | 0.0336 | 0.02438 | 0.0095 | 0.00674 |
| 38 | 0.03201 | 0.01978 | 0.00703 | 0.00483 |
| 38.5 | 0.03045 | 0.01594 | 0.00514 | 0.00343 |
| 39 | 0.02891 | 0.01274 | 0.00373 | 0.0024 |
| 39.5 | 0.02741 | 0.01012 | 0.00267 | 0.00166 |
| 40 | 0.02593 | 0.00797 | 0.00189 | 0.00114 |
| 40.5 | 0.0245 | 0.00624 | 0.00133 | 0.00077 |
| 41 | 0.0231 | 0.00484 | 0.00092 | 0.00052 |
| 41.5 | 0.02175 | 0.00373 | 0.00063 | 0.00034 |
| 42 | 0.02044 | 0.00286 | 0.00043 | 0.00022 |
| 42.5 | 0.01918 | 0.00217 | 0.00029 | 0.00014 |
| 43 | 0.01796 | 0.00163 | 0.00019 | 0.00009 |
| 43.5 | 0.01679 | 0.00122 | 0.00013 | 0.00006 |
| 44 | 0.01567 | 0.00091 | 0.00008 | 0.00004 |
| 44.5 | 0.0146 | 0.00067 | 0.00005 | 0.00002 |
| 45 | 0.01357 | 0.00049 | 0.00003 | 0.00001 |

Source: Computed from the game's own shine rule, a Blinn-Phong highlight that sharpens with gloss, halfway between the key light and the eye.

## Saving redraws your page up to 3200 pixels wide, with crisp edges and paper fibre

Save picture doesn't blow up the screen. The worker packs the whole page afresh, and the same graphics program runs again with a print switch on, 3200 pixels on the long side, or 2400 on a phone or a device with little memory. At the full table's 512 cells across, that's 6.25 pixels for every cell.

Between cells it samples along curves rather than straight ramps. Where the cells around hold a real step, such as a marker line or a stain's edge, it squeezes the soft ramp back into a crisp edge, and it leaves smooth washes alone. It leans a little toward the ink, so thin lines stay whole, and the paper's fibres ruffle each edge. Pigment settles into the dips of the paper. Crayon wax only catches the tops of the tooth, so paper shows through in specks. Holes, char and the edges of goop are cut a pixel wide.

Then the things lying on the paper are drawn over it from their paintings, without what's in your hand and without the guides. A big drawing is made in bands of rows, each read back with a pause, so the game stays responsive while it saves.

This detail is rebuilt, not simulated. The grid underneath is the same one, and the fine fibre in a print is worked out from position alone, so every print has the same grain.

[Under the hood](<#hood-print>)

Saving redraws your page up to 3200 pixels wide, with crisp edges and paper fibre

Under the hood

### Saving redraws your page up to 3200 pixels wide, with crisp edges and paper fibre

The saved picture is the same paper shader with a print switch on. Between cells it samples with Catmull-Rom curves. Then, per colour channel, it looks at the lowest and highest values in the cells around: where they hold a real step it pulls the soft ramp toward a crisp edge, where they don't it leaves the wash alone, and the result is blended across cells so no seam shows. The paper's fibre nudges where each edge falls, leaning toward the ink; crayon wax turns the effect down.

Granulation: pigment shows a little stronger in the paper's dips. The fine fibre is layered gradient noise, some of it stretched and turned, and it tilts the surface as well as tinting it. The picture is read back in bands of rows and saved as a JPEG; an automated test checks it.

Try it: the screen and the saved picture, side by side The game's own ruleA small page of blue ink: a stain with a hard edge, a soft wash, a marker line and pencil hatching. Shown both ways, the same patch is drawn twice: on the left as on screen, on the right by the saved picture's rules. Draw marker lines with your finger, or press Draw a marker line. **What to look for:** the stain's edge goes from a ramp about ten pixels wide to about three, while the soft wash keeps its gradient. Grain appears in the paper and in the pigment. Zoom in to see that the print's edges stay crisp. \(Lit flat, without shadows, to keep it quick.\)

![A small page of blue ink drawn two ways: as on screen on the left, and by the saved picture's rules on the right](<https://professoroops.com/_assets/entity_0Q1resAavBwqec9jhsb7.png>)

ShowBothScreenSaved picture

Pixels per cell6.25

stain edge on screen—

in the saved picture—

a 512-cell page saved at this scale—

![From the screen: a blue marker line meeting a blob of blue watercolour, both with soft, blurred edges.](<https://professoroops.com/_assets/entity_Uvev9hEPzRLHDUKhe6hr.webp>)

In the game, on screen. The same 3 cm or so of one drawing, a marker line running into a watercolour dab, as the screen draws it.

![The same area from the saved 3200-pixel picture: the marker line and the watercolour edge crisp, with paper fibre showing in the paint and the paper.](<https://professoroops.com/_assets/entity_F4xxtOWj6bHLNFGZKyb8.webp>)

In the game, the saved picture. The same patch as the saved picture draws it, at the same scale. Two real captures, cropped and enlarged alike, never retouched.

## Every object on the table is a painting, and new drawing code must say why

Everything lying on the paper as an object is a painting: a cup, a toothpick, a bottle, the cat. Things on the table are painted from straight above, as a map shows them, in one gouache style with warm light from the upper left, the same side as the lamp in the graphics program. Tools in your hand are painted from the side, because that's how you see them held.

Each painting starts as a generated image, made with an image model to one shared style, with the game's own art as its guide and the tool's shelf icon beside it, so the thing in your hand is the thing on the shelf. Each one is picked by eye. Scripts then clean it, cut it into sprite sheets and measure things such as where a nozzle points from the paint itself. Parts that take a colour become a layer of their own, so one crayon painting serves every crayon colour.

A thing changes paintings as it changes state. In the released game that means squeezed bottles, a toothpick standing, broken and in pieces, clips open and shut, and each creature's poses. The next update adds more, such as bottles lying on their side, and balls whose panels come from the real shape of a football next update.

Two rules keep it so. Something whose painting hasn't arrived draws nothing until it has, and a tool whose art isn't finished stays off the shelf. And a test reads all the drawing code on the screen side: any function that draws shapes or text must be on a list, with the reason it isn't an object. The next update adds the functions that write pixels next update. Liquids, fire, shadows, light, cables, guides and the buttons' glyphs stay code by design.

Creatures are paintings that bend. A cat's body is one top-down painting stretched over a mesh that follows its bones, so it turns and stretches without a seam. Its legs and tail are painted pieces of their own, and a new pose fades in from another painting.

[Under the hood](<#hood-paintings>)

Every object on the table is a painting, and new drawing code must say why

Under the hood

### Every object on the table is a painting, and new drawing code must say why

A test parses all the screen-side drawing code and finds every function that draws shapes or text on a canvas, or builds an SVG. Each must be listed with why it stays code: 92 at release, 106 next. The next update also lists the 10 functions that write pixels, each with its kind and "never an object". A function that stops drawing must leave the list.

What it can't see: a new drawing inside a function already listed, and the shaders. Whether a written reason is honest is a person's call. One exception to say out loud: 12 functions draw creatures in code, used only without WebGL2, when their paintings fail to load, or in a saved picture before they arrive.

Creatures: each body is a painting on a fine mesh that follows the bones every frame, all of them drawn together on the graphics chip; their shadows are blended so overlapping parts never darken twice. At release: 94 sprite sheets, 347 sprites, 615 colouring pages.

What code may still draw, and why"Other" is materials, particles, cables, guides and what the simulation keeps on a thing. "Fallback" draws the animals in code, used only without WebGL2, when their paintings fail to load, or in a saved picture before they arrive.

Of 106 functions on the list, 59 draw other things, 15 shadows, 14 light, 12 the fallback animals and 6 UI glyphs.

Data table

What code may still draw, and why

|  | functions |
| --- | --- |
| other | 59 |
| shadows | 15 |
| light | 14 |
| fallback | 12 |
| UI glyphs | 6 |

Source: The 106 functions on the test's list in the next update next update \(92 at release\).

![A page in the game seen from above: the cat asleep beside a cup of coffee, a toothpick stuck in a blob of red paint, and a ketchup bottle being squeezed, each a painting with a soft shadow.](<https://professoroops.com/_assets/entity_HvtyUfVKxsBtwX3bi1w4.webp>)

In the game. A cup, the cat, a toothpick in paint and a ketchup bottle being squeezed: each is a painting, placed where the simulation says it is.

## Promises to families, such as no ads and no harm to animals, are checked by code wherever code can hold them

### No animal is ever hurt, and a test throws trouble at every one

Professor Oops has animals: flies, ants, a pigeon, a cat, a dog and snails. The rule is that none of them is ever hurt. They dodge, flee or tuck in. A test puts each one through a long list of trouble, from a hammer and a rolling pin to a fire lit beside it, the oven and a long wait. It checks that nothing lands on the animal, no flame is lit under it, it doesn't vanish, and it moved when something came at it.

At release the list held 61 kinds of trouble for 8 animal states, 488 runs in all; about 25 of those kinds use tools still being finished. The next update has 97 kinds for 10 states, 970 runs next update.

Safety comes from the world giving way, not from quick animals. Every press, whatever the tool, goes through one shared rule that reads what is pressing where. A small creature is slid clear by the press itself. In the next update a heavy thing over a cat or a dog waits in the air until the animal has moved, and is taken away if it doesn't next update.

### There are no ads, and the advertising permission is taken out

The game holds no advertising code and no analytics library from another company. In one study of 135 apps used by young children, 95 % had advertising <sup>[Meyer 2019](<https://professoroops.com/en/inside/research-respect/#meyer-2019-respect>)</sup>. On Android, the advertising-ID permission that a library could slip in is removed, along with storage permissions, and a test holds the list. On iOS the app declares no tracking. The released game never uses the microphone or the camera. Anonymous counts, with no identifier and no time finer than a day, can be switched off by a grown-up, and start off in the UK and most of Europe.

### The apps play without a connection

The whole game is inside the app, the WebAssembly kernels too, packed into the code so nothing is fetched. At release the painter's voice is in the app as well. In the next update, voice and most music become packs fetched when there's a connection, and everything works without them next update. The browser version needs a connection to load.

### Real things only is a decision, made on the first day

On the first day, with only a prototype running, several hundred ideas were written down and scored for fun, ease, how fresh they were and how much "you are never allowed to do that" thrill they gave. Three of the eight highest new ideas were a giant's footstep, a giant sneeze and a black hole. The same day a hard rule overrode the scores: only real things a child could really do to paper. No monsters, no giants, no black holes. No test can check that. It's held by whoever decides what gets built. Some gentle ideas that scored low arrived anyway: red cabbage that changes colour and a tea-stained wash as tools, and a sun print and a pencil rubbing with no tool of their own, because the shared physics made them.

### Grown-up things sit behind a sum a young child can't do

Buying, the grown-ups' area and extra play time sit behind a sum such as 37 \+ 40. It's meant to stop a young child, and the code says so: it's a sum, not a lock. Offers are rare: at most three asks, a week apart, never again after "No thanks, don't ask again". A parent can set a daily limit of 15, 30, 45 or 60 minutes, off by default, and the painter says so two minutes before the end.

[Under the hood](<#hood-promises>)

Promises to families, such as no ads and no harm to animals, are checked by code wherever code can hold them

Under the hood

### Promises to families, such as no ads and no harm to animals, are checked by code wherever code can hold them

The no-harm test crosses every animal state with every listed harm. Each run sets out a small page with the animal on it, brings the harm, then steps the real engine for a few seconds at 60 Hz, watching every press and every flame. It fails if a press above what the animal can bear lands on it, a flame is lit or burns under it, it vanished, or something came at it and it didn't move. 42 of the 61 harms \(69 of 97 next\) require it to move; the rest it may sit out. Each animal states what needn't move it, such as a cat bearing a lint roller.

"Unhurt" has no number: animals have no health to read. Every press, from any tool, goes through one shared danger rule that reads the contact, not the tool. The waiting for an animal to move is in the next update.

Android: the advertising-ID, network-state and external-storage permissions are stripped from the merged manifest, and a test pins what's left, the internet and store billing. iOS: the app declares no tracking and no tracking domains; the only permission it asks for is adding to the photo library. The kernels travel inside the game's code.

The idea ranking: a weighted score of fun, ease, freshness and thrill, each from 1 to 10, over 176 ideas. The gate: a two-digit sum, a few tries.

### Most promises are held by a test; one is held by people

a test

#### No animal is ever hurt

Every animal state meets every listed kind of trouble: 488 runs at release, 970 in the next update next update.

*Checked by a test on every run*

a test

#### No ads, no advertising ID

No advertising code and no analytics library from another company. The advertising-ID and storage permissions are removed from the Android build.

*Pinned by a test*

how it's built

#### The apps play offline

The whole game, the WebAssembly kernels too, is packed into the app, so nothing is fetched to play.

*The kernels travel inside the game's code*

code

#### Grown-up things need a sum

Buying, the grown-ups' area and extra time sit behind a sum like 37 \+ 40. It's meant to stop a young child, not to be a lock.

*Written into the code, and called a sum there*

people

#### Real things only

No monsters, no giants, no black holes. No test can check that; it's held by whoever decides what gets built.

*a decision made on the first day*

## One test can follow a rolling pin, a pencil or a saved picture through layers the whole game shares

Each of these runs from something a child does, through a layer many systems share, to what follows. A test pins each one.

Shared: a fingerprint of the copied source

### Change one line of the paper's water loop

The test run fails, naming the function, until the WebAssembly copy is ported. Pinned by a test.

*[Fast on a cheap tablet](<https://professoroops.com/en/inside/speed/>)*

Shared: dent in the ground height

### Press a pencil line hard, then shade over it

The grey skips the pressed line, which stays clean, with dark edges where the groove's walls catch the grey. Pinned by a test.

*[Pencil, crayon and paper](<https://professoroops.com/en/inside/drawing/>)*

Shared: one press, read by the shared danger rule

### Roll a rolling pin over an ant

The ant is slid clear and never left under it. Pinned by a test.

*[Animals with their own minds](<https://professoroops.com/en/inside/creatures/>)*

Shared: two layers folded into one texel by solving the shader backwards

### Pour oil over dried ketchup

The ketchup hardly pales, and no hard line shows at the oil's front. Pinned by a test.

*[Runny, sticky and oily](<https://professoroops.com/en/inside/liquids/>)*

Shared: a fresh full pack, the same shader with print on

### Save a picture of a page with a burnt hole

The hole and the table under it are in the saved picture too; a browser check makes sure a real 3200-pixel JPEG comes out in worker and in-thread mode alike.

*[Heat and fire](<https://professoroops.com/en/inside/heat/>)*

0.56

Lines of test for each line of game at release; 0.60 in the next update.

5 of 7

Compiled kernels that match their readable twins to the last bit, each tested alone.

488

Runs of trouble at release in the no-harm test: 8 animal states × 61 kinds, about 25 of them with tools still being finished.

108 of 112

Changes to a page's fingerprint that came with a note saying what moved.

0

any types and @ts-ignores in the game's TypeScript, under strict mode.

3200 px

Across a saved picture, redrawn from the page's own numbers, never enlarged from the screen.

## Where we simplify, and why

Checking is a craft of choosing what to hold fixed. These are the gaps between what's checked and what ships, and between the real world and the picture, on purpose.

**In real life.** The apps run compiled kernels

**In the game.** Behaviour tests run the readable JS

**Why that's enough.** A change to a loop shows up in its own readable test. Parity bridges the rest: five kernels exact on their own, paper and floating layers within rounding, the whole page within 2e-3. At low detail the apps' wind does a fuller solve, on purpose.

**In real life.** Thousands of phones and graphics chips

**In the game.** Headless Chrome on desktop computers, plus one purchase test on an iPhone simulator

**Why that's enough.** Fast and repeatable. Real devices are tried by hand; there's no device farm and no screenshot regression suite.

**In real life.** A test shows the behaviour is right

**In the game.** A fingerprint shows it's as before

**Why that's enough.** It stops accidents and keeps a logbook. Few tests compare with real-world figures; the scene tests check what a child would recognise.

**In real life.** A child could do anything to an animal

**In the game.** A listed set of harms times animal states, one position and page size each

**Why that's enough.** Bounded and repeatable. A harm nobody listed isn't tested, so the safety rests on the shared danger rule, and the list checks it.

**In real life.** Light has a spectrum and paint scatters it

**In the game.** Three channels of absorbance that add

**Why that's enough.** One number per channel; washes multiply, as thin glazes do. Lost: colours that match under one light and not another, and the strong lightening of white paint.

**In real life.** A table seen in three dimensions

**In the game.** A height field, one fixed key light, one fixed view, shadows traced a short way toward the light

**Why that's enough.** Everything lives in flat fields, at a bounded cost per pixel. No overhangs, no bounce light; gloss is a highlight, not a mirror.

**In real life.** Surface tension pulls fine ripples flat

**In the game.** The fine part of a liquid's slope is toned down for smooth materials

**Why that's enough.** Otherwise each cell would light as its own facet, in specks. It's drawn smoothing; a puddle's water isn't smoothed.

**In real life.** A print shows detail finer than the grid

**In the game.** Edges rebuilt, fibre worked out from position

**Why that's enough.** A 512-cell page becomes a 3200-pixel print that looks like paper. The detail is invented, and every print has the same fine grain.
