Build log

A Python interpreter for kids, inside an offline WKWebView

Rocket Cadets teaches children 8–12 to write real Python on an iPad. It has no backend, no accounts, and makes no network requests at all. Here is how it is put together — and the four bugs that shaped it.

My daughter outgrew the drag-and-drop block apps. Everything past that point assumed an adult: an account, a subscription, a laptop, a network. So I built the thing I wanted her to have — an iPad app where a kid types actual Python into a real editor and gets real tracebacks back.

The interesting constraint wasn't the curriculum. It was this: a nine-year-old's coding environment should not need the internet, and should not have a profile of that child anywhere. Not as a privacy feature bolted on at the end — as the thing the architecture is built around. What follows is how that worked out in practice.

1. Why a WebView is the right answer here

Engineers groan at WebView apps, usually for good reason. So let me argue against myself first: the App Store has a guideline (4.2) specifically about apps that are just a repackaged website, and most WebView apps deserve the suspicion.

This one inverts the usual reasoning. The product is fundamentally browser technology:

  • Skulpt — a Python interpreter implemented in JavaScript. Real parser, real exceptions, real tracebacks.
  • CodeMirror — the code editor.
  • HTML5 canvas — the hand-built AI-literacy playgrounds, where a kid trains and then deliberately fools a tiny classifier.

There is no native iOS equivalent of any of those. Rebuilding them natively would mean writing a Python interpreter and a code editor from scratch, on a platform that can't ship an interpreter it downloads anyway. The web stack isn't a shortcut here; it's the only stack that has the parts.

So the web app is the canonical source, checked into the repo and bundled into the binary. The native layer then has to earn its place — and it does, in four specific ways: the load path, the persistence bridge, StoreKit, and an iPad code keyboard. Each of those is a section below.

The web app isn't a website the app displays. It's the product, and the native shell is the runtime it needs to survive on a device.

2. The load path, and a one-word bug that cost a day

At build time, a script zips the webapp/ directory into assets/codequest.zip and writes a content hash to a generated TypeScript file. On first launch — and on any launch where that hash has changed — the app unzips the bundle into its document directory and points a WKWebView at the extracted index.html over file://.

Here is the bug. This looks completely reasonable:

<WebView
  source={{ uri: indexUri }}
  allowingReadAccessToURL={indexUri}   // ← wrong: this is the FILE
/>

And in the simulator, it works. On a device, the HTML loads, the page paints, and #app renders completely empty — with no error anywhere.

allowingReadAccessToURL is not a permission check on the thing you're loading. It's the root of the subtree WKWebView will grant file:// read access to. Point it at the HTML file and you have granted access to exactly one file: the HTML. Every sibling <script src="js/…"> and stylesheet is then silently blocked — no console error, no failed-load callback, just an empty root element. The fix is one word:

  allowingReadAccessToURL={CODEQUEST_DIR}   // ← the extraction DIRECTORY
Why the simulator lied. The simulator's more permissive filesystem sandbox masked it entirely, so the failure only ever appeared on real hardware. This is now a load-bearing comment in the source — it is exactly the kind of thing a future contributor would "clean up" back into a bug.

3. Persistence: mirroring localStorage to native

The web app stores everything a child has earned — XP, badges, progress through 15 worlds, and the code they've typed — in localStorage. That is fine in a browser. It is not fine in a file:// WKWebView, where the OS will evict that storage under pressure. For a kid, eviction means losing weeks of work, and there is no cloud copy to fall back on, because there is deliberately no cloud.

So there's a bridge, injected as JavaScript by the native shell. It does two things:

  1. Restores a snapshot into localStorage before the page's own scripts run — via injectedJavaScriptBeforeContentLoaded. That ordering is the whole trick: the web app boots up believing it simply had its data all along, and needs no knowledge that a native layer exists.
  2. Mirrors every subsequent write back out to native AsyncStorage over postMessage.

