A real-time multiplayer version of the classic game Stadt Land Fluss. A Go backend and a React frontend communicate exclusively over WebSockets; the server is the single source of truth, there is no database, the entire game state lives in RAM.
- Lobbies with a 6-character alphanumeric code, QR code and share link
- Host controls: create/edit/delete categories, time limit, enable/disable letters, remove players, end the running round
- Round flow: countdown -> answers -> buzzing -> review -> result
- Live review by the host, including merging identical answers
- Optional game modes (toggleable per lobby):
- Commentator host: the host does not play and instead sees a live overview of which categories each player has filled in
- Last letter instead of first: answers must end with the letter instead of starting with it
- Flames: bet once per round on being the "only answer" (+5, see below)
- Scoring by classic rules (see below)
- Reconnect: a reload, tab close or brief connection drop lead back into the
game; as soon as the network or tab is back, the client reconnects immediately
(the session lives in
localStorage, see Architecture) - Join anytime: you can join a lobby that is already running. Whoever joins mid-game waits as a spectator (sees the fill-in overview, does not yet appear in the table) and plays from the next round on
- Host disconnect protection: if the host loses the connection, all players see a 15-second countdown; if the host returns, the game continues, otherwise the lobby is closed (SRS 4.6/8.5)
- Light/dark theme, responsive layout, keyboard controls
| Area | Technologies |
|---|---|
| Backend | Go, coder/websocket, in-memory |
| Frontend | React 19, Vite, TypeScript, Tailwind CSS 4, Base UI (shadcn style), Zustand, TanStack Router, Zod |
| Tooling | Bun (package manager), air (Go live-reload), Nix (dev shell) |
- Server = single source of truth. Clients send actions, the server validates them, mutates the state and broadcasts the new state. No client is trusted for points/rules.
- State machine per lobby:
Lobby -> Countdown -> Playing -> Reviewing -> RoundResult -> GameOver -> Lobby - Message format (JSON over
/ws):- Client -> Server:
{ "type": ..., "payload": ..., "sessionId": ... } - Server -> Client:
{ "type": ..., "payload": ..., "stateVersion": ... }(stateVersion= monotonic lobby counter for detecting stale snapshots)
- Client -> Server:
- Session/reconnect: the
sessionIdlives inlocalStorageand therefore survives closing the tab: on the next open the client reconnects automatically as the same player (same name, points, role), as long as the lobby still exists. A host keeps its role within the 15-second grace window. Trade-off (deliberate): all tabs of the same browser share one identity; a second tab connects into the same session instead of becoming its own player. An explicit leave is a final exit: the client clears the storedsessionIdand the server invalidates the session, so a replayed id cannot bring the player back. Only a tab close/connection drop remains re-joinable.
| Points | Condition |
|---|---|
| 0 | invalid or empty |
| 5 | valid, but the same answer as another player |
| 10 | valid, unique answer |
| 20 | only valid answer in the category |
During review the host can mark answers as valid/invalid and merge answers that mean the same thing (they then count as one group).
Flames (if enabled): once per round a player can "flame" a category, a bet that they hold the only valid answer there. If the bet pays off, it gives +5 points (unique answer 10 -> 15, only valid 20 -> 25); with a shared or invalid answer the category counts 0.
- Server-authoritative: rules, scoring and state are enforced only on the server; incoming events are validated (backend + Zod).
- WebSocket origin check against cross-site hijacking (same-origin +
localhost/LAN allowed, otherwise 403;
WS_ALLOWED_ORIGINSas an override). - Rate limiting per connection, join backoff and caps for lobbies, players and categories; orphaned lobbies are cleaned up automatically.
- Security headers (CSP, HSTS with
preload, COOP, and more) for the served SPA; content-hashed assets are cachedimmutable,index.html/robots.txtrevalidate.sessionIdis never leaked to other clients and only logged hashed.
backend/
cmd/server/ Entry point (HTTP server + /ws + optional frontend)
cmd/loadtest/ Load/latency measurement tool (see "Performance")
internal/game/ Domain logic: models, engine (letters, rules, scoring)
internal/websocket/ Hub, clients, message handlers, broadcasts
frontend/
src/pages/ Home, Lobby and game screens (game/)
src/components/ UI building blocks (incl. ui/ = shadcn style on Base UI)
src/store/ Zustand stores (lobby, game)
src/lib/ WebSocket client, theme, session, validation
src/types/ Shared event/payload types
Dockerfile Multi-stage build -> one image (frontend + Go server)
docker-compose.yml Starts the published image
.gitlab-ci.yml CI: tests + image build/push to Docker Hub
flake.nix Nix dev shell (Go, Bun, air, ...)
Alternatively the Nix flake provides everything:
nix developcd backend
air # live-reload on file changes
# or without air:
go run ./cmd/servercd frontend
bun install
bun devThen open the shown URL in the browser. For a local multiplayer game, connect
several tabs/devices on the same network (the join link/QR code points to
<host>/join/<code>).
# Backend: format check, vet, tests (incl. WS integration tests)
cd backend && gofmt -l . && go vet ./... && go test ./...
# Frontend: Biome lint, unit tests (bun test), typecheck + production build
cd frontend && bun run lint && bun run test && bun run buildThe same steps run in CI (.gitlab-ci.yml): gofmt gate + go vet
go testfor the backend, Biome lint +bun test+ build for the frontend.
Backend and frontend sit in one image (multi-stage build): the frontend is
built and served by the Go server as static files together with /ws on port
8080.
docker compose up -d # pulls weilerleonhard/slf:latest -> http://localhost:8080
docker compose up --build # build locally instead (enable the build: line in compose)The GitLab CI builds the image after every commit on master and pushes it to
weilerleonhard/slf (tags latest + commit SHA). For that, the variables
DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (Docker Hub access token, "masked")
must be set in GitLab under Settings -> CI/CD -> Variables.
| Variable | Default | Meaning |
|---|---|---|
STATIC_DIR |
- | Directory with the built frontend; empty = only /ws (dev) |
LOG_LEVEL |
info |
debug | info | warn | error |
LOG_FORMAT |
text |
text | json |
LOG_FILE |
- | additionally log to a file (in Docker on the slf-logs volume) |
WS_ALLOWED_ORIGINS |
Same-origin + localhost/LAN | Additionally allowed WebSocket origins (comma-separated). By default same-origin as well as localhost/private LAN IPs are allowed; set this to allow the frontend from a different domain |
Two isolated, reproducible measurements (targets: avg < 80 ms, max < 150 ms roundtrip, >= 500 concurrent players):
# 1) Pure logic (no network): scoring & ranking
cd backend && go test -bench=. -benchmem -run=^$ ./internal/game/
# 2) End-to-end roundtrip under load (server must be running)
go run ./cmd/server &
go run ./cmd/loadtest -players 500 # connects 500 players, measures broadcast latencyThe tool spreads the players across many lobbies, has all hosts trigger a state
change at the same time and measures, per client, the time until the resulting
lobbyState broadcast (avg/p50/p95/p99/max).
These points are known and chosen deliberately; they are not a problem for the intended use (LAN/party game):
- A single global mutex in the hub serializes all lobbies. Not critical at the current scale (load test: 500 players, avg < 5 ms roundtrip); sharding per lobby would only be needed with very many concurrent games and was therefore deliberately not implemented.
- Clipboard fallback via
document.execCommand("copy"): over plain HTTP on the LAN (typical: phone connects via a local IP)navigator.clipboardis not available. The fallback uses the officially deprecated but still widely supportedexecCommand, a deliberate pragmatic compromise. - No host handoff: if a disconnected host does not return within the 15 s, the lobby is closed (no transfer of the host role), kept deliberately simple for a party game.
- No persistence layer: the entire state lives in RAM. After a server restart the lobbies are gone; clients are disconnected cleanly and return to the home screen.
This project is licensed under the terms of the GNU General Public License,
version 3 or (at your option) any later version (GPL-3.0-or-later). See the
LICENSE file for the full license text.