Multiplayer seagull racing game. Live at https://flappygull.val.run (Val Town val
stormfar/flappygull).
It used to be a Next.js app on Vercel with Supabase. It now runs as a single Val Town HTTP val:
a Hono server, SQLite (std/sqlite), blob storage for the images, and the original React +
Phaser game served to the browser with no build step.
main.ts HTTP entrypoint (Hono): page shell, /api, /assets, frontend modules
backend/
api.ts JSON API, replacing the browser's direct Supabase calls and Realtime
db.ts SQLite access + schema (std/sqlite on Val Town, libsql file locally)
assets.ts serves /assets/* and /favicon.ico from blob storage (public/ locally)
shell.ts the HTML page: import map (esm.sh), Tailwind browser compiler, fonts
styles.ts Tailwind v4 source (was app/globals.css)
static.ts serves frontend/ modules (std/utils on Val Town, esbuild locally)
frontend/
index.tsx, App.tsx entry + client-side router (was app/layout.tsx + the app/ routes)
pages/ the four Next pages: /, /game/multiplayer, /game/practice, /game/test
components/ the original lobby and results components
game/ the original Phaser game; systems/MultiplayerManager.ts now syncs over HTTP
lib/api.ts client for backend/api.ts (replaces lib/supabase.ts)
lib/types.ts Match / MatchPlayer / LeaderboardEntry types (were in lib/supabase.ts)
lib/navigation.ts drop-in for next/navigation (useRouter / useSearchParams)
dev/server.ts local dev server, not deployed
public/ game images (the source for the val's blob storage, see below)
assets/, docs/ source art and the original design docs
scripts/
sync-assets.ts copies public/ images from GitHub into the val's blob storage (runs on Val Town)
export-from-supabase.sql, import-sql.ts one-off data migration, not deployed
Imports use @/components/..., @/lib/... and @/game. The import map in backend/shell.ts
resolves those, plus react, react-dom and uuid from esm.sh and Phaser's ES module build
from jsDelivr, pinned to the versions in the old package-lock.json. (Phaser doesn't come from
esm.sh because esm.sh builds it from the CommonJS bundle, which has no named exports.)
None. The Next app only needed the Supabase URL and anon key.
Val Town doesn't accept WebSocket connections, so Supabase Realtime is replaced with HTTP:
- Lobby: the waiting room polls
GET /api/matches/:id/state(match + players) every 750 ms instead of subscribing topostgres_changes. Guests see the match start within a second, well inside the 3-second countdown, which is still timed from the match'sstarted_at. - In a match: each player POSTs its position to
/api/matches/:id/positionsabout 6 times a second (one request in flight at a time) and gets everyone else's latest position back in the same response. Supabase broadcast sent 20 a second. Ghost gulls are extrapolated between updates (MultiplayerManager.getOtherPlayers), so they still move smoothly. Positions live in thepositionstable and a player who goes quiet for 5 s disappears. - Clock skew: every API response carries the server time.
started_atis set by the server (it was the host's clock) and converted to each player's local clock, so countdowns and match timers line up across devices.
- Ghost position updates are ~6/s with extrapolation instead of 20/s (see above).
- Joining checks "match full" and duplicates on the server in one statement.
- "Leave match" actually removes you (the old RLS policies had no DELETE, so it silently failed).
- Starting a match is limited to the host (first player to join).
- Pressing Space after a multiplayer match ended used to restart it instantly and submit an extra 0-point score (visible in the old data). Space now does nothing there, and the score sent is the one at the moment the match ended.
match_players.final_score / best_flight / finished_atare now filled in when a score is submitted.- The beach umbrellas in the lobby background show up: their image URL contains a space and wasn't quoted, so browsers ignored it.
- Data copied from the Supabase project
flappygull(pennrorgrgastugiifle) withscripts/export-from-supabase.sql, keeping every id: 16 matches, 72 match players, 33 leaderboard entries (score totals verified identical). - Images copied into the val's blob storage with
scripts/sync-assets.ts. - The Vercel project
flappygulland the Supabase project are paused; https://flappygull.val.run is the only deployment.
deno run -A dev/server.ts # http://localhost:8787This uses dev/local.db and reads images from public/. To load a Supabase export into it:
FLAPPYGULL_LOCAL_DB=dev/local.db deno run -A scripts/import-sql.ts export.sql.
Upload changed files to the val stormfar/flappygull with the val.town editor or the vt CLI,
keeping their paths: main.ts is the HTTP file; everything in backend/ and frontend/,
and scripts/sync-assets.ts, are scripts. Don't upload dev/, the other scripts/, or deno.json.
After adding or changing an image in public/, add it to ASSET_FILES in backend/assets.ts,
push to GitHub, upload backend/assets.ts and run scripts/sync-assets.ts on Val Town (it copies
the images from GitHub into the val's blob storage; set ASSETS_GIT_REF for a branch other than main).