The web app remains a completely ordinary web app. It reads and writes localStorage; the durability is somebody else's problem. That separation is what keeps the same codebase runnable in a plain browser, which matters more than it sounds — every automated test runs against the real thing in Chromium, not a mock.

The bug in the debounce

Saving the child's typed code introduced a subtle one. Every lesson keeps its own buffer, keyed by lesson id, so navigating away and back doesn't reset the editor to the starter code. Saves are debounced, because saving on every keystroke is wasteful.

The obvious implementation is wrong:

// WRONG — reads the global editor when the timer fires
const save = debounce(() => {
  state.code[currentLessonId] = editor.getValue();
}, 400);

The app re-renders a lesson from scratch on every hashchange, swapping in a new editor. So if a child types and then navigates within the debounce window, the timer fires after the swap and reads the new lesson's editor — writing the new lesson's code into the old lesson's slot. Two lessons corrupted by one keystroke of bad luck.

The fix is to capture both the code and its lesson id at change time, not fire time:

// RIGHT — both values captured when the change happened
const onChange = (id, code) => debounce(() => {
  state.code[id] = code;
}, 400);

This is the sort of bug that never reproduces while you're looking for it, and there's now a test suite that navigates between lessons mid-debounce specifically to keep it dead.

4. Teaching problems that turn into parsing problems

The most interesting engineering in this project came from teaching requirements, not platform ones. Two examples.

"Try it" has to know what came before

A lesson explains an idea using several small code examples, and later examples build on earlier ones. Block one does import turtle and creates t; block four just calls t.forward(100). Each block has a "Try it" button that drops it into the editor and runs it.

Run block four alone and the kid gets NameError: name 't' is not defined — for a mistake they did not make. Concatenating every earlier block instead is worse: unrelated examples run again, printing confusing output.

What it does is compute a transitive dependency chain. For each block it extracts the names that block defines (imports, assignments, for targets, def/class), then walks backwards to find the nearest earlier block defining each name the block uses, recursively:

const definedNames = code => {
  const n = new Set();
  for (const m of code.matchAll(/^\s*(?:import|from)\s+(\w+)/gm))       n.add(m[1]);
  for (const m of code.matchAll(/^\s*([A-Za-z_]\w*)\s*=(?!=)/gm))        n.add(m[1]);
  for (const m of code.matchAll(/^\s*for\s+([A-Za-z_]\w*)/gm))           n.add(m[1]);
  for (const m of code.matchAll(/^\s*(?:def|class)\s+([A-Za-z_]\w*)/gm)) n.add(m[1]);
  return n;
};

Only the blocks actually needed get prepended. Unrelated ones are skipped.

But there's a catch that took a second pass to see. A comment reading # now the turtle draws a square would make an unrelated block look like it referenced turtle, dragging in setup it never needed. So every block is masked before analysis — triple-quoted strings, quoted strings, and line comments all replaced with whitespace — so that prose can never masquerade as a variable reference.

The same masking rule, in a different feature

A kid using this app alone can tap any Python term in a lesson to get a plain-language card explaining it. That's a linkifier over the lesson HTML, and it has one absolute correctness rule: a plain English word must never be labelled as code. Tell a nine-year-old that the ordinary word "for" is a Python keyword, in a sentence where it isn't, and you have actively made them more confused than before they tapped.

So the same masking runs again — print("...for...") never links the for inside the string — plus a rule that method terms match only immediately after a dot, so a variable a kid named color is never mislabelled as the method .color().

Two features, one invariant: strings and comments are not code. When the same rule shows up twice in unrelated places, it's usually telling you something true about the domain.

5. Payments with no server

The app is free to download; World 1 is free forever. One non-consumable in-app purchase unlocks the other fourteen worlds. No subscription — parents have enough of those, and a fixed, finite, offline curriculum has no recurring cost to justify one.

