diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14c766ee..1a419d24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -75,6 +75,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Under the hood
+- **The builder's STATS & RULES page: stats and tables.** STATS lists the numbers players fill in,
+ in groups you name, each with its lowest and highest value, the stat it's tied to (a skill to its
+ ability) and a SAMPLE value for a made-up character. Each group becomes a section of the starter
+ sheet. TABLES holds lookups such as an ability score's modifier, as "up to / gives" rows, with a
+ TRY IT box. Formulas, with their live values, come next.
+
- **What the builder's STATS & RULES page will change.** Adding, renaming and removing stat groups
and stats (with their lowest, highest and ties), the sample character, formulas with their names,
and lookup tables with their rows. A new entry's id comes from its first name and never changes,
diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx
index 6cbab36a..33575dde 100644
--- a/frontend/src/components/BuilderScreen.tsx
+++ b/frontend/src/components/BuilderScreen.tsx
@@ -9,6 +9,7 @@ import { MySystemsPage } from './MySystemsPage';
import { SetupPage } from './SetupPage';
import { WordsPage } from './WordsPage';
import { FeaturesPage } from './FeaturesPage';
+import { StatsRulesPage } from './StatsRulesPage';
// The system builder (4a2b): it takes over the whole window, with no map. A sidebar down the left
// holds the system's name, its pages, SAVE and PUBLISH, and EXIT TO MAP; the open page fills the
@@ -320,7 +321,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
)}
{system && definition && page === 'words' && }
{system && definition && page === 'features' && }
- {system && !['problems', 'systems', 'setup', 'words', 'features'].includes(page) && (
+ {system && definition && page === 'rules' && }
+ {system && !['problems', 'systems', 'setup', 'words', 'features', 'rules'].includes(page) && (
{current.label}
{current.what} This page arrives in a coming update.
diff --git a/frontend/src/components/StatsRulesPage.tsx b/frontend/src/components/StatsRulesPage.tsx
new file mode 100644
index 00000000..ca258a9d
--- /dev/null
+++ b/frontend/src/components/StatsRulesPage.tsx
@@ -0,0 +1,178 @@
+import React, { useState } from 'react';
+import type { Definition } from '../sheets/systemsApi';
+import {
+ statGroups, withNewGroup, withGroupLabel, withoutGroup, withNewStat, withStat, withoutStat, sampleOf, withSample,
+ tableList, withNewTable, withTableLabel, withoutTable, withBands, lookupIn,
+} from '../sheets/statsRules';
+
+// The builder's STATS & RULES page (4b2d): the numbers players fill in (STATS) and the lookup
+// tables formulas read (TABLES), with FORMULAS between them (approved mockup
+// docs/mockups/builder-stats-rules.html, 2026-10-06). STATS are in groups, each shown as a section
+// of the starter sheet; skills are a group whose stats may tie to an ability; the SAMPLE column is a
+// made-up character for checking formulas, saved with the draft. What it reads and writes is
+// sheets/statsRules.ts; every change goes through the builder's `edit`, which autosaves it.
+
+interface Props {
+ definition: Definition;
+ edit: (next: Definition) => void;
+}
+
+type Tab = 'stats' | 'tables';
+
+const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 };
+const why: React.CSSProperties = { fontSize: 12, lineHeight: 1.45, opacity: 0.85 };
+const btn: React.CSSProperties = { fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '4px 9px' };
+const field: React.CSSProperties = {
+ background: 'var(--black)', color: 'var(--green)', border: '1px solid var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '5px 7px', minWidth: 0, boxSizing: 'border-box',
+};
+const th: React.CSSProperties = { textAlign: 'left', ...small, fontWeight: 'normal', padding: '6px 8px 4px 10px' };
+const td: React.CSSProperties = { padding: '3px 8px 3px 10px', verticalAlign: 'middle', borderTop: '1px solid color-mix(in srgb, var(--dark-green) 60%, transparent)' };
+const box: React.CSSProperties = { border: '1px solid var(--dark-green)', marginBottom: 12 };
+const head: React.CSSProperties = { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid var(--dark-green)', background: 'color-mix(in srgb, var(--green) 8%, transparent)', flexWrap: 'wrap' };
+const tabStyle = (on: boolean): React.CSSProperties => ({
+ background: on ? 'color-mix(in srgb, var(--green) 10%, transparent)' : 'none', border: 0, borderBottom: `2px solid ${on ? 'var(--green)' : 'transparent'}`,
+ color: on ? 'var(--green)' : 'color-mix(in srgb, var(--green) 55%, transparent)', fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '7px 14px', cursor: 'pointer',
+});
+
+/** A whole number box where empty means none (null). */
+const wholeOrNone = (v: string): number | null => (v.trim() === '' || !Number.isFinite(Number(v)) ? null : Math.round(Number(v)));
+
+function StatsTab({ definition, edit }: Props) {
+ const groups = statGroups(definition);
+ const abilities = groups[0]?.stats ?? [];
+ return (
+
+
+ The numbers players fill in on their sheet. Each group becomes a section of the starter sheet. The SAMPLE column is a made-up character to check formulas with.
+
+ Tables a formula reads by name, like an ability score to its modifier: mod(@str). Read top to bottom: the first row the number isn't above gives the answer.
+