Imported from sebastiansucker/schnechnen (
AGENTS.md). Install upstream withnpx skills add sebastiansucker/schnechnen. Copyright stays with the author.
Schnechnen - Agent Instructions
Project Overview
Schnechnen is a mobile-first math learning game built with vanilla JavaScript, HTML, and CSS. Players solve timed math problems (60 seconds) across 6 difficulty levels (Level 0 to 5), with dial-pad input optimized for touch devices. Features adaptive learning that repeats frequently missed problems, a statistics page with a history chart, and a self-hosted anonymous leaderboard.
Architecture
Core Components
public/game-logic.js: Pure game logic with no DOM dependencies (CONFIG, problem generation). Imported directly by unit tests and byscript.js.public/script.js: DOM/game-state layer built on top ofgame-logic.js- screen navigation, timer, highscores, game history.public/weighting.js: Standalone mistake tracking (localStorage with in-memory fallback). Also tracks per-fact hit/miss history (schnechnen-facts) for the Einmaleins heatmap on the stats page (see below).public/leaderboard.js/public/leaderboard-screen.js: Anonymous username generation and leaderboard UI/data loading.public/leaderboard-config.js:LEADERBOARD_ENABLEDflag (turned off for the GitHub Pages build).public/index.html: Five-screen flow (start → game → result / stats / leaderboard).public/style.css: Mobile-first responsive design with gradient-based color system.server.js: Static-file server + leaderboard API, backed by the built-innode:sqlitemodule.
Key Design Pattern: Dual Environment Support
The app runs in both browser and Node.js (for unit tests). game-logic.js has no DOM dependency at all and is imported as-is by test/unit-test.js. The createElements() function in script.js returns:
- Real DOM elements when
documentexists (browser) - Mock objects with minimal API when in Node.js (tests)
Critical: When adding new DOM elements, update both branches of createElements() and the mock objects in test/unit-test.js.
Level Configuration (CONFIG object, in game-logic.js)
All game mechanics are driven by CONFIG.levels[0-5]:
{
name: "Display name",
operations: ['+', '-', '*', '/'], // Allowed operators
maxNumber: 100, // Max operand value
minResult: 0 // Minimum acceptable result
}
- Level 0 (Addition bis 10): Introductory level, addition only, numbers up to 10.
- Level 1: Addition & Subtraktion bis 10.
- Level 2: Addition & Subtraktion bis 100.
- Level 3: Multiplikation bis 100.
- Level 4: Multiplikation & Division bis 100.
- Level 5 (🌪️ Chaos Mode): All four operations mixed, numbers up to 1000.
Level logic is centralized in generateProblemFor() in game-logic.js - modify here to change math rules.
State Management
Global gameState object (in script.js) holds runtime state. Reset via resetGame() (clears everything) or the back button handler (preserves highscores).
Game Modes
gameState.mode is one of:
'timed'(default, "⏱️ Zeitrennen"): 60-second timer, counts toward highscore/leaderboard.'practice'("🧘 Üben"): no timer, ends afterCONFIG.practiceProblemCount(default 20) problems (GameLogic.isPracticeRoundComplete()). Does not update highscore/leaderboard, but is still saved toschnechnen-history(withmode: 'practice') and feeds the mistake list like normal play.'mistakes'("❌ Fehler üben", started via a dedicated button on the result/stats screens, not the mode switcher): pulls exclusively fromWeighting.getMistakes(level)(highestwrongCountfirst, viapeekMistake()), never generates a new random problem. A problem is only removed once it has been answered correctly twice in a row (gameState.mistakeStreaks, keyed byGameLogic.mistakeKey(); a wrong answer resets the streak to 0). The round ends when the mistake list is empty.
The mode switcher on #start-screen only chooses between 'timed' and 'practice'; 'mistakes' is always started explicitly via startGame(level, 'mistakes') from the "❌ Fehler üben" button, which is disabled (with a "Keine Fehler zum Üben 🎉" label) when there are no mistakes for that level.
The game header shows either the timer (#timer-display) or a progress indicator (#progress-display, "Aufgabe X / Y" or "Noch N Fehler") depending on the mode — see updateGameHeaderForMode() / updateProgressDisplay().
Highscores
gameState.score / the persisted highscore is the number of correctly answered problems within the 60-second round, not a percentage.
Adaptive Learning System
generateProblem() integrates with weighting.js to implement spaced repetition:
- 30% probability to repeat a previously missed problem (via
peekMistake()) - Problems with higher
wrongCountare prioritized - Correct answers remove problems from the mistake pool (via
removeMistake()) - Wrong answers add/increment problems in the mistake pool (via
addMistake())
Development Workflows
Local Development
npm ci # Install dependencies
npm run start # Start server.js (game + leaderboard API) on :8080
# or: npm run start:simple # Static-only server (http-server), no leaderboard API
# Open http://localhost:8080
Testing Strategy
CRITICAL: Always run npm test before committing! All tests must pass before pushing changes.
Unit tests (test/unit-test.js, 27 tests): Run in Node.js against the real game-logic.js / weighting.js modules (problem generation, scoring, CONFIG validation, adaptive learning, practice/mistakes mode logic, Einmaleins-heatmap fact tracking/classification).
npm run test:unit # runs test/unit-test.js and test/server-test.js
test/server-test.js (10 tests) covers the server directly: JSON body parsing, rate limiting, and path-traversal protection.
E2E tests (test/e2e/, 672 tests across 6 Playwright browser projects): require the local server running.
npm run test:e2e # Headless run
npm run test:e2e:ui # Interactive UI mode
Run all tests before committing:
npm test # Runs unit + server + E2E tests (709 tests total)
Important: playwright.config.mjs starts the server itself (webServer) against a throwaway SQLite file, so E2E runs never touch real leaderboard data. baseURL is http://localhost:8080.
Test Utilities
script.js exposes a window.__TEST__ API for E2E tests (only when running on localhost or with ?e2e-test in the URL):
window.__TEST__.endGame()
window.__TEST__.startGame(level)
window.__TEST__.generateProblem()
Project Conventions
Mobile-First Input
- Dial pad is default: Input field is
readonly, users click dial buttons (85px × 85px on desktop, responsive down to 64px on small screens) - Dial pad layout: Bottom row organized as: Backspace (left) → 0 (center) → OK (right)
- Never auto-focus input (prevents mobile keyboard pop-up)
- Dial buttons use
data-valueattribute for digits (0-9) - Special buttons:
#backspace-btn,#submit-btn - Touch optimization:
touch-action: manipulationprevents zoom on rapid taps
Design System
- Color Palette: Gradient-based design
- Primary Orange:
#FF6B35 - Primary Turquoise:
#00B4D8 - Primary Purple:
#9D4EDD - Primary Pink:
#FF006E - Primary Teal:
#06A77D
- Primary Orange:
- CSS Variables: All colors defined in
:rootfor easy theming - Gradient Backgrounds:
linear-gradient(135deg, ...)throughout UI - Card Shadows:
0 8px 32px rgba(0, 0, 0, 0.1)for depth - Border Radius:
16pxstandard,20pxfor container
Operator Display
Internal operators (+, -, *, /) map to printable symbols via displayOperator():
*→×(multiplication sign)/→÷(division sign)
Always use displayOperator() when showing math problems to users.
LocalStorage Keys
schnechnen-highscores: JSON object{ "0": 12, "1": 8, ... }(level → highscore, i.e. number of correct answers)schnechnen-mistakes: JSON object managed byweighting.jsschnechnen-facts: JSON object managed byweighting.js, per-level hit/miss history per (commutatively normalized) task, feeding the Einmaleins heatmapschnechnen-keyboard-mode: boolean, dial-pad vs. native keyboardschnechnen-username: anonymous leaderboard display name
Screen Navigation
Five screens with the .hidden class toggled via showScreen():
start-screen→game-screen→result-screenstats-screenandleaderboard-screenare reachable fromstart-screen/result-screen- Back button returns to
start-screen(clears game state but keeps highscores)
Common Patterns
Adding a New Level
- Add an entry to
CONFIG.levels(ingame-logic.js) with operations array and constraints - Add a corresponding button to
index.htmlwithdata-level="N" - Create a dedicated E2E test file (e.g.
test/e2e/levelN-test.spec.js) - Update level button text in E2E test expectations
Problem Generation Logic
generateProblemFor() uses different strategies per operation:
- Division: Generate
resultandnum2first, thennum1 = num2 * result(ensures whole numbers) - Multiplication: Use
sqrt(maxNumber)for operands to keep results in range - Subtraction: Ensure
num2 ≤ num1to avoid negative results - Loop with
do-whileto enforceresult ≥ minResult
Mistake Tracking
Mistakes are tracked per level in weighting.js:
addMistake(level, { num1, num2, operation, result, wrongCount })
Called from checkAnswer() only on wrong answers. The wrongCount field is incremented for duplicate problems.
Einmaleins Heatmap (Stats Page, Level 3/4/5)
weighting.js also records every attempt (right or wrong) as a "fact" via recordAttempt(level, problem, isCorrect), called from checkAnswer() for both branches. Facts are keyed by normalizeFactKey(), which commutatively normalizes +/* (7 × 8 and 8 × 7 share a cell) and maps division onto its multiplication fact (56 ÷ 7 counts for the 7 × 8 cell, since the divisor and quotient are the two factors). classifyFact() turns a fact into 'gray' (never attempted) / 'red' (last wrong, or more wrong than right) / 'yellow' (mixed) / 'green' (≥3 correct, last correct) — this classification logic is DOM-free and unit-tested directly. script.js's renderTimesTable(level) renders a 10×10 CSS grid of <button> cells (only for levels 3-5) into #times-table-grid, called from updateStatsForLevel() before renderChart() so it doesn't depend on the Chart.js CDN script having loaded successfully.
Testing Notes
Flaky Test Prevention
- E2E tests must
await page.waitForSelector()before reading dynamic content - Use scoped selectors (e.g.,
#start-screen p) to avoid ambiguous matches - Check for
not.toHaveClass('hidden')rather than.toBeVisible()for reliability
Unit Test Mocking
When testing functions that use DOM elements, ensure mock objects in test/unit-test.js implement all accessed properties/methods (e.g., classList.add, textContent, focus).
Dependencies
- Playwright (
@playwright/test,playwright): E2E testing framework - http-server: Static file server for
npm run start:simple - eslint / @eslint/js: Linting (
npm run lint) - nyc: Coverage reporting for unit tests
No build step required - everything runs directly in the browser. The leaderboard backend (server.js) uses only Node's built-in node:sqlite, no extra database dependency.