The relevant part is that this needs no backend either. StoreKit 2 verifies transactions on device: Apple signs the transaction, and the framework checks that signature locally. There is no receipt-validation server, because there is no server.

The grant path is a purchase listener, which also covers a case that's easy to miss — Family Sharing's "Ask to Buy", where a parent approves on their own device and the grant arrives out of band, potentially much later.

A device test matrix then turned up the gap: force-quit mid-purchase, relaunch, and the app stayed locked despite the Apple ID now owning the unlock. So there's a silent startup self-heal that re-grants when the account already owns the product — covering interrupted purchases, reinstalls, and Family Sharing siblings. Both paths call the same idempotent grant.

Deliberate weakness. This entitlement check is client-side, and a determined adult can bypass it. That is the correct trade for a one-time purchase on an offline kids' app. The alternative is an account system and a server — which would mean collecting data on children, permanently, to protect revenue from the small number of people willing to jailbreak an iPad over a coding app. The privacy story is worth more than the leakage.

6. Offline as the security model

"Works offline" is usually a feature. Here it's the enforcement mechanism. The WebView is pinned shut:

originWhitelist={['file://*']}
allowFileAccessFromFileURLs={false}
allowUniversalAccessFromFileURLs={false}

The app makes no outbound requests. No analytics, no crash reporting SDK, no ad network, no fonts fetched at runtime, no accounts, no login. Put the iPad in airplane mode and every lesson, the interpreter, and every AI playground behave identically — because that is already the only mode there is.

This is what makes the App Store privacy label an honest "Data Not Collected". It isn't a policy promise about what we do with data we hold; there is no data to hold, and no path by which any could leave the device. The claim is verifiable by anyone with a network monitor, which is the only kind of privacy claim worth making.

It does close doors. Progress is device-local, so it doesn't follow a child to a second iPad. That's a real cost, disclosed plainly to parents in the app, and I'd make the same call again: syncing a child's progress means holding an identifier for that child on a server somewhere, forever.

The one genuinely AI-flavoured part of the app — the playgrounds where kids train and fool a small classifier — runs entirely in canvas and JavaScript. Nothing talks to any AI service. There is deliberately no chatbot for an eight-year-old.

7. What this architecture costs

Three honest limitations.

Skulpt is a subset. It's a genuine interpreter — real parser, real exception objects, real tracebacks — and it covers what an 8–12 curriculum needs: variables, types, strings, lists, dicts, loops, conditionals, functions, and turtle graphics. It is not CPython, and there is no stdlib beyond what it implements and no C extensions. For a child learning what a for loop is, that gap never gets hit. I'd rather be upfront about it than let anyone discover it after paying.

Typing code on glass is hard. iOS's on-screen keyboard buries every character that matters — colons, brackets, quotes, indentation. There's a custom accessory bar for those, and getting it positioned correctly above the system keyboard across the different iPhone and iPad keyboard geometries is still being tuned. Anyone who tells you cross-device keyboard positioning on iOS is solved is running one device.

No syncing, by construction. Covered above. It's the price of the privacy model, and it's the right price, but it is a price.


The through-line, if there is one: almost every hard problem here came from the audience rather than the platform. A grown-up hitting NameError on a code snippet shrugs and scrolls up. A nine-year-old concludes they're bad at this and closes the app. That difference is what turned a "Try it" button into a dependency solver, and a glossary into a masking problem.

The offline, no-server, no-accounts constraint made the engineering harder in the load path and the persistence layer, and dramatically easier everywhere else. There's no auth, no sync conflicts, no GDPR data-subject flows, no incident plan for a breach of children's data, no server bill that grows with success. For a solo developer building for kids, that trade is not close.

Rocket Cadets is built by one parent-developer under Meno Data AB in Sweden. Questions about any of the above: [email protected].

See it running

World 1 runs in a plain browser — no download, no signup. Or get the app: free to download, World 1 free forever, $14.99 once to unlock all 15 worlds.

Try World 1 in your browser   App Store →