learnwith.games 作者如何以 Astro、固定種子生成器及 55 個測試檔建立課程對應的兒童學習遊戲網站
How I built a curriculum-mapped learning games site with Astro, seeded generators and 50+ test files
learnwith.games 作者 Omer Celik 分享以 Astro 建立對應英格蘭及美國課程的兒童學習遊戲網站,並説明其資料模型、題目生成和測試設計。網站現有 216 款遊戲,其中 159 款對應英格蘭國家課程或 EYFS,並各附免費可列印遊戲及工作紙。數學題會由生成器產生,並以獨立計算器逐題檢查;55 個測試檔全部通過後才會部署,網站亦不設帳戶、Cookie 或個別兒童追蹤。
learnwith.games is a free site of short learning games for children aged 4–11: maths, English, science, geography, history, computing, French and Spanish, from Reception to Year 6. Today it has 216 games. 159 of them are placed against a specific statement in the National Curriculum in England (or the EYFS framework for Reception), and those 159 each come with a free printable game and worksheet.
It has no accounts, no cookies, no ads and no tracking of individual children. It's a static site on Cloudflare Pages.
This post covers the parts that turned out to be interesting engineering: the data model, the question generators, the tests that check the maths, the structured data and the printables.
The constraint that shaped everything: children
Most architecture choices on the site follow from one rule: a child's device should send nothing about them to anyone.
So:
-
No accounts, no cookies, no server-side state. Stars and preferences live in
localStorageand never leave the device:
// src/js/progress.js
// Progress and preferences are stored only in this browser's localStorage. Nothing leaves the device.
const KEY = 'lwg.progress.v1';
const PREFS = 'lwg.prefs.v1';
- A strict Content Security Policy. The only third-party origin is Cloudflare's cookieless Web Analytics beacon, which counts page views without identifying anyone:
content-security-policy: default-src 'self'; script-src 'self' https://static.cloudflareinsights.com;
style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://cloudflareinsights.com;
media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
referrer-policy: no-referrer
permissions-policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=(), browsing-topics=()
style-src 'self' with no 'unsafe-inline' means no inline styles anywhere, including in generated markup. That's annoying at first and very clarifying after.
Search stays in the URL fragment. The "Find a game" page filters a single content-hashed JSON index in the browser. Queries and filters live after the
#, so they never reach a server log.Sound is off by default, and
prefers-reduced-motionis respected.Cloudflare's email obfuscation is switched off with
<!--email_off-->. It was injecting a script and rewriting the contact address into links that returned 404 when JavaScript was off.
Stack: Astro, static, no islands
The site is built with Astro in output: 'static' mode and deployed to Cloudflare Pages:
// astro.config.mjs
export default defineConfig({
site: 'https://learnwith.games',
output: 'static',
trailingSlash: 'always',
build: { format: 'directory', inlineStylesheets: 'never' },
});
Astro has no framework islands here. The games are plain ES modules (one per "engine": quiz race, match the pairs, sort it out, timeline, number line and so on) loaded on the game page. Astro's job is the document: layouts, components, and getStaticPaths() over a route table.
A Node script, scripts/build.mjs, prepares that route table from the data, runs Astro, and then writes the sitemap with real <lastmod> dates. Those dates are the page's last content change, not the build time. Duplicate page or asset paths fail the build.
The current sitemap has 1,102 URLs. 468 of them are the US section, which reuses the same games with Common Core placements and US grade labels.
The data model: store every game once
The site started as "one page per curriculum topic". That broke as soon as a single game was useful in two places, or in a second country. The model now separates what a game is from where it sits in a curriculum:
src/data/taxonomy/<subject>.json concepts and skills (subject-neutral)
src/data/activities/<subject>.json each game, once: engine, generator, params
src/data/curricula/registry.json curricula and their stages
src/data/curricula/gb-eng/<subject>.json England statements → home activities
src/data/curricula/us-ccss/<subject>.json US homes and Common Core links
An activity is keyed by a permanent id that never changes:
{
"id": "times-tables-to-12",
"status": "live",
"subject": "maths",
"skills": ["maths.multiplication-division.times-tables-to-12"],
"engine": "fact-sprint",
"generator": "timesTables",
"params": { "tables": [2,3,4,5,6,7,8,9,10,11,12], "maxFactor": 12 },
"game": { "seconds": 60, "stars": [5, 10, 15] },
"printable": { "free": true, "inPacks": true }
}
The curriculum file says where that game lives in England, quoting the statutory statement:
{
"id": "gb-eng.maths.year-4.times-tables-to-12",
"stage": "year-4",
"strand": "multiplication-division",
"domain": "Number – multiplication and division",
"text": "recall multiplication and division facts for multiplication tables up to 12 × 12",
"home": ["times-tables-to-12"],
"alsoPractises": []
}
loadCurriculum(curriculumId) in scripts/lib.mjs joins these into the "view" that the pages render: subjects, then years, then topics. It is deliberately strict. An unknown stage throws, and if the old pre-migration data folder ever reappears the build refuses to run:
if (exists('data/skills')) throw new Error('Legacy src/data/skills reappeared. Write taxonomy/activity/curriculum files; do not mix data formats.');
Because progress is keyed by activity id rather than by URL, a child's stars follow a game when it moves year or country. Old URLs are kept alive in a URL ledger. All 88 redirect rules are covered by a test.
Questions are generated, not written
Maths questions come from procedural generators. Each one takes params and a random number generator and returns a question:
// src/js/generators.js (excerpt)
timesTables({ tables = [2, 5, 10], maxFactor = 12, division = true }, rng) {
const t = pick(rng, tables);
const f = randInt(rng, 1, maxFactor);
let q, ans;
if (division && rng() < 0.35) {
if (rng() < 0.5) { q = `${fmtInt(t * f)} ÷ ${t} = ?`; ans = f; }
else { q = `${fmtInt(t * f)} ÷ ${f} = ?`; ans = t; }
} else {
q = rng() < 0.5 ? `${t} × ${f} = ?` : `${f} × ${t} = ?`;
ans = t * f;
}
return qa(q, fmtInt(ans), numericChoices(rng, ans, Math.max(3, t), { extra: [ans + t, ans - t, ans + f] }), { eq: q });
},
Two details matter.
First, the generator returns the equation it used (meta.eq), so a test can check the question independently of how it was made.
Second, the RNG is seedable (mulberry32 with an FNV-1a string hash). The browser uses a random seed per round. The printable builder uses a fixed string seed per game, so a worksheet is stable and reproducible:
// scripts/printables.mjs
export function printableSeed(slug) { return `${slug}#worksheet-A`; }
export function packSeed(slug, i) { return `${slug}#pack-${i + 1}`; }
English, science and the other subjects use hand-written question banks ({ q, a, d, why }) through the same engines. The why field is the short explanation shown after a wrong answer.
Testing maths for children like it's financial code
A wrong answer in a children's maths game is worse than a crash. The child learns the wrong thing and trusts it. So the main maths test doesn't trust the generators. tests/maths.test.mjs generates thousands of questions per live topic (3,000 by default) and checks each one with an independent exact evaluator in tests/expr.mjs. It's a tiny tokenizer and parser over BigInt rationals that understands ×, ÷, fractions written as {3/4}, % and "of":
export class Q {
constructor(n, d = 1n) {
n = BigInt(n); d = BigInt(d);
if (d === 0n) throw new Error('division by zero');
if (d < 0n) { n = -n; d = -d; }
const g = gcd(n, d) || 1n;
this.n = n / g; this.d = d / g;
}
add(o) { return new Q(this.n * o.d + o.n * this.d, this.d * o.d); }
// sub, mul, div, eq, cmp ...
}
Floating point never gets a vote. For multiple-choice questions, the test also checks that exactly one choice satisfies the equation, that there are four distinct choices, and that the answer is one of them:
const correct = q.choices.filter((c) => { try { return checkEquation(eq, c); } catch { return false; } });
ok(correct.length === 1, slug, `exactly one choice should satisfy ${eq}: ${correct}`, q);
The other tests I'd recommend to anyone building something similar:
-
Curriculum wording (
tests/curriculum.test.mjs). Every quoted statement must appear word for word in the DfE source text kept in the repo, ignoring whitespace and normalising quotes and dashes. Reception statements must come from the EYFS framework. Languages must be Key Stage 2 only. Without this test, "lightly paraphrased" curriculum text creeps in fast. - Printable puzzles have exactly one solution. The answer maze is checked with a depth-first search that counts every route through it. It must find exactly one, and no off-path square may hold a correct answer. Loop dominoes must form a single loop that visits every domino once.
-
Engine variety (
tests/engines.test.mjs). The build fails if any one engine is used by more than 35% of live topics. Otherwise everything slowly turns into a quiz. - Structured data. Every JSON-LD block must parse, and the BreadcrumbList must match the visible breadcrumbs.
- Headless browser tests for play-through, accessibility and phone overflow at narrow widths.
There are 55 test files in tests/, and npm test chains them all with the build in the middle. Nothing deploys unless the whole chain passes.
Structured data that tells the truth
Each game page carries LearningResource + Game, BreadcrumbList and FAQPage JSON-LD. The alignment uses schema.org's AlignmentObject, generated from the same curriculum data as the visible page, so the two can't disagree:
{
"@type": ["LearningResource", "Game"],
"name": "Times tables to 12 × 12",
"educationalLevel": "Year 4",
"isAccessibleForFree": true,
"teaches": "recall multiplication and division facts for multiplication tables up to 12 × 12",
"educationalAlignment": [{
"@type": "AlignmentObject",
"alignmentType": "teaches",
"educationalFramework": "National curriculum in England: mathematics programmes of study (DfE, updated 28 September 2021)",
"targetName": "Year 4 – Number – multiplication and division: recall multiplication and division facts for multiplication tables up to 12 × 12"
}]
}
The home page has WebSite + Organization. There's no Offer markup on free pages, because they aren't selling anything.
Printables from the same code
The free printables use the same generators as the games, so a worksheet can't drift from the game it belongs to. Each topic gets a printable game plus a worksheet with an answer sheet. The printable formats include colour-by-answer, answer mazes, secret codes, bingo, stepping stones, match cards, loop dominoes and cut-and-sort "sorting hoops".
They're rendered as print-ready HTML and turned into PDFs with headless Chrome, in both A4 and US Letter:
await page.pdf({ path: file, width: SIZES[paper].w, height: SIZES[paper].h,
printBackground: true, margin: { top: 0, bottom: 0, left: 0, right: 0 } });
The build measures every sheet and fails on overflow. A question that wraps onto a second page is a bug, not a layout quirk. On screen, the 210 mm sheet is zoomed to fit phones. In print it stays true size.
SEO without tracking
- Real
<lastmod>dates in the sitemap, taken from content changes. - IndexNow pings from
npm run indexnowafter each deploy. -
hreflangpairs between the England and US versions of a game. - Every redirect from earlier URL schemes kept and tested.
I can't see who visits beyond anonymous page counts, and that's fine. The site is for children.
How the work gets done
The site is built and maintained by me, Omer Celik, an independent UK maker. Much of the day-to-day engineering was done with an AI coding agent working through a written handoff document and a daily routine. The rule that made that workable was the test suite above: the agent can propose anything, but nothing ships unless the curriculum, maths, puzzle, accessibility and structured-data checks all pass. It also helped to write the product rules down in the repo, such as "no inline styles", "no cookies" and "never change a permanent id", so they're enforced and not just remembered.
What I'd do again
- Separate "what a thing is" from "where it's placed" as early as possible. Curricula change. Games shouldn't have to.
- Write an independent checker for anything a child will trust. Generators are clever, and checkers should be boring.
- Make privacy a build constraint, not a policy page. A strict CSP and no cookies remove whole categories of decisions.
- Fail the build on layout problems in printables. Teachers will print it, and it has to fit.
If you teach, home-educate or just have a child who likes beating their own score, the games are at learnwith.games. Every game page also has a free printable. I'm happy to answer questions about any of the above in the comments.
來源:DEV Community · dev.to