Insights

Two kids built games, so I gave games a content type

Two kids built games, so I gave games a content type

My niece is 10 and my nephew is 11, and between them they finished three browser games over a handful of afternoons this summer. Lulu built one, Otto built two, and then I had a small publishing problem: where does a kid's game live on a studio site? Three hand-coded pages would have been quicker for an hour and worse forever, so I gave games a content type instead, and deployed the whole lab on 29 July.

What did they actually finish?

Three playable folders, each one running in a browser with no install, no account, and nothing on the server but static files. You can play all three at the Game Lab right now.

Lulu built Habitat Quest. An animal turns up in disguise with three clues written in its own voice, and the clues never name the place: "I waddle on land but I'm built for the sea." You then build a habitat out of five slots, climate, terrain, water, plants and shelter, release the animal into what you made, and find out whether you read the clues right. There are 24 animals in the level list, spread across six continents, six of them in Africa and only two in Antarctica. She wrote every clue by hand and would not let me write any of them.

Otto built a tank game twice. The first version is the workshop one, the second is the one he wanted: a garage, a branching upgrade tree, a battlefield picker, and a screen that searches for online players before every match. There are no online players. He built the searching screen anyway, because the game he plays has one and a real game has one, and I have thought about that decision considerably more than he has. The tree holds eleven upgrades, ending in a Twin Cannon that costs six points and fires two shells at once.

Habitat Quest running in a browser: three numbered clues across the top, a low-poly Antarctic sea with ice floes and a mystery egg in the middle, and a builder drawer at the bottom showing five filled slots reading Freezing, Ocean, Saltwater, None and Open above a green Release the animal button.
Level one, five pieces placed, one button away from finding out whether the clues meant a penguin.

Why a content type instead of three pages?

Because I already know what happens to a hand-written page. It works, it never gets edited, and eighteen months later it is the one file nobody dares touch because the markup drifted away from everything around it. A table costs an afternoon and pays out every time a kid finishes something new: the next game is a folder and a form, not a deploy.

The better reason showed up while I was designing the columns. A schema is a set of questions, and once learned was a column, every game page had to end with what the game taught its builder, which meant I had to ask them. Lulu's answers are about design: a clue that is fair but not obvious is harder than the code that checks the answer, and a level nobody solves on the first try is broken rather than difficult. Otto's are about systems: an upgrade tree is a budget, and if every branch is affordable then choosing between them stops meaning anything. Neither of those sentences exists if the page is a hand-written div.

What does a game record carry?

Fifteen columns that a person fills in, plus an id and a timestamp, and every one of the fifteen decides something a visitor can see. Nothing on a game page is typed into a template.

A table of the games content type. Each row names a column, the real value in the Habitat Quest record, and where it renders: slug becomes the URL, title feeds the hub card and the JSON-LD name, builder and builder_age feed the player chip and the Built by line, built_on is the When line, kicker is the Kind line, summary is the card blurb and meta description, body is the prose column, controls is parsed as key and description on each side of a pipe, learned becomes the What it taught band, tech is the Made with line, path drives the Play button and is written only through the folder scan, cover is the card image, and status and sort decide whether and where a card appears.

Two of those columns are small parsers rather than plain text. controls is one line per control, with the key on the left of a pipe and what it does on the right, which the module splits into a definition list. learned is one lesson per line. Both go through the same small helper, and the deliberate choice is that neither field is HTML: a ten year old typing into a textarea should not be able to break a page layout, and the strictness of the format is what keeps the rendering predictable.

How does a folder become a game?

The module goes and looks. A playable game is a directory under cms/games/play/ with an index.html inside it, so the scan is a filesystem walk rather than a registry anyone has to maintain:

foreach (glob($base . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
    $name = basename($dir);
    if ($name[0] === '_' || !is_file($dir . '/index.html')) continue;
    $out[$name] = [
        'dir'   => $name,
        'path'  => GAMES_DIR . '/' . $name . '/',
        'files' => count(glob($dir . '/*') ?: []),
        'mtime' => filemtime($dir . '/index.html') ?: 0,
    ];
}

Anything the scan finds that has no row yet appears on the admin screen under "Found in the games folder", with its file count and the date the index last changed. Adopting one inserts a draft named after the folder, which then gets a title, a builder, a summary and its lessons before it goes public. The underscore prefix is the escape hatch: a folder called _scratch is skipped, so an unfinished experiment can sit in the same directory without ever being offered.

Writing the save handler is where I caught the real problem in my own design. The path is an ordinary form field, and an ordinary form field is a string somebody sends me, which meant the Play button was one careless value away from linking anywhere on the server. The guard is three lines, and it shipped in the same commit as the module rather than in a follow-up:

/** A posted path, forced back to a real scanned folder. Unknown => ''. */
function games_clean_path(string $raw): string
{
    $dir  = basename(rtrim(trim($raw), '/'));
    $scan = games_scan();
    return isset($scan[$dir]) ? $scan[$dir]['path'] : '';
}

The value is reduced to a bare folder name and then handed back only if the scan actually found that folder, so an unknown name resolves to an empty string and the game simply has no Play button. What generalizes is the shape rather than the function: the safest whitelist is one you do not maintain, because it is generated from the thing it is describing. The directory listing is the allow list. Nobody has to remember to update it, which is the only kind of security control that survives a year of me being busy.

Routing needed the same care for a duller reason. /games is a CMS page and games/ is also a real directory full of the kids' files, so exactly two paths are handed to the application and everything deeper is served as static files:

RewriteRule ^games/?$ index.php [L,QSA]
RewriteRule ^games/play/?$ index.php [L,QSA]

What does the page tell a machine it is?

That it is a video game, in the plainest terms available. Each game page emits a schema.org VideoGame node carrying the title, the summary, the cover, the builder as author, the kicker as genre, and gamePlatform set to "Web browser"; the hub emits a CollectionPage whose hasPart lists all three. Both pages carry breadcrumbs.

Worth being honest about what that buys: nothing in search results. Google's gallery of structured data features lists article, breadcrumb, carousel, course list, dataset, event, job posting, local business, product, recipe, review snippet, software app, video and twelve more, and there is no video game entry among them (25 rows, counted 4 September 2026). No rich result exists to win. The markup is there because it is a true description of the page, and increasingly the machines reading a page are not the ones drawing search results, which is the same reasoning behind how an assistant decides what to recommend.

The Habitat Quest page on ulric.studio: headline, summary, and a black panel holding the Play button beside four facts, Built by Lulu age 10, When Summer 2026, Kind Puzzle, Made with HTML CSS JavaScript and Three.js.
Every line in that panel is a database column. So is the Play button, and it is the only one that has to be true about the filesystem.

What are they actually learning?

The loop, mostly. At the top of Otto's first game there is a block of plain numbers with a comment beside each one:

const TWEAK = {
  playerSpeed:    16,   // how fast you drive (units per second)
  turnSpeed:      2.4,  // how fast you turn
  bulletSpeed:    60,   // how fast your shells fly
  reloadTime:     0.35, // seconds between your shots (smaller = faster)
  maxHealth:      100,  // your health starts here (the health bar = 100%)
  enemySpeed:     7,
  enemyReload:    1.6,
  // five more below: enemy damage, your damage, enemy health, ram damage, starting enemies
};

The rule is one change at a time: edit a number, save, reload, play for thirty seconds, keep it if the game got better and put it back if it did not. He set the reload to 0.1 in the first week and discovered inside a minute that a machine gun is boring, and he could say why. That is the same shape as the loops I run in production, minus the deploy step: change one thing, look at the result, keep or revert.

I do not hand them code. They say what they want in plain language, I put the question to a model with them watching, and then the part that matters starts, which is reading what came back and deciding whether it does the thing they asked for. Otto found his own best bug that way: at Size II his tank was crushing trees it had not reached yet, because the collision radius grew before the model did. Nobody could have told him that. He drove into a tree and noticed.

There is a small pile of research on what happens when novices work with a model, and most of it is about undergraduates rather than children, so I will not stretch it. The most useful recent piece is a randomized controlled trial written up in August 2026 by researchers at Bocconi, Berkeley, Duke and OpenAI, which put 1,053 first-year students into four conditions: training in causal reasoning, ChatGPT access, both, or neither. It is a working paper rather than a peer-reviewed one. Their conclusion, in their own words:

Overall, we find that human causal reasoning produces effects independently of LLMs, particularly on the diversity of ideas, and therefore it is worth cultivating causal reasoning in a world in which students use LLMs.

Read that next to a boy editing one number to find out what it was holding up. The trial found that the model raised expert evaluations of a well-defined business task while causal training did not, which is not a flattering result for the teaching, and the authors are partly employed by the company whose product they tested, which is worth saying out loud. What survives both caveats is the finding on diversity of ideas: the thinking a person brings is the part the model does not supply. That is the whole reason I make them read the answer rather than paste it, and it is the same discipline I keep for my own work, where the files that keep slop out of my projects exist for exactly this.

Otto's other build is a Game Brain: a small database of tank facts he can search in plain English and add his own to. It stays on my Mac rather than the web, because an eleven year old with a public database is an incident waiting to happen. It is still the clearest way I have found to explain retrieval to anyone, which is that you do not ask the model to remember, you give it somewhere to look, and everything I have written about keeping a knowledge base current is that same idea with more plumbing.

The one rule I wrote for myself

Their screens keep their punctuation. This site has a hard rule against emoji and em dashes in anything I write, and both are all over the kids' game screens: a toolbox in a heading, hearts for lives, a dash where I would use a comma. Those stay. The rule governs studio copy, and a game built by a ten year old is not studio copy. It was the easiest call in the build and the one I am most sure about.

The credit line at the top of the hub is a row of arcade chips: P1 Lulu, age 10. P2 Otto, age 11. Then a third chip, dashed rather than solid, that reads "insert coin to join". It has been empty since July. The folder scan checks for a new one on every visit to the admin screen, which is a strange way to hold a place for a kid who has not asked yet, and it is the only part of this build with nothing to verify.

Common questions

What is the Game Lab on ulric.studio?

A public page at /games holding the browser games my niece and nephew finished over a handful of afternoons in the summer of 2026, plus a page per game explaining how it was made and what it taught its builder. All three games run in a browser on a phone or a laptop, with no install and no account.

Why make kids' games a CMS content type?

Because a hand-written page never gets edited and drifts out of step with the rest of the site, while a table costs one afternoon and makes the next game a folder and a form rather than a deploy. Designing the columns also forced the useful question: once "learned" is a field, every game page has to end with what the game taught the kid who built it.

How does the CMS know a game exists?

It scans the filesystem. Any directory under cms/games/play/ that contains an index.html and is not prefixed with an underscore shows up on the admin screen as an unlinked folder, with its file count and last-changed date; adopting one inserts a draft row named after the folder, which is filled in and then published.

Can a game page link to any file on the server?

No. The path field is reduced to a bare folder name and returned only if the folder scan found it, so an unknown value resolves to an empty string and the page renders with no Play button. The directory listing is the allow list, which means nobody has to maintain one.

Does schema.org VideoGame markup produce a rich result?

Not on Google. Its gallery of structured data features has no video game entry as of 4 September 2026, so there is no rich result to win. The markup is on the page because it is a true description of what the page is, which matters more as assistants rather than search pages read the site.

Related

← All insights