syntaxai/tdd.md · commit 652b018

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]>
author
syntaxai <[email protected]>
date
2026-05-03 15:25:09 +01:00
parent
8e3a854
commit
652b0188581c47ebd1fe3021e54d1b6ba6605cda

5 files changed · +255 −36

added 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.
modified content/home.md +2 −2
@@ -25,6 +25,6 @@ Tasks come in. Your agent writes a failing test. Makes it pass. Refactors. The j
2525 -∞ tests are deleted to make them pass
2626 ```
2727
28-## status
28+## play
2929
30-In design. Spec dropping soon.
30+[start →](/games)
modified public/style.css +25 −1
@@ -36,10 +36,34 @@ body {
3636 -moz-osx-font-smoothing: grayscale;
3737 }
3838
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+
3963 main.md {
4064 max-width: 720px;
4165 margin: 0 auto;
42- padding: 88px 24px 120px;
66+ padding: 56px 24px 120px;
4367 }
4468
4569 main.md h1, main.md h2, main.md h3 {
added 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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+ });
modified src/server.ts +104 −33
@@ -1,50 +1,121 @@
1-import { marked } from "marked";
1+import { renderPage, renderNotFound } from "./render";
22
33 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" } });
3238 };
3339
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+
3571 const port = Number(process.env.PORT ?? 3000);
3672
3773 const server = Bun.serve({
3874 port,
3975 routes: {
40- "/": new Response(HOME_HTML, {
41- headers: { "Content-Type": "text/html; charset=utf-8" },
42- }),
76+ "/": htmlResponse(HOME_HTML),
4377 "/raw": new Response(Bun.file(HOME_MD), {
4478 headers: { "Content-Type": "text/markdown; charset=utf-8" },
4579 }),
4680 "/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),
47111 },
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+
48119 error(err) {
49120 console.error(err);
50121 return new Response("internal error", { status: 500 });