- TypeScript 96.1%
- CSS 2%
- Shell 0.8%
- Dockerfile 0.7%
- JavaScript 0.4%
| .claude | ||
| prisma | ||
| public | ||
| scripts | ||
| src | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.yml | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| next.config.mjs | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| README.md | ||
| tailwind.config.ts | ||
| tsconfig.json | ||
Quindle
A cartoonish, British-English word game. One official word a day, a global leaderboard, and unlimited practice rounds — wrapped in a playful, hand-drawn UI and guarded by invite-only accounts.
Contents
- Why Quindle?
- Features
- Tech stack
- Architecture
- Quick start (Docker)
- Local development
- Scoring
- Configuration
- Switching to MySQL
- Admin CLI
- Word lists
- Project structure
Why Quindle?
Quindle is a self-hosted take on the daily word game, built for a small, private community rather than the whole internet:
- 🎯 One word a day that genuinely counts — everyone races the same puzzle on the same clock.
- 🧠 Practice whenever without polluting the leaderboard.
- 🧩 Invite-only — no open sign-ups, no spam, just the people you invite.
- 👾 Personality — every account gets a custom monster avatar, and Quin (the mascot above) shows you the ropes.
Features
| 📅 Daily word | One official 5-letter word per day (midnight Europe/London), never repeated. |
| 🕛 Midnight grace | Start the daily before midnight and finish after the word rolls over — your streak still counts if you complete within the grace window (default 3 hours, configurable). |
| ♾️ Practice mode | Unlimited rounds that don't touch the leaderboard. |
| 🏆 Leaderboard | All-time / monthly / weekly, scored on guesses, streak, and speed. |
| 🥇 Daily top 10 | Today's standings shown right in the result modal. |
| 💡 Hint tokens | Three per calendar month (non-cumulative). Spend one to reveal the daily word's definition — the answer stays hidden and your score is unaffected. Balance shown on the Daily page. |
| ✏️ Free letter placement | Opt-in profile setting (off by default). Tap any cell in your current row to move the caret there and type letters out of order — handy for placing known letters first. |
| 📊 Guess distribution | Profile bar chart of how many guesses it takes you to solve each daily word (won games only). |
| ✉️ Invite system | Admins mint one-time invite links; users pick their own credentials. |
| 👾 Monster avatars | 50 procedurally-drawn cartoon monsters × 12 background colours (plus transparent), customisable per user. |
| 🌙 Dark mode | Follows system preference, overridable per user. |
| 📖 Definitions | Revealed on completion — definitions, IPA phonetics and a UK audio pronunciation from the BEDA (British English Dictionary API), with a free dictionaryapi.dev fallback. |
| 💡 Feature requests | Players submit ideas anonymously (author visible to admins only) into a moderation queue; admins accept, decline or merge them. Accepted ideas land on a public board where players upvote and comment (comments show their author). |
| 🛠️ Admin panel | Manage words, browse daily history, search & manage users, handle invites, and moderate feature requests. Adjust a user's streak, total points (delta or absolute), and remaining hint tokens. Today's word stays masked until the admin has played it. |
| 🐳 One-container deploy | SQLite by default, or MySQL via environment variables. |
Tech stack
- Framework — Next.js 16 (App Router) + React 19
- Language — TypeScript
- Auth — Auth.js / NextAuth v5 (credentials, JWT sessions)
- Database — Prisma 6 over SQLite or MySQL
- Styling — Tailwind CSS + Framer Motion
- Avatars — custom procedurally-generated SVG monsters (self-contained, no external service)
- Validation — Zod
- Packaging — Docker (single image, runtime DB provider switch)
Architecture
flowchart TD
Player([👤 Player]) --> App[Next.js App Router]
App --> Pages[Server Components<br/>Daily · Practice · Leaderboard · Admin]
App --> API[Route Handlers<br/>/api/*]
API --> Auth[Auth.js<br/>credentials + JWT]
API --> Logic[Game logic<br/>scoring · daily selection]
API --> Prisma[Prisma ORM]
API --> Dict[Definitions<br/>BEDA / dictionaryapi.dev]
Prisma --> DB[(SQLite / MySQL)]
The daily word is chosen deterministically per Europe/London date and stored so it's stable for everyone and never repeats. Scoring (base + streak + speed) is computed server-side on each daily completion. The answer is never sent to the client until the game is finished — guess validation and tile colouring all happen on the server.
Quick start (Docker — SQLite)
git clone https://your-forgejo-host/you/quindle.git
cd quindle
Edit docker-compose.yml — at minimum change these two values:
AUTH_SECRET: "replace-with-a-long-random-string" # e.g. `openssl rand -base64 32`
NEXTAUTH_URL: "https://quindle.yoursite.com" # your public domain
Then build, start, and create the first admin account:
docker compose up -d --build
docker compose exec app node node_modules/.bin/tsx scripts/create-admin.ts \
--username admin --password "choose-a-good-password" --display "Quiz Master"
The app listens on port 3000 — put a reverse proxy in front of it.
Minimal nginx reverse proxy
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
NEXTAUTH_URL must be the public URL your users visit — it's used to generate invite links. The container sets AUTH_TRUST_HOST: "true" so Auth.js reads the public URL from forwarded headers.
Local development
# 1. Install dependencies
npm install
# 2. Create the data dir and push the schema
mkdir -p data
npm run prisma:push
# 3. Seed the word lists
npm run seed
# 4. Create your first admin
npm run create-admin -- --username admin --password secret123 --display "Admin"
# 5. Start the dev server
npm run dev
Open http://localhost:3000. Leave NEXTAUTH_URL unset in .env for local dev — Auth.js derives the URL from the request automatically.
Handy scripts
| Command | What it does |
|---|---|
npm run dev |
Start the Next.js dev server |
npm run build |
prisma generate + production build |
npm run start |
Run the production build |
npm run lint |
ESLint |
npm run prisma:push |
Sync the Prisma schema to the database |
npm run seed |
Load the answer + guess word lists |
npm run create-admin |
Create or promote an admin account |
Scoring
Fewer guesses earn more points:
| Guesses used | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| Base points | 100 | 80 | 60 | 45 | 30 | 20 |
- 🔥 Streak multiplier — each consecutive daily win adds +5% (capped at +50%). A 5-day streak ⇒ ×1.20.
- ⚡ Speed bonus — up to +30 points for solving in under 30 seconds; fades to 0 at 5 minutes. The clock starts on your first key press (not page load), so idle time before you start typing is free.
Only daily games count toward the leaderboard. Practice is purely for fun.
Hint tokens do not reduce your score. Each user receives three per month (reset on the 1st, Europe/London); unused tokens do not roll over.
Configuration
All configuration is via environment variables (set them in docker-compose.yml for Docker, or .env for local dev).
| Variable | Default | Description |
|---|---|---|
AUTH_SECRET |
(required) | Long random string for signing JWTs — change in production |
AUTH_TRUST_HOST |
true |
Trust X-Forwarded-* headers from a reverse proxy |
NEXTAUTH_URL |
(unset) | Public base URL — required in production for invite links |
DB_PROVIDER |
sqlite |
sqlite or mysql |
DATABASE_URL |
auto | Prisma connection string (override if needed) |
MYSQL_HOST |
— | MySQL hostname (mysql mode only) |
MYSQL_PORT |
3306 |
MySQL port |
MYSQL_USER |
— | MySQL user |
MYSQL_PASSWORD |
— | MySQL password |
MYSQL_DATABASE |
— | MySQL database name |
BEDA_API_URL |
https://beda.quinlan.cloud |
Base URL of the British English Dictionary API |
BEDA_API_KEY |
(empty) | BEDA API key — blank uses the free dictionaryapi.dev fallback |
DAILY_TIMEZONE |
Europe/London |
IANA timezone for the daily word boundary |
DAILY_GRACE_HOURS |
3 |
Hours after midnight during which an unfinished previous-day daily can still be completed for streak credit |
NODE_ENV |
production |
Set automatically in the Docker image |
Switching to MySQL
- In
docker-compose.yml, replaceDB_PROVIDER: "sqlite"with:
DB_PROVIDER: "mysql"
MYSQL_HOST: "db"
MYSQL_PORT: "3306"
MYSQL_USER: "quindle"
MYSQL_PASSWORD: "quindlepass"
MYSQL_DATABASE: "quindle"
- Start with the MySQL profile (spins up a bundled MySQL 8 container):
docker compose --profile mysql up -d --build
The entrypoint builds DATABASE_URL from those variables and runs prisma db push on startup.
Admin CLI
Create or promote an admin account at any time:
# Docker
docker compose exec app node node_modules/.bin/tsx scripts/create-admin.ts \
--username alice --password "s3cret" --display "Alice"
# Local
npm run create-admin -- --username alice --password "s3cret" --display "Alice"
If the username already exists, the account is promoted to ADMIN and its password is reset. Admins can also create users directly or send invite links from the admin panel.
Word lists
Source
Quindle’s allowed-guess list is the full set of five-letter, a-z-only British-English words served by the BEDA (British English Dictionary API), which scrapes the Cambridge UK English dictionary. The daily/practice answer candidates are then narrowed to words in common parlance by ranking that list against a British-English frequency list, so obscure dictionary entries (e.g. amias) never become daily words.
| Allowed guesses | BEDA GET /v1/words?length=5&alpha=true&all=true |
| Answer frequency source | hermitdave FrequencyWords (OpenSubtitles en) |
| Build script | scripts/fetch-word-lists.ts (needs BEDA_API_KEY) |
BEDA is contacted once by the build script and the response is cached in .cache/ (gitignored); the frequency list is cached there too. What ships in the repo are the curated text outputs below, so neither the app nor production needs to call BEDA again at runtime for the word lists.
Files in the repo
src/data/allowed.txt— every valid 5-letter a-z word from BEDA (accepted as guesses).src/data/answers.txt— the ~5,000 most frequent playable words from that list (well over ten years of unique daily words), with British spellings preferred where both exist (e.g.fibreoverfiber).
On a fresh database, npm run seed (or the Docker entrypoint on first start) loads these files idempotently — it only inserts words that are not already present.
Admins can also add/remove words and toggle answer-candidate status from the admin panel.
Updating the word lists (local)
Use this workflow when you want to regenerate the text files from upstream, or after editing them by hand:
# 1. Rebuild src/data/*.txt from BEDA (caches the response in .cache/ on first run)
BEDA_API_KEY=your_beda_key npm run fetch-word-lists
# 2. Preview the migration (keeps game history)
npm run migrate-words -- --confirm --dry-run
# 3. Apply to your local database
npm run migrate-words -- --confirm
# 4. Commit the updated text files
git add src/data/answers.txt src/data/allowed.txt
git commit -m "Update word lists from BEDA"
Updating production (Docker)
Rebuilding the image bakes the new src/data/*.txt files into the container, but does not change words already in the database — the entrypoint only seeds when the word table is empty. After deploying new list files you must run migrate-words inside the container (this keeps all game sessions and daily history).
SQLite (default):
# Always back up before changing words
cp data/quindle.db "data/quindle.db.bak.$(date +%Y%m%d)"
# Pull, rebuild, and restart
git pull
docker compose up -d --build
# Preview, then migrate (history preserved)
docker compose exec app node node_modules/.bin/tsx scripts/migrate-words.ts --confirm --dry-run
docker compose exec app node node_modules/.bin/tsx scripts/migrate-words.ts --confirm
MySQL — same commands, but start the stack with the mysql profile:
docker compose --profile mysql up -d --build
docker compose exec app node node_modules/.bin/tsx scripts/migrate-words.ts --confirm
You do not need to run fetch-word-lists on the server unless you are regenerating lists there; production only needs the committed src/data/*.txt files from the image.
Restoring from backup (e.g. after accidental history loss)
If game sessions were wiped (e.g. by running replace-words without --delete-sessions on an older build, or by restoring the wrong file), restore the database from your backup before migrating words.
SQLite (default):
# 1. Stop the app so nothing writes to the DB
docker compose stop app
# 2. Restore the backup over the live database
cp data/quindle.db.bak.YYYYMMDD data/quindle.db
# — or copy your backup file to data/quindle.db —
# 3. Start the app
docker compose up -d
# 4. Verify sessions came back (expect a non-zero count)
docker compose exec app node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.gameSession.count().then(n => { console.log('Game sessions:', n); p.\$disconnect(); });
"
# 5. Deploy the latest code (with migrate-words.ts), then migrate words safely
git pull
docker compose up -d --build
docker compose exec app node node_modules/.bin/tsx scripts/migrate-words.ts --confirm --dry-run
docker compose exec app node node_modules/.bin/tsx scripts/migrate-words.ts --confirm
MySQL — restore your mysqldump or volume snapshot, confirm session counts, then run migrate-words as above.
What each command does
migrate-words (preferred for production) — upserts words from src/data/, updates flags on existing words, and preserves all GameSession, DailyWord, and Definition rows. Words that only exist because of past games are deactivated but kept so foreign keys stay valid.
replace-words --confirm --delete-sessions (nuclear option) — deletes all game sessions, daily assignments, definitions, and words, then reloads from scratch. Only use on a fresh install or when you explicitly want to wipe history:
npm run replace-words -- --confirm --delete-sessions
Kept by migrate-words |
Cleared only by replace-words --delete-sessions |
|---|---|
| Users, passwords, avatars | All game sessions (daily + practice) |
| Invites | Daily word assignments |
| User stats (streaks, points, hint tokens) | Cached word definitions |
| Game sessions & guess history | All words (then re-inserted from lists) |
Project structure
quindle/
├── prisma/
│ ├── schema.prisma # User, Word, DailyWord, GameSession, …
│ └── seed.ts # Loads the word lists
├── scripts/
│ ├── create-admin.ts # Admin bootstrap CLI
│ ├── fetch-word-lists.ts # Fetch from BEDA and rebuild src/data/*.txt
│ ├── migrate-words.ts # Update DB words, preserve game history
│ └── replace-words.ts # Nuclear word swap (deletes all sessions)
├── src/
│ ├── app/
│ │ ├── (app)/ # Authenticated app: daily, practice, leaderboard, admin
│ │ ├── api/ # Route handlers (game, admin, auth, definitions)
│ │ ├── login / invite # Public auth flows
│ │ └── icon.svg # Quin — the favicon
│ ├── components/game/ # Board, tiles, keyboard, modals, hints, mascot
│ ├── lib/ # Game logic, scoring, hints, daily selection, helpers
│ └── data/ # answers.txt + allowed.txt
└── docker-compose.yml
Made with 💜 and a friendly monster named Quin.