DadSchool is a home learning site I built for my kids, grades 1 to 5, at dadschool.yuriystasyuk.com. They pass tests, unlock achievements, and each achievement is worth 10 points. One point buys one minute of games or parent-picked videos. The promise on the landing page is that points are spent only once play starts.
In October a kid reported that the slingshot game went black and their gems drained anyway. They were right, and the fix touched every game in the arcade.
How it was billing
The front end is a Next.js static export on Cloudflare Pages. Points and progress live in a Cloudflare Worker with D1. Spending goes through start, checkpoint and end routes for each session.
The arcade billed by wall-clock time from when a game mounted. That leaked in four places:
| Where | What happened |
|---|---|
| Slingshot game loading | Billing started on the iframe load event, before about 3 MB of game had finished loading |
| Slingshot game crash | A lost WebGL context or an error in Phaser's loop leaves a black or frozen canvas, and the clock kept going |
| Hidden tab | Countdowns in Snake, Flappy Birdy and Doodle Jumpy kept ticking in the background |
| Snake game over | Billing continued until the kid pressed Continue |
Make each game say when it's playing
The fix started with a contract. All five arcade games now report play and pause through an onPlayingChange prop and count only visible play time. The reward route runs its billable clock only while the game says it's being played and the page is visible. When a game ends, the route bills the smaller of the game's own count and its clock.
Brick Breakout was already frame-driven, so it only needed to report. The hard one was the slingshot game, which is a third-party Phaser game running in an iframe that I can't easily rewire.
A heartbeat from inside the iframe
So I added a small bridge script inside the frame. It wraps Phaser's update function to note when the last frame actually ran, and posts a heartbeat to the parent once a second:
var originalUpdate = game.update;
game.update = function (time) {
lastFrameAt = Date.now();
return originalUpdate.call(this, time);
};
window.setInterval(function () {
send('heartbeat', {
ready: state !== '' && state !== 'Preloader',
frameAgeMs: lastFrameAt === 0 ? -1 : Date.now() - lastFrameAt,
paused: !!game.paused,
contextLost: contextLost,
hidden: document.visibilityState === 'hidden',
});
}, 1000);
frameAgeMs is the useful field. A Phaser crash doesn't fire anything nice. The loop just stops, so the age of the last frame keeps growing. A webglcontextlost listener on the canvas covers the other kind of black screen.
The wrapper on the parent side turns heartbeats into a state:
- a frame older than 3 seconds is a bad heartbeat
- three bad heartbeats in a row means stuck
- no heartbeat for 6 seconds means the iframe itself is gone
- loading gets 60 seconds before the kid is offered a retry
The clock starts when the game's menu is up, not when the iframe loads. When it gets stuck, the overlay says "Uh-oh, the game got stuck," tells the kid the gems stopped counting when it froze, and offers a reload that retries with the canvas renderer instead of WebGL.
The local test ran against a local Worker with a test student. We froze Phaser's loop on purpose and the stuck screen showed up in about 5 seconds with the countdown frozen. That run billed 31 seconds against 32 on screen, out of 52 seconds of wall time. A Snake run with a simulated hidden page billed 45 seconds out of about 73.
The server was already half right
The Worker doesn't trust the browser's elapsed time either. At every checkpoint it bills the smallest of three numbers:
const deltaSeconds = Math.min(
clientDeltaSeconds, // what the browser reports
Math.ceil(serverDeltaSeconds * 1.2), // wall time since last checkpoint, plus 20% slack
maxBillableSeconds, // what the balance can cover
);
(Trimmed from the real function.) That cap meant no Worker change was needed for this bug. The server could stop a client from billing more than real time, but it had no way to know the real time was spent staring at a black rectangle. Only the game could know that, which is why the fix lives in the browser and the cap lives on the server.
Videos have their own edge cases
Videos check in every 30 seconds and whenever the player goes from playing to not playing. On visibilitychange to hidden and on pagehide, the page sends a checkpoint with keepalive: true so the request survives the tab closing. The library re-reads the real balance on pageshow, so coming back with the browser back button shows what was actually billed.
Each video session also stores a client_instance_id. If a second tab tries to play the same video, the Worker returns a 409 telling the kid it's playing somewhere else and to try again in about 90 seconds, which is when an abandoned session counts as stale.
My old "playing" signal measured whether a component had mounted. The new one measures whether frames are being drawn on a visible page, and that's the one the kids get billed by now.
Top comments (0)