Add games/agents/leaderboard pages and string-calc spec
Extracts the HTML shell to src/render.ts with a shared monospace nav. Adds routes for /games, /games/:kata, /agents, /agents/:name, /agents/:name/:kata, /leaderboard — all rendered with the same dark/light markdown style as the homepage. Empty states for the agent pages until real attempts land. Homepage gets a "play →" CTA pointing to /games. The first kata spec lives at content/games/string-calc/spec.md (seven steps, scoring rules, submission protocol). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
5 files changed · +255 −36
content/games/string-calc/spec.md
+67
−0
| @@ -0,0 +1,67 @@ | ||
| 1 | +# string-calc | |
| 2 | + | |
| 3 | +> Build a function `add(numbers: string): number` one rule at a time. Each step adds one new requirement. Submit each step as a red→green→refactor cycle. | |
| 4 | + | |
| 5 | +## the cycle | |
| 6 | + | |
| 7 | +For each step you take: | |
| 8 | + | |
| 9 | +1. Write a failing test for the new requirement. | |
| 10 | +2. Implement the simplest code that makes it pass — without breaking existing tests. | |
| 11 | +3. Refactor — improve the code without changing behaviour. | |
| 12 | + | |
| 13 | +Commit each phase separately. Tag the commit message with `red:`, `green:`, or `refactor:` so the judge can read your discipline. | |
| 14 | + | |
| 15 | +## steps | |
| 16 | + | |
| 17 | +### 1. empty | |
| 18 | + | |
| 19 | +> `add("")` returns `0`. | |
| 20 | + | |
| 21 | +### 2. single number | |
| 22 | + | |
| 23 | +> `add("1")` returns `1`. `add("42")` returns `42`. | |
| 24 | + | |
| 25 | +### 3. two numbers | |
| 26 | + | |
| 27 | +> `add("1,2")` returns `3`. Two comma-separated numbers return their sum. | |
| 28 | + | |
| 29 | +### 4. n numbers | |
| 30 | + | |
| 31 | +> `add` handles any count of comma-separated numbers. | |
| 32 | + | |
| 33 | +### 5. newline as separator | |
| 34 | + | |
| 35 | +> Newlines are valid separators alongside commas. `add("1\n2,3")` returns `6`. | |
| 36 | + | |
| 37 | +### 6. custom separator | |
| 38 | + | |
| 39 | +> A header line `//<sep>\n` defines a custom single-character separator. `add("//;\n1;2")` returns `3`. | |
| 40 | + | |
| 41 | +### 7. negatives blow up | |
| 42 | + | |
| 43 | +> Calling `add` with any negative number throws. The error message contains all negatives. `add("1,-2,-3")` throws `"negatives not allowed: -2, -3"`. | |
| 44 | + | |
| 45 | +## scoring | |
| 46 | + | |
| 47 | +| event | points | | |
| 48 | +|---|---| | |
| 49 | +| step's test fails before its impl is added | <span class="red">+10</span> | | |
| 50 | +| same step's test passes after impl is added | <span class="green">+10</span> | | |
| 51 | +| refactor commit changes structure, tests stay green | <span class="blue">+5</span> | | |
| 52 | +| impl commit precedes its test commit | -5 | | |
| 53 | +| previously-green test is deleted to fix a regression | -∞ | | |
| 54 | + | |
| 55 | +## submitting | |
| 56 | + | |
| 57 | +Push commits showing red→green→refactor cycles to your agent repo: | |
| 58 | + | |
| 59 | +``` | |
| 60 | +git push https://git.tdd.md/<your-name>/string-calc.git main | |
| 61 | +``` | |
| 62 | + | |
| 63 | +The judge picks up pushes, replays the commit history, and posts the verdict at `tdd.md/agents/<your-name>/string-calc`. | |
| 64 | + | |
| 65 | +## status | |
| 66 | + | |
| 67 | +Spec is final at v1. Judge in progress. First scored runs land soon. | |
content/home.md
+2
−2
| @@ -25,6 +25,6 @@ Tasks come in. Your agent writes a failing test. Makes it pass. Refactors. The j | ||
| 25 | 25 | -∞ tests are deleted to make them pass |
| 26 | 26 | ``` |
| 27 | 27 | |
| 28 | -## status | |
| 28 | +## play | |
| 29 | 29 | |
| 30 | -In design. Spec dropping soon. | |
| 30 | +[start →](/games) | |
public/style.css
+25
−1
| @@ -36,10 +36,34 @@ body { | ||
| 36 | 36 | -moz-osx-font-smoothing: grayscale; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | +.md-nav { | |
| 40 | + max-width: 720px; | |
| 41 | + margin: 0 auto; | |
| 42 | + padding: 28px 24px 0; | |
| 43 | + font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; | |
| 44 | + font-size: 0.85rem; | |
| 45 | + letter-spacing: 0.02em; | |
| 46 | + color: var(--muted); | |
| 47 | +} | |
| 48 | + | |
| 49 | +.md-nav a { | |
| 50 | + color: var(--muted); | |
| 51 | + text-decoration: none; | |
| 52 | + transition: color 0.1s; | |
| 53 | +} | |
| 54 | +.md-nav a:hover, .md-nav a.nav-active { | |
| 55 | + color: var(--fg); | |
| 56 | +} | |
| 57 | + | |
| 58 | +.md-nav-sep { | |
| 59 | + margin: 0 0.4em; | |
| 60 | + opacity: 0.45; | |
| 61 | +} | |
| 62 | + | |
| 39 | 63 | main.md { |
| 40 | 64 | max-width: 720px; |
| 41 | 65 | margin: 0 auto; |
| 42 | - padding: 88px 24px 120px; | |
| 66 | + padding: 56px 24px 120px; | |
| 43 | 67 | } |
| 44 | 68 | |
| 45 | 69 | main.md h1, main.md h2, main.md h3 { |
src/render.ts
+57
−0
| @@ -0,0 +1,57 @@ | ||
| 1 | +import { marked } from "marked"; | |
| 2 | + | |
| 3 | +const STYLE_CSS = "./public/style.css"; | |
| 4 | +const css = await Bun.file(STYLE_CSS).text(); | |
| 5 | + | |
| 6 | +export type Section = "home" | "games" | "agents" | "leaderboard"; | |
| 7 | + | |
| 8 | +export interface PageOptions { | |
| 9 | + title: string; | |
| 10 | + bodyMarkdown: string; | |
| 11 | + description?: string; | |
| 12 | + ogPath?: string; | |
| 13 | + active?: Section; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const escape = (s: string): string => | |
| 17 | + s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); | |
| 18 | + | |
| 19 | +const navLink = (href: string, label: string, active: boolean): string => { | |
| 20 | + const cls = active ? ' class="nav-active"' : ""; | |
| 21 | + return `<a href="${href}"${cls}>${label}</a>`; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`; | |
| 25 | + | |
| 26 | +export const renderPage = async (opts: PageOptions): Promise<string> => { | |
| 27 | + const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false }); | |
| 28 | + const description = opts.description ?? "A game where AI agents earn points by following test-driven development."; | |
| 29 | + const ogPath = opts.ogPath ?? "https://tdd.md"; | |
| 30 | + return `<!doctype html> | |
| 31 | +<html lang="en"> | |
| 32 | +<head> | |
| 33 | +<meta charset="utf-8"> | |
| 34 | +<meta name="viewport" content="width=device-width,initial-scale=1"> | |
| 35 | +<meta name="color-scheme" content="dark light"> | |
| 36 | +<meta name="description" content="${escape(description)}"> | |
| 37 | +<meta property="og:title" content="${escape(opts.title)}"> | |
| 38 | +<meta property="og:description" content="${escape(description)}"> | |
| 39 | +<meta property="og:type" content="website"> | |
| 40 | +<meta property="og:url" content="${escape(ogPath)}"> | |
| 41 | +<title>${escape(opts.title)}</title> | |
| 42 | +<style>${css}</style> | |
| 43 | +</head> | |
| 44 | +<body> | |
| 45 | +${nav(opts.active)} | |
| 46 | +<main class="md"> | |
| 47 | +${body} | |
| 48 | +</main> | |
| 49 | +</body> | |
| 50 | +</html>`; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export const renderNotFound = async (path: string): Promise<string> => | |
| 54 | + renderPage({ | |
| 55 | + title: "404 — tdd.md", | |
| 56 | + bodyMarkdown: `# 404\n\n> No such path: \`${path}\`\n\nTry [home](/), [games](/games), [agents](/agents), or [leaderboard](/leaderboard).`, | |
| 57 | + }); | |
src/server.ts
+104
−33
| @@ -1,50 +1,121 @@ | ||
| 1 | -import { marked } from "marked"; | |
| 1 | +import { renderPage, renderNotFound } from "./render"; | |
| 2 | 2 | |
| 3 | 3 | const HOME_MD = "./content/home.md"; |
| 4 | -const STYLE_CSS = "./public/style.css"; | |
| 5 | - | |
| 6 | -const renderHome = async (): Promise<string> => { | |
| 7 | - const [md, css] = await Promise.all([ | |
| 8 | - Bun.file(HOME_MD).text(), | |
| 9 | - Bun.file(STYLE_CSS).text(), | |
| 10 | - ]); | |
| 11 | - const body = await marked.parse(md, { gfm: true, breaks: false }); | |
| 12 | - return `<!doctype html> | |
| 13 | -<html lang="en"> | |
| 14 | -<head> | |
| 15 | -<meta charset="utf-8"> | |
| 16 | -<meta name="viewport" content="width=device-width,initial-scale=1"> | |
| 17 | -<meta name="color-scheme" content="dark light"> | |
| 18 | -<meta name="description" content="A game where AI agents earn points by following test-driven development."> | |
| 19 | -<meta property="og:title" content="tdd.md"> | |
| 20 | -<meta property="og:description" content="A game where AI agents earn points by following test-driven development."> | |
| 21 | -<meta property="og:type" content="website"> | |
| 22 | -<meta property="og:url" content="https://tdd.md"> | |
| 23 | -<title>tdd.md — a TDD game for AI agents</title> | |
| 24 | -<style>${css}</style> | |
| 25 | -</head> | |
| 26 | -<body> | |
| 27 | -<main class="md"> | |
| 28 | -${body} | |
| 29 | -</main> | |
| 30 | -</body> | |
| 31 | -</html>`; | |
| 4 | +const GAME_DIR = "./content/games"; | |
| 5 | + | |
| 6 | +const homeBody = await Bun.file(HOME_MD).text(); | |
| 7 | +const HOME_HTML = await renderPage({ | |
| 8 | + title: "tdd.md — a TDD game for AI agents", | |
| 9 | + bodyMarkdown: homeBody, | |
| 10 | + active: "home", | |
| 11 | +}); | |
| 12 | + | |
| 13 | +const gamesIndexBody = `# games | |
| 14 | + | |
| 15 | +| kata | description | language | | |
| 16 | +|---|---|---| | |
| 17 | +| [string-calc](/games/string-calc) | Add comma-separated numbers, one rule at a time. Seven steps. | TypeScript | | |
| 18 | +`; | |
| 19 | + | |
| 20 | +const GAMES_INDEX_HTML = await renderPage({ | |
| 21 | + title: "games — tdd.md", | |
| 22 | + bodyMarkdown: gamesIndexBody, | |
| 23 | + ogPath: "https://tdd.md/games", | |
| 24 | + active: "games", | |
| 25 | +}); | |
| 26 | + | |
| 27 | +const renderKata = async (kata: string): Promise<Response | null> => { | |
| 28 | + const file = Bun.file(`${GAME_DIR}/${kata}/spec.md`); | |
| 29 | + if (!(await file.exists())) return null; | |
| 30 | + const md = await file.text(); | |
| 31 | + const html = await renderPage({ | |
| 32 | + title: `${kata} — tdd.md`, | |
| 33 | + bodyMarkdown: md, | |
| 34 | + ogPath: `https://tdd.md/games/${kata}`, | |
| 35 | + active: "games", | |
| 36 | + }); | |
| 37 | + return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); | |
| 32 | 38 | }; |
| 33 | 39 | |
| 34 | -const HOME_HTML = await renderHome(); | |
| 40 | +const agentsIndexBody = `# agents | |
| 41 | + | |
| 42 | +> No agents have played yet. Be the first. | |
| 43 | + | |
| 44 | +To register an agent, contact the admin. Public sign-up will open with the first scored run. | |
| 45 | +`; | |
| 46 | + | |
| 47 | +const AGENTS_INDEX_HTML = await renderPage({ | |
| 48 | + title: "agents — tdd.md", | |
| 49 | + bodyMarkdown: agentsIndexBody, | |
| 50 | + ogPath: "https://tdd.md/agents", | |
| 51 | + active: "agents", | |
| 52 | +}); | |
| 53 | + | |
| 54 | +const leaderboardBody = `# leaderboard | |
| 55 | + | |
| 56 | +> Empty. | |
| 57 | + | |
| 58 | +Scores will appear here once agents start submitting. | |
| 59 | +`; | |
| 60 | + | |
| 61 | +const LEADERBOARD_HTML = await renderPage({ | |
| 62 | + title: "leaderboard — tdd.md", | |
| 63 | + bodyMarkdown: leaderboardBody, | |
| 64 | + ogPath: "https://tdd.md/leaderboard", | |
| 65 | + active: "leaderboard", | |
| 66 | +}); | |
| 67 | + | |
| 68 | +const htmlResponse = (html: string) => | |
| 69 | + new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); | |
| 70 | + | |
| 35 | 71 | const port = Number(process.env.PORT ?? 3000); |
| 36 | 72 | |
| 37 | 73 | const server = Bun.serve({ |
| 38 | 74 | port, |
| 39 | 75 | routes: { |
| 40 | - "/": new Response(HOME_HTML, { | |
| 41 | - headers: { "Content-Type": "text/html; charset=utf-8" }, | |
| 42 | - }), | |
| 76 | + "/": htmlResponse(HOME_HTML), | |
| 43 | 77 | "/raw": new Response(Bun.file(HOME_MD), { |
| 44 | 78 | headers: { "Content-Type": "text/markdown; charset=utf-8" }, |
| 45 | 79 | }), |
| 46 | 80 | "/healthz": new Response("ok"), |
| 81 | + | |
| 82 | + "/games": htmlResponse(GAMES_INDEX_HTML), | |
| 83 | + "/games/:kata": async (req) => { | |
| 84 | + const res = await renderKata(req.params.kata); | |
| 85 | + if (res) return res; | |
| 86 | + const html = await renderNotFound(`/games/${req.params.kata}`); | |
| 87 | + return new Response(html, { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } }); | |
| 88 | + }, | |
| 89 | + | |
| 90 | + "/agents": htmlResponse(AGENTS_INDEX_HTML), | |
| 91 | + "/agents/:name": async (req) => { | |
| 92 | + const html = await renderPage({ | |
| 93 | + title: `${req.params.name} — agents — tdd.md`, | |
| 94 | + bodyMarkdown: `# agents / ${req.params.name}\n\n> Not yet registered or no attempts.\n\nWhen this agent submits a run, their commits and verdicts will appear here.`, | |
| 95 | + ogPath: `https://tdd.md/agents/${req.params.name}`, | |
| 96 | + active: "agents", | |
| 97 | + }); | |
| 98 | + return htmlResponse(html); | |
| 99 | + }, | |
| 100 | + "/agents/:name/:kata": async (req) => { | |
| 101 | + const html = await renderPage({ | |
| 102 | + title: `${req.params.name} / ${req.params.kata} — tdd.md`, | |
| 103 | + bodyMarkdown: `# agents / ${req.params.name} / ${req.params.kata}\n\n> No run yet.\n\nWhen \`${req.params.name}\` pushes \`${req.params.kata}\`, the trace will render here: each red, green, and refactor commit, plus the judge verdict.`, | |
| 104 | + ogPath: `https://tdd.md/agents/${req.params.name}/${req.params.kata}`, | |
| 105 | + active: "agents", | |
| 106 | + }); | |
| 107 | + return htmlResponse(html); | |
| 108 | + }, | |
| 109 | + | |
| 110 | + "/leaderboard": htmlResponse(LEADERBOARD_HTML), | |
| 47 | 111 | }, |
| 112 | + | |
| 113 | + async fetch(req) { | |
| 114 | + const url = new URL(req.url); | |
| 115 | + const html = await renderNotFound(url.pathname); | |
| 116 | + return new Response(html, { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } }); | |
| 117 | + }, | |
| 118 | + | |
| 48 | 119 | error(err) { |
| 49 | 120 | console.error(err); |
| 50 | 121 | return new Response("internal error", { status: 500 }); |