6 KiB
Picker
A simple web app to select who pays the bills: everyone puts a finger on the phone screen, and after a suspenseful roulette one of you is chosen.
Plain HTML, CSS and vanilla JS modules. No build step and no dependencies. Works on any static host and installs as an offline PWA.
Modes
- Pick one: one finger is chosen.
- Pick several: N fingers are chosen.
- Teams: everyone is split into 2–4 balanced teams.
- Order: everyone gets a number, revealed one at a time.
- Elimination: one finger is knocked out per round until one remains. Letting go early counts as being knocked out.
Settings (gear button on the idle screen) also cover the stakes text
("pay the bill" → "You pay the bill!"), theme (neon, pastel, or high
contrast with a distinct shape per finger), language (English/Dutch), sound
and vibration. Settings are saved in localStorage.
How a round works
- Put two or more fingers down. Once the set of fingers has been stable for 2.5 s, a three-beat countdown starts. Adding a finger restarts the wait. Lifting one shows a "chickened out" message.
- The outcome is drawn with
crypto.getRandomValuesbefore the animation starts. The roulette is only presentation and always lands on the pre-drawn result. - The result stays on screen after fingers lift. Once everyone has let go, tap to play again (the tapping finger joins the next round).
Project layout
| File | What |
|---|---|
js/main.js |
wiring + frame loop |
js/input.js |
Pointer Events per pointerId, gesture blocking, debug fake fingers |
js/game.js |
state machine (idle → gathering → countdown → suspense → result) |
js/modes.js |
outcome planning and roulette schedules for each mode |
js/random.js |
unbiased crypto randomness |
js/renderer.js |
canvas rendering (pre-rendered glow sprites) |
js/reveals.js |
reveal animations: flood, confetti, spotlight, shockwave, calm |
js/audio.js |
Web Audio sound effects + vibration |
js/ui.js |
overlay text, toolbar, settings sheet |
js/settings.js |
persisted settings |
js/i18n.js |
all strings (en, nl) |
js/themes.js |
colour themes |
js/device.js |
wake lock, fullscreen |
sw.js |
offline caching (network-first, cache fallback) |
tools/make_icons.py |
regenerates the PNG icons (Python stdlib only) |
Testing on a desktop
Open http://localhost:8000/?debug=1:
- Click to place a fake finger, click it again to remove it, drag to move it.
- A adds a fake finger at a random spot, C clears them all.
?debug=1&fingers=4starts with 4 fake fingers in a circle.?debug=1&open=settingsopens the settings sheet on load.- A HUD shows the phase, finger count and fps. The service worker is not registered in debug mode, so you always get fresh files.
Automated checks (serve the folder, then open in any browser):
tests/logic.html: state machine and fairness tests.tests/sim.html?mode=order&n=5&t=11500: renders one simulated frame at a given time (mode,n,t,theme,style,k,teams,reduced).
Testing on your phone (local network)
Serve the folder from your PC:
python -m http.server 8000 --bind 0.0.0.0
Find your PC's LAN IP (ipconfig on Windows, ip addr on Linux), then open
http://<that-ip>:8000/ on the phone. On Windows, allow Python through the
firewall for private networks when asked.
Over plain http:// on a LAN address, the page is not a secure context.
These features need HTTPS (or localhost):
| Feature | Plain http on LAN |
|---|---|
| Touch, canvas, crypto randomness, sound, vibration, fullscreen | works |
| Service worker / offline mode | needs HTTPS |
| Install as app (PWA) | needs HTTPS |
| Screen wake lock | needs HTTPS |
To test the secure-only features:
- Android + USB (easiest): enable USB debugging, open
chrome://inspectin desktop Chrome or Edge, click Port forwarding and map8000→localhost:8000. Then openhttp://localhost:8000/on the phone.localhostcounts as secure, so everything works. - Android without USB: on the phone, open
chrome://flags/#unsafely-treat-insecure-origin-as-secureand addhttp://<your-ip>:8000. Use this for testing only. - Anywhere, iOS included: put it behind HTTPS (see Docker below, behind your reverse proxy).
Browser notes:
- Browsers only allow sound and vibration after a tap or a finger lift. A finger going down doesn't count. If the very first round after opening is silent, tap the screen once (or use any toolbar button) first.
- iOS Safari has no vibration API and no fullscreen API on iPhone, so those controls are hidden there. Add it to the home screen for a full-screen experience.
- Devices cap simultaneous touches (iPhone: 5, most Android phones: 10). The app handles whatever arrives.
Docker
The image is plain unprivileged nginx serving the static files on port
8080, with a /healthz endpoint.
docker build -t picker .
docker run --rm -p 8080:8080 picker
On every push to main, the GitHub Actions workflow
(.github/workflows/docker.yml) publishes a multi-arch (amd64 + arm64)
image to GHCR:
ghcr.io/<owner>/<repo>:latestonmain:1.2.3and:1.2when you push av1.2.3tag:sha-abc1234on every build
Example docker-compose.yml for a home server:
services:
picker:
image: ghcr.io/<owner>/<repo>:latest
restart: unless-stopped
ports:
- "8080:8080"
Point your reverse proxy (Caddy, Traefik, nginx proxy manager, …) at port 8080 for your subdomain and terminate HTTPS there. HTTPS is required for offline mode, installing and wake lock. All paths are relative, so it also works under a sub-path.
GHCR packages are private by default. Either make the package public
(GitHub → your profile → Packages → picker → Package settings), or run
docker login ghcr.io on the server with a token that has read:packages.
Icons
icons/*.png are generated: python tools/make_icons.py.