{"slug": "reps-wordle-but-with-javascript", "title": "Reps: Wordle, but with JavaScript", "summary": "Developer Zander built Reps, a daily JavaScript puzzle game launched in June 2026 that presents one puzzle per day with three tests and a reference solution, now holding 118 puzzles stored as plain YAML files. Each submission runs in a fresh Web Worker per test with a 2 second kill switch, and a CI pipeline deploys a playable Cloudflare preview of every puzzle submitted via pull request. The project uses Vite, React, Monaco, ZUI and Cloudflare Workers with no backend.", "body_md": "# Reps: Wordle, but with JavaScript\n\n## A write-up of the interesting parts of the daily JavaScript puzzle game I built in June. Puzzles as YAML data, a Web Worker sandbox, and a CI pipeline that deploys a playable preview of every submitted puzzle.\n\nI built Reps at the end of May and [launched it a few days later](https://zander.wtf/blog/2026-06-03-reps). In this age of AI I write less code than I used to, and it felt like I was losing a skill. Not the big stuff, that’s fine. The muscle memory. The bit where you know what `reduce` is going to do before you’ve finished typing it. Reviewing an agent’s diff keeps the judgement sharp. It does nothing for the hands.\n\nSo I wanted a short daily thing. Like Wordle: one puzzle a day, five minutes, no backlog to feel guilty about. Write a small function in the browser, watch three tests run, share a row of coloured squares. [Reps](https://reps.zander.wtf) is what came out of that.\n\n**TL;DR**\n\n- Every puzzle is a plain YAML file: prompt, three tests, a reference solution. There are 118 of them now.\n- Your code runs in a fresh Web Worker per test, with a 2 second kill switch.\n- The validator compiles every reference solution and runs it against its own tests. That’s the entire test suite.\n- Open a PR with a puzzle and CI deploys a playable Cloudflare preview and comments the link.\n- Promoting accepted puzzles into the live rotation is a workflow that commits back to `main` .\n- Vite, React, Monaco, [ZUI](https://zui.zander.wtf) , Cloudflare Workers. No backend at all.\n\n## The shape of it\n\nEvery day you get one puzzle: a prompt with a worked example, and a Monaco editor with some starter code in it. Write the function, hit run, and three tests go green or red one at a time. Solve it and you get a spoiler-free share string, coloured squares and a time, never your code.\n\nThe three tests are always in the same order: happy path, a second normal case, then an edge case. That’s a rule for contributors rather than a coincidence. The third one is where you find out your solution was hardcoded.\n\nThe recommended solution sits behind a button you can press at any point, with no penalty. Reps is a warm-up, not an exam, and a puzzle you failed and can’t learn from is just a bad morning. So each one also carries an explanation written for someone who tried and couldn’t see the path. I press that button plenty.\n\n## How puzzles are made\n\nI took this idea from [Astro](https://astro.build). Reps has its own content collection, made of YAML files, working much the way Astro’s content collections do. Content is files on disk, a schema says what shape those files have to be, and a check runs over them before any of it ships. No database, no CMS, no admin panel. Adding a puzzle is adding a file.\n\nSo every puzzle is one YAML file. No registration, no imports, nothing to wire up:\n\n```\nid: 11\ntitle: 'Chunk an Array'\ndifficulty: medium\nprompt: |\n  Implement `chunk(arr, size)` so it splits `arr` into sub-arrays of length\n  `size` (the last chunk may be shorter).\n\n  For example, `chunk([1, 2, 3, 4, 5], 2)` returns `[[1, 2], [3, 4], [5]]`.\nfunctionName: chunk\nstarterCode: |\n  function chunk(arr, size) {\n    // your code here\n  }\ntests:\n  - name: 'Even split'\n    args: [[1, 2, 3, 4, 5], 2]\n    expected: [[1, 2], [3, 4], [5]]\n  - name: 'Exact fit'\n    args: [[1, 2, 3], 3]\n    expected: [[1, 2, 3]]\n  - name: 'Empty input'\n    args: [[], 3]\n    expected: []\nsolution: |\n  function chunk(arr, size) {\n    const out = [];\n    for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));\n    return out;\n  }\n```\n\n`args` is spread into the call, so `args: [[1,2,3], 2]` calls `chunk([1,2,3], 2)`. A deep equality that handles arrays, plain objects and `NaN` compares the result against `expected`. It doesn’t handle `Map` or `Set`, so puzzles have to convert those before returning. The validator hard-fails if you try.\n\nA separate `index.json` holds the schedule: a launch date and an ordered list of puzzle ids that the app walks through a day at a time, wrapping back to the start when it reaches the end. Adding a puzzle file doesn’t put it in rotation, someone has to add it to that list, so the difficulty pacing stays curated.\n\n## Running a stranger’s code in their own browser\n\nThere’s no backend. Your solution never leaves your machine, which removes an entire category of problem and introduces one. An infinite loop would lock the tab.\n\nSo each test gets its own Web Worker, and the runner holds a hard timeout over it:\n\n``` js\nconst timer = setTimeout(() => {\n  worker.terminate()\n  resolve({ name: test.name, pass: false, error: `Timed out after ${TIMEOUT_MS} ms` })\n}, TIMEOUT_MS)\n```\n\n`while(true)` gives you a clean red “Timed out after 2000 ms” instead of a beachball. A fresh worker per test also means one bad test can’t take the other two down with it.\n\nInside the worker, three details that each came from something going wrong:\n\n``` js\nconst factory = new Function(\n  'console',\n  `\"use strict\";\\n${code}\\n;return typeof ${functionName} === \"function\" ? ${functionName} : undefined;`\n)\nconst fn = factory(captureConsole)\nconst args = structuredClone(test.args)\nconst received = fn(...args)\n```\n\n`\"use strict\"` stops accidental globals leaking between runs. `structuredClone` on the arguments means a solution that mutates its input can’t poison the comparison for the next test, and you can mutate freely without being punished for it. And `console` is passed in as a parameter, shadowing the global, so every `console.log` you left in your working gets captured and shown next to the test result rather than disappearing into a worker’s devtools context nobody opens.\n\nThat last one was [a whole commit on its own](https://github.com/mrmartineau/reps.zander.wtf/commit/86a66a4) and it’s the single change that made debugging a failing puzzle feel normal.\n\n### React puzzles\n\nA puzzle can set a `kind` and ask for a React component or a custom hook instead of a pure function. JSX is compiled in the browser with [Sucrase](https://github.com/alangpierce/sucrase), and component tests assert against the rendered DOM with a small declarative DSL:\n\n```\nkind: react-component\ncomponentName: TaskList\ntests:\n  - name: 'Renders one <li> per task'\n    props: { tasks: ['a', 'b', 'c'] }\n    assert: { selector: 'ul li', count: 3 }\n```\n\nReact needs the DOM, and a Web Worker doesn’t have one. So these render on the main thread into a hidden container that’s torn down after each test, which means they get none of the worker’s timeout protection. A mistyped render loop can hang the tab, so a sandboxed `<iframe>` I can terminate is the obvious next job. Two samples ship outside the daily rotation at `?preview=900` and `?preview=901` if you want to try them.\n\n## How puzzles are submitted\n\nThis is the part I enjoyed building most, because the tension is real. I want contributions to be easy, and I want to keep control of pacing. If contributors picked their own day numbers they’d collide, and the difficulty spread would drift into three hard puzzles in a row and scare off anyone new.\n\nSo nobody picks a day number:\n\n1. Drop a `.yaml` file in`submissions/` with`id: 0` .\n2. Run `pnpm validate-submissions` .\n3. Open a PR.\n4. If it’s accepted, a workflow assigns the real id and slots it into the rotation.\n\n`submissions/` is a holding area that never ships to the live site. Two `example-*.yaml` files live there permanently as templates, and the prefix is reserved so promotion skips them.\n\n### The validator is the test suite\n\nThere’s no Vitest, no Jest, nothing. `scripts/validate-puzzles.mjs` checks the structure, then compiles each puzzle’s `solution` and runs it against that puzzle’s own three tests, asserting deep equality with `expected`.\n\n```\nnode scripts/validate-puzzles.mjs public/puzzles      # the live set\nnode scripts/validate-puzzles.mjs submissions         # drafts\n```\n\nThat closes the gap where a stored `expected` value drifts away from a working answer. You can’t hand-write an `expected` that’s subtly wrong and have it sit there for months, because the reference solution has to produce it. It mirrors `src/lib/equal.js` on purpose, so validation matches in-browser behaviour exactly. Two copies of a deep-equal function is a small sin I’ll take over the build complexity of sharing one between a browser bundle and a Node script.\n\n### An agent skill for writing puzzles\n\nThe repo has `.claude/skills/new-puzzle/SKILL.md`, a skill that walks anyone, or any agent, from a puzzle idea to a validated draft. It encodes the calibration rules: reject one-liners with no decision in them, reject anything impure or locale-dependent, reject anything you’d have to look up, be honest about difficulty because the badge drives ordering.\n\nThere’s a nice symmetry to it. The thing I built to stop leaning on AI is itself authored with AI. I generated most of the 118 puzzles with Claude working through that skill, because writing 118 puzzles by hand is exactly the kind of work I don’t want to be doing. The reps are in solving them, not in typing out YAML.\n\nIt also means the set is harder than one I’d have written alone, and I’m glad of that. Left to myself I’d have written 118 puzzles sitting comfortably inside what I already know. A fair number of these I can’t do quickly, and I hit the reveal button like everyone else.\n\n## Every PR gets a playable preview\n\n`pr-preview.yml` runs on any PR touching puzzles or source. It validates the live set and the submissions, always, including PRs from forks.\n\nThen, if the PR changed a file in `submissions/`, it does something better than a screenshot. It deploys a Cloudflare Workers **version**, not a deployment:\n\n```\n- uses: cloudflare/wrangler-action@v3\n  with:\n    command: versions upload --preview-alias pr-${{ github.event.pull_request.number }}\n```\n\n`versions upload` uploads new code without routing production traffic to it, and `--preview-alias` gives that version a stable, guessable URL: `pr-5-reps.zanderwtf.workers.dev`. The alias comes from the PR number, so pushing a new commit updates the same link rather than generating a new one.\n\nA `github-script` step then comments on the PR with deep links straight to each changed submission. [PR #5](https://github.com/mrmartineau/reps.zander.wtf/pull/5) added 60 drafts in one go, so its comment runs to 60 links:\n\n### 🧩 Submission preview\n\nPreview the submission(s) in this PR on the deployed site:\n\n[`affordable-items.yaml`](https://pr-5-reps.zanderwtf.workers.dev/submissions.html?file=affordable-items.yaml)\n[`array-diff.yaml`](https://pr-5-reps.zanderwtf.workers.dev/submissions.html?file=array-diff.yaml)- (57 more)\n[`zip.yaml`](https://pr-5-reps.zanderwtf.workers.dev/submissions.html?file=zip.yaml)\nAll submissions: [https://pr-5-reps.zanderwtf.workers.dev/submissions.html](https://pr-5-reps.zanderwtf.workers.dev/submissions.html)\n\nThat PR merged months ago and its preview is still serving all 60 drafts, if you want to see what a contributor sees: [pr-5-reps.zanderwtf.workers.dev/submissions](https://pr-5-reps.zanderwtf.workers.dev/submissions).\n\nThe comment carries an HTML marker, `<!-- reps-preview -->`, and the script looks for it before posting. If it’s there, it edits that comment in place. One comment per PR, always pointing at the latest commit, no wall of bot noise.\n\nFork PRs can’t read secrets or write comments, by design, so the preview block is gated and they skip it. A fork contributor still gets their puzzle validated, just not deployed.\n\nThe preview page is a second Vite entry: `index.html` is the game, `submissions.html` is the contributor preview.\n\n## Promoting drafts into the rotation\n\n`promote-submissions.yml` is manually triggered, guarded to `main` only, and it does the whole promotion:\n\n- Validates every draft first, and refuses to continue if one fails.\n- Assigns each the next free id, taken as the highest across both the live files and the index, so the two can’t drift into a collision.\n- Renames it to `puzzle-NNN.yaml` , zero-padded, because that’s the path the loader fetches, and appends`{ id, title }` to`days[]` .\n- Re-validates, then commits back to `main` .\n\nThree decisions in there I’d defend. The workflow only ever appends to `days[]` and never inserts, so a date that showed puzzle 12 last week still shows puzzle 12 forever. It shuffles the drafts before assigning ids, because `readdirSync` order is alphabetical and promoting 76 at once would have put every puzzle starting with “a” in the same week. And it rewrites only the `id` line, with a regex rather than a YAML load-and-dump, because round-tripping through `js-yaml` reflows the whole document and turns a clean review diff into noise.\n\n## Deploying by hand\n\n`deploy.yml` is `workflow_dispatch` only. Push to `main` doesn’t ship. For a daily game where a bad deploy means a broken puzzle for everyone that morning, a manual button is fine, and I’d rather have it.\n\n## The stack\n\n- **Vite 8** with`@vitejs/plugin-react` , and the[Cloudflare Vite plugin](https://developers.cloudflare.com/workers/vite-plugin/) , which emits a deployable`dist/wrangler.json` for a static-assets Workers site. There’s no Worker entry at all, just assets with an SPA fallback.\n- **React 19** for the UI.\n- **[Monaco](https://microsoft.github.io/monaco-editor/)** via`@monaco-editor/react` . It’s about a megabyte gzipped, which for an editor-centric game is the cost of the thing rather than a regression, so the build’s size warning is turned up rather than ignored.\n- **[ZUI](https://zui.zander.wtf)** , my own CSS-first component library, which is why there’s almost no CSS of my own in the repo.\n- js-yaml for puzzles, react-markdown and remark-gfm for prompts, Sucrase for the JSX transform.\n- pnpm, wrangler, Cloudflare Workers.\n\n## Where it would go next\n\nMore languages is the obvious one. The same puzzle with idiomatic implementations per language: JS and TS run in the browser today, Python via Pyodide and Rust via wasm could, and Go, PHP or Swift would want a sandboxed backend runner. The YAML schema would need a `language` field with per-language `starterCode` and `tests`, so one file still describes one puzzle.\n\nBefore that, the React puzzles need their `<iframe>` sandbox, because a spike with a known hole shouldn’t stay a spike forever.\n\nDid it do what I wanted? For a few months, yes. Five minutes of writing a function from nothing, with no agent and no autocomplete worth the name, is a completely different activity from reviewing a diff. Both are useful. Only one of them is practice.\n\n[Play today’s puzzle](https://reps.zander.wtf), or read the short version on [its project page](https://zander.wtf/projects/reps). If you write one you like, [the contributor guide](https://github.com/mrmartineau/reps.zander.wtf/blob/main/CONTRIBUTORS.md) explains the format, and CI will deploy it for you to play before anyone reviews it.\n\n**P.S.** I don’t play it every day any more. I used it properly for a couple of months, then got busy with other side projects, and the daily habit was the first thing to go. That’s the usual fate of a side project built to fix a habit, and I’d rather say so than pretend otherwise.\n\nThe nice part is that nothing has rotted. It’s a static site with no backend, no database and no tokens to expire, and the cycling calendar means it never runs out of puzzles to serve. It’s been quietly handing out a puzzle a day the whole time I wasn’t looking. I’ll come back to it when the React sandbox starts annoying me enough.", "url": "https://wpnews.pro/news/reps-wordle-but-with-javascript", "canonical_source": "https://zander.wtf/blog/reps/", "published_at": "2026-09-23 22:00:10.094650+00:00", "updated_at": "2026-09-23 22:00:11.533739+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Reps", "Zander", "Cloudflare Workers", "Monaco", "Vite", "React", "ZUI", "Astro"], "alternates": {"html": "https://wpnews.pro/news/reps-wordle-but-with-javascript", "markdown": "https://wpnews.pro/news/reps-wordle-but-with-javascript.md", "text": "https://wpnews.pro/news/reps-wordle-but-with-javascript.txt", "jsonld": "https://wpnews.pro/news/reps-wordle-but-with-javascript.jsonld"}}