6.6 KiB
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.basefrombase-path.js) as arguments to thesveltekit()plugin invite.config.js. #lib/*imports.src/libis reached through theimportsfield inpackage.json(import { settings } from "#lib/settings-store.svelte.js"), always with the file extension.jsconfig.jsonextends$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.