Files

6.6 KiB
Raw Permalink Blame History

Lô Tô

Web version of Lô tô hội chợ — Vietnamese fairground bingo. Built with SvelteKit (static export). The caller draws numbers one at a time; a host panel manages the game and called numbers are announced aloud in Vietnamese using bundled audio clips. No runtime TTS or server required.

Live: tiennm99.github.io/loto

Android port: android/ — a native Kotlin + Jetpack Compose app at feature parity with this one (this app is the behavioral spec). It mounts static/audio/ straight into its APK, so the voice clips exist once.


Features

  • Number board — 1–90 grid; called numbers highlight as they are drawn
  • Host panel — enable "Chế độ quản trò" (host mode) in settings to reveal the master draw panel inline below your player card
  • Vietnamese voice callouts — pre-generated MP3 clips for every number using Microsoft Edge TTS (edge-tts); multiple voices selectable in settings; no API key or internet connection needed at runtime
  • Auto-cross player card — optional automatic crossing of numbers on the player's bingo card as they are called
  • Auto-countdown — configurable delay between number draws for unattended calling
  • PWA — installable as a progressive web app; works offline after first load
  • Static export — no server; deploys to any static host or GitHub Pages

Requirements

Node.js 18+ and npm.


Quick Start

git clone https://github.com/tiennm99/loto
cd loto
npm install
npm run dev     # dev server at http://localhost:5173

Commands

Command Description
npm run dev Start development server
npm run build Production build (root base path)
npm run build:gh Production build for GitHub Pages (/loto base path)
npm run preview Preview production build locally
npm test Run unit tests
npm run lint ESLint check
npm run check svelte-check type check (JSDoc)
npm run verify:pwa Check the service worker in build/ (after a build, same BUILD_PROFILE)

Configuration

Copy .env.example to .env.local and set:

Variable Description
CODESERVER_HOST Hostname for code-server reverse-proxy dev (optional)
CODESERVER_PORT Port for code-server proxy (optional)

When running inside code-server use npm run dev:codeserver and open https://<CODESERVER_HOST>/absproxy/<CODESERVER_PORT>/ (use /absproxy/, not /proxy/ — the latter strips the path prefix and breaks SvelteKit routing).


Architecture

Single-page SvelteKit app, statically exported. No backend.

src/
├── routes/          # Single route: /
├── lib/
│   ├── game-logic.js          # Draw state, number pool, called-number tracking
│   ├── master-store.svelte.js # Host panel state (Svelte 5 runes store)
│   ├── settings-store.svelte.js
│   ├── voice.js               # Audio manifest loader + clip player
│   ├── player-auto-cross.js   # Auto-cross logic
│   ├── MasterPanel.svelte     # Host draw UI
│   ├── PlayerBoard.svelte     # Bingo card + number grid
│   └── AutoCountdown.svelte   # Countdown timer between draws
static/
└── audio/{voiceId}/   # Pre-generated MP3 clips (one per number per voice)
scripts/
└── generate-audio.py  # Regenerates audio clips via edge-tts

Game state lives entirely in Svelte 5 rune stores; no server round-trips.

Build config

  • No svelte.config.js. SvelteKit 3 takes its options (adapter-static, paths.base from base-path.js) as arguments to the sveltekit() plugin in vite.config.js.
  • #lib/* imports. src/lib is reached through the imports field in package.json (import { settings } from "#lib/settings-store.svelte.js"), always with the file extension. jsconfig.json extends $app/tsconfig.
  • Base-path-safe URLs. Static files go through asset() from $app/paths (no leading /: asset("audio/…")), so they resolve under both "" and /loto.

PWA

The service worker comes from vite-plugin-pwa (generateSW, registerType: "prompt"); the manifest is the hand-written static/manifest.webmanifest. +layout.svelte registers it through virtual:pwa-register and wires onNeedRefresh to the update banner in src/lib/update-prompt.svelte.js. It precaches the app shell plus the default voice; other voices are cached on first play.

@vite-pwa/sveltekit is not used: it generates sw.js when Vite's SSR build closes, which assumed SvelteKit 2's build order. SvelteKit 3 builds SSR first, then the client, then prerenders, all inside its buildApp hook, so vite.config.js drops vite-plugin-pwa's own build step and calls its generateSW() from a buildApp hook that runs after SvelteKit's and before the adapter copies .svelte-kit/output into build/. npm run verify:pwa checks the result after a build (run it with the same BUILD_PROFILE). Audio manifest is generated once at build prep time — the app reads a JSON manifest on load and plays the appropriate clip for each drawn number.


Regenerating Audio

Voice clips in static/audio/ were generated with edge-tts (free, no API key). To regenerate (e.g. to add a new Microsoft vi-* voice):

pip install edge-tts
python3 scripts/generate-audio.py

The script auto-discovers all available vi-* voices and writes an updated manifest.


Regenerating Icons

static/icons/source.svg is the source for icon-192.png and icon-512.png; static/icons/source-maskable.svg (full-bleed background, wordmark inside the 80% safe zone) is the source for icon-maskable-512.png. Render them with the real Roboto Condensed Black Italic TTF loaded explicitly — a fallback font is wider and clips the wordmark. Any SVG rasterizer that accepts a font file works, e.g. @resvg/resvg-js with font: { fontFiles: [...], loadSystemFonts: false } and fitTo set to each PNG's exact pixel width. Open the results and check nothing touches the edges.


Deployment

Deployed to GitHub Pages by the deploy-pages job in .github/workflows/ci.yml on push to main. Build command: npm run build:gh (sets /loto base path). Static output in build/.

The same workflow also deploys the base-"" build to Firebase Hosting and bundles it into the debug APK, so web/ is built once per base path per run.


License

Apache-2.0 — see LICENSE at the repository root.