Files
bibdle/README.md
T
2026-07-07 12:13:35 -04:00

11 KiB
Raw Blame History

Bibdle

A daily Bible verse guessing game. Each day a verse is shown and players try to identify which of the 66 books of the Bible it comes from, receiving Wordle-style feedback (Testament match, Section match, Adjacent book, First letter, etc.) after each guess. A new verse is generated daily and progress is tracked across timezones.

Live at bibdle.com.

Tech Stack

  • Framework: SvelteKit 2 with Svelte 5 (runes: $state, $derived, $effect, $props)
  • Styling: Tailwind CSS 4
  • Database: SQLite (bun:sqlite) with Drizzle ORM
  • Auth: Session-based — email/password (argon2id via Bun.password), plus Apple and Google OAuth
  • Bible text: Local NKJV XML (EnglishNKJBible.xml), parsed with fast-xml-parser; a Greek 1904 and Swedish 2000 translation are also bundled for alternate modes
  • ML (currently disabled): @xenova/transformers verse embeddings for a similarity search route
  • Deployment: @sveltejs/adapter-node, run under Bun, managed by a systemd service (see bibdle.service)

Getting Started

bun install

# Start the dev server (Vite, Bun runtime)
bun run dev

# Type checking
bun run check

# Tests (Bun test)
bun test
bun test tests/timezone-handling.test.ts   # single file
bun test --watch

# Production build & preview
bun run build
bun run preview

Database

bun run db:push      # push schema changes directly (avoid in prod)
bun run db:generate  # generate migrations
bun run db:migrate   # run migrations
bun run db:studio    # open Drizzle Studio GUI

Environment Variables

See .env.example. Required/used variables:

  • DATABASE_URL — path to the SQLite database file (e.g. prod.db)
  • PUBLIC_SITE_URL — canonical site URL
  • CRON_SECRET — bearer token protecting the cron-only /api/send-daily-verse endpoint
  • DISCORD_DAILY_WEBHOOK — webhook for posting the daily verse to Discord
  • AUTH_SECRET — secret for Apple Sign-In
  • APPLE_ID / APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_PRIVATE_KEY — Apple Sign-In credentials
  • SMTP_USERNAME / SMTP_TOKEN / SMTP_SERVER / SMTP_PORT — email (nodemailer)

Architecture

Database Schema (src/lib/server/db/schema.ts)

  • userid, firstName, lastName, email (unique), passwordHash, appleId (unique), googleId (unique), isPrivate
  • sessionid (SHA-256 hash of token), userId (FK), expiresAt
  • dailyVerses — cached daily verse: date (unique), bookId, verseText, reference, createdAt
  • dailyCompletions — one row per player/date: anonymousId, date, guessCount, guesses (JSON of book IDs, nullable), completedAt. Unique on (anonymousId, date) to prevent duplicate submissions.

Sessions expire after 30 days and auto-renew when fewer than 15 days remain.

Bible Data (src/lib/types/bible.ts)

The bibleBooks array lists all 66 books with metadata:

  • testament: old | new
  • section: Law, History, Wisdom, Major Prophets, Minor Prophets, Gospels, Pauline Epistles, General Epistles, Apocalyptic
  • order (166, used for adjacency detection)

Game Logic

  • src/lib/utils/game.tsevaluateGuess() compares a guess to the target book and returns testamentMatch, sectionMatch, adjacent, and firstLetterMatch flags. getGrade() maps guess count to a letter grade (S+ down to C). Includes a special-case so that numbered Epistles (e.g. "1 John") match on first letter against any other numbered Epistle.
  • src/lib/stores/game-persistence.svelte.ts — reactive store that keeps guesses and per-day flags in sync with localStorage, keyed by date (bibdle-guesses-${date}). Resolves the player identity (logged-in user ID, or a locally generated anonymous UUID) and restores state.
  • src/lib/utils/share.ts — generates the share grid and text. Hint emojis: exact · 🟩 section · 🟧 testament · ‼️ adjacent · 🟥 no match.

Daily Verse System

src/lib/server/daily-verse.tsgetVerseForDate(date): returns the cached verse for a date if present, otherwise fetches a random verse from the local XML Bible and stores it permanently. The XML Bible is read and parsed in src/lib/server/xml-bible.ts; src/lib/server/bible-api.ts wraps it to produce a verse with a validated bookId, reference, and verseText.

Authentication (src/lib/server/auth.ts)

  • Token: base64url-encoded random bytes; stored as a SHA-256 hash in the DB. Cookie name: auth-session.
  • Anonymous users are identified by a client-generated UUID in localStorage. On sign-up, migrateAnonymousStats() re-attributes the user's dailyCompletions rows from the anonymous ID to the new user ID (duplicates for overlapping dates are dropped).
  • Apple Sign-In (src/lib/server/apple-auth.ts) and Google Sign-In (src/lib/server/google-auth.ts) are supported; the SvelteKit CSRF config trusts https://appleid.apple.com for the cross-origin form_post callback.

Routes

Pages

Route Description
/ Main game (classic mode). Verse display, search input, guesses table, win screen, streak/percentile. +page.ts sets ssr = false so the load runs client-side with the true local date, then POSTs that date to /api/daily-verse.
/imposter Imposter Mode — four verses are shown; three come from one book and one from a different book. Pick the one that doesn't belong.
/random Debug page showing a random verse from the NKJV.
/greek-random Debug page showing a random verse in parallel Greek (1904) / English (NKJV).
/similarity Search a sentence and return the most similar Bible verses via the (currently disabled) embeddings model.
/about About page with the project's backstory and social links.
/global Public stats dashboard: completions today/all-time, unique & weekly & monthly players, active streak distribution, 14-day completions trend, retention/return-rate metrics.
/progress Personal progress page (requires auth): activity calendar, 66-book grid with mastery tiers, insights, and achievements/milestones.
/stats Personal stats page (requires auth); returns requiresAuth: true for unauthenticated visitors and renders a sign-in modal.
/dev Local-time / countdown debug page.

API Endpoints

Endpoint Description
POST /api/daily-verse Fetch (and cache) the verse for a given YYYY-MM-DD date.
POST /api/submit-completion Submit a game result (anonymousId, date, guessCount, guesses); returns solve rank, guess rank, total solves, average guesses, ties, and percentile. Unique on (anonymousId, date).
GET /api/streak?anonymousId=X&localDate=Y Current streak: walks backwards from the client's local date through completed dates, counting consecutive days. Single-day streaks are reported as 0 (minimum displayed streak is 2).
GET /api/streak-percentile?streak=N&localDate=Y Streak percentile ranking computed across all players' current streaks.
GET /api/stats Aggregated stats used by the /global dashboard.
GET /api/imposter Generates a four-verse imposter-mode round.
POST /api/similar-verses Semantic verse search via embeddings.
POST /api/send-daily-verse Cron-only (bearer CRON_SECRET); posts today's verse to the Discord webhook.
POST /api/dev/seed-history Dev seeding helper.

Other Endpoints

  • GET /feed.xml — RSS feed of daily verses.
  • GET /sitemap.xml — XML sitemap for SEO.

Critical: Date/Time Handling

Bibdle is played across many timezones. The verse shown must always be the verse for the calendar date at the player's location — never the server's timezone, never UTC.

  • The client computes today's date with new Date().toLocaleDateString("en-CA") (YYYY-MM-DD) and sends it to the server.
  • Server-side date arithmetic always uses UTC methods on the client-provided date string (new Date(dateStr + 'T00:00:00Z') + setUTCDate/getUTCDate) to avoid timezone drift — see /api/streak and /api/send-daily-verse.
  • / has ssr = false so the load runs client-side with the real local date.
  • The main page also reloads itself if the tab regains focus on a new calendar day.
  • The user's local date is never placed in the URL; it is only ever sent to API routes.

Streak Calculation

A streak counts consecutive calendar days (in the player's local timezone) on which the puzzle was completed:

  • The client passes localDate; the server never uses its own clock.
  • /api/streak walks backwards from localDate through dailyCompletions, counting each completed day; stops at the first missing day. It's called from the win screen, so today is typically completed. Single-day streaks are reported as 0 — the minimum displayed streak is 2.
  • /api/streak-percentile (which ranks all players) anchors on today-if-played-else-yesterday, so a player's streak isn't zeroed mid-day before they've had a chance to complete today's puzzle.

Key Files

File Purpose
src/routes/+page.svelte Main game UI and client-side logic
src/routes/+page.server.ts / +page.ts Server load (user/session) + client load (ssr: false, fetches the daily verse)
src/routes/+layout.svelte App shell, title animation, theme toggle, analytics injection
src/lib/server/auth.ts Session management, password hashing, anonymous→user migration
src/lib/server/apple-auth.ts, google-auth.ts OAuth providers
src/lib/server/daily-verse.ts Per-date verse caching/lookup
src/lib/server/xml-bible.ts Local XML Bible parsing (NKJV / Greek / Swedish)
src/lib/server/bible-api.ts Random verse fetching on top of the XML parser
src/lib/server/bible.ts Bible book utility functions
src/lib/server/milestones.ts Achievement/milestone calculation (set-completion, streak, etc.)
src/lib/types/bible.ts 66-book metadata and TypeScript types
src/lib/utils/game.ts Guess evaluation and grading
src/lib/utils/share.ts Share grid/text generation
src/lib/utils/streak.ts, stats-client.ts, stats.ts Client-side streak/stats fetching and formatting
src/lib/stores/game-persistence.svelte.ts Reactive localStorage-backed game state
src/lib/server/db/schema.ts Drizzle ORM schema
src/hooks.server.ts Session validation hook; (commented-out) embeddings init
tests/ Bun test suites: timezone, game, bible, stats, share, sign-in migration

Deployment

Production uses @sveltejs/adapter-node run under Bun via a systemd service. deploy.sh pulls latest, installs deps, builds, and restarts bibdle.service (which runs bun --bun build/index.js on port 5173 with DATABASE_URL=prod.db). See bibdle.service for the unit file.

Background

Bibdle was created as a small, daily nudge to read the Bible — inspired by the Wordle story of a personal project that grew organically through word of mouth. The full backstory lives on the /about page.