1
0
Fork 0
Custom Wordle clone with competitive features and friendly monsters. https://quindle.quinlan.cloud
  • TypeScript 96.1%
  • CSS 2%
  • Shell 0.8%
  • Dockerfile 0.7%
  • JavaScript 0.4%
Find a file
2026-07-11 01:00:44 +01:00
.claude Add launch config for the dev server 2026-07-11 01:00:44 +01:00
prisma Add per-game averages, admin comment badge and review banner 2026-07-11 00:59:29 +01:00
public Add public 2026-06-02 20:33:21 +01:00
scripts Add per-session hint tracking, fix ESLint, and silence npm notice 2026-06-17 00:27:02 +01:00
src Add per-game averages, admin comment badge and review banner 2026-07-11 00:59:29 +01:00
.dockerignore Initial commit 2026-06-02 20:06:32 +01:00
.gitignore Word list updates 2026-06-06 22:34:23 +01:00
docker-compose.yml Fix bugs, and switch to BEDA 2026-06-08 14:17:44 +01:00
docker-entrypoint.sh Fix build error 2026-06-02 20:42:53 +01:00
Dockerfile Add per-session hint tracking, fix ESLint, and silence npm notice 2026-06-17 00:27:02 +01:00
eslint.config.mjs Add per-session hint tracking, fix ESLint, and silence npm notice 2026-06-17 00:27:02 +01:00
next.config.mjs Initial commit 2026-06-02 20:06:32 +01:00
package-lock.json Add per-session hint tracking, fix ESLint, and silence npm notice 2026-06-17 00:27:02 +01:00
package.json Add per-session hint tracking, fix ESLint, and silence npm notice 2026-06-17 00:27:02 +01:00
postcss.config.mjs Initial commit 2026-06-02 20:06:32 +01:00
README.md Implement feature requests 2026-06-08 15:06:14 +01:00
tailwind.config.ts Initial commit 2026-06-02 20:06:32 +01:00
tsconfig.json Initial commit 2026-06-02 20:06:32 +01:00

Quin, the Quindle mascot

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.


Next.js React TypeScript Prisma Tailwind CSS Docker


Contents


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


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

  1. In docker-compose.yml, replace DB_PROVIDER: "sqlite" with:
DB_PROVIDER: "mysql"
MYSQL_HOST: "db"
MYSQL_PORT: "3306"
MYSQL_USER: "quindle"
MYSQL_PASSWORD: "quindlepass"
MYSQL_DATABASE: "quindle"
  1. 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

Quindles 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. fibre over fiber).

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.