Mini games for maths and computer science lessons. One teacher hosts, the class joins with a six-digit code on their own devices, and everyone plays the same game at the same time.
Teachers need an account; students do not.
Needs Node 24 or newer — the server uses the built-in node:sqlite, so there is
no native module to compile — and pnpm.
pnpm install
pnpm dev:server # game server on :3000
pnpm dev # client, proxying /parties to the game serverpnpm dev on its own is enough to look at a single stage: /preview lists
every one of them and plays it in the tab, with no server and no account. It is
served only by the dev build.
Then create an account for yourself and sign in at /login:
pnpm teacher add you@example.com "Your Name"The bitflow game runs @bitflow/web-component from the sibling bitflow
checkout (link:../bitflow/packages/web-component in package.json) until the
release that exports createShareableReport is on npm; build that package
first (pnpm --filter @bitflow/web-component build in ../bitflow), and switch
the dependency to its npm version once it is published.
pnpm check:games # builds a round of every stage of every game
pnpm check:server # signs in, opens a lobby, plays a round, restarts the server
pnpm test # every stage of every game, as a player sees it, through axe
pnpm lint
pnpm buildA React single-page app, a Node process holding the lobbies, and one SQLite file.
A lobby lives in memory for as long as it is being played, and is mirrored to SQLite on every change, so restarting the server mid-lesson puts players back into the round they were in. Results are written when a game ends. Lobbies close themselves two hours after they are opened, so a forgotten browser tab cannot keep one alive.
Everything about the games themselves — the stage model, settings, scoring, and how to add a game — is in documentation.md.
The client is static files; the server is one long-running Node process. A
reverse proxy serves the first and forwards /parties/* to the second, so both
are on the same origin.
pnpm install --frozen-lockfile
pnpm build # writes dist/
node --import tsx server/index.ts # serves /parties on $PORTAUTH_SECRET |
required. Signs every session. Generate with openssl rand -base64 32, set it before the first start, and never change it — replacing it signs every teacher out. |
BASE_URL |
required. The public URL, e.g. https://lms.example.com. Sign-in requests from another origin are rejected, and it is what decides that session cookies are marked Secure. |
PORT |
Port the game server listens on. Defaults to 3000. |
DB_PATH |
SQLite file. Defaults to ./data/lms.db. |
Keep the secret in a file the process manager reads, not in the repository.
One process. Lobbies live in memory and each owns its own timers, so a second worker would hold a different set of lobbies — half a class would be told their code does not exist, depending on which worker the proxy handed them to. Whatever runs it, do not cluster it.
Give it at least 15 seconds between SIGTERM and SIGKILL. The server flushes
live lobbies to SQLite on SIGTERM, and some process managers kill much sooner
than that by default.
Raise the open-file limit if more than a few hundred students will be connected
at once. Every player holds a socket, and the usual default of 1024 descriptors
is reached long before the machine runs out of anything: new connections then
fail with EMFILE while memory and CPU still look idle. Most process managers
can set it per app, or raise it for the user that runs the server.
Point the process manager at node --import tsx server/index.ts directly, not
at pnpm start or the tsx binary. Both run the server in a child process, so
the manager ends up supervising a wrapper: memory limits watch the wrong
process, and anything in between that does not forward signals swallows the
shutdown.
Gameplay is a WebSocket, which needs two things beyond a plain proxy_pass.
deploy/nginx.conf is a complete example; the parts that
matter:
- Forward the upgrade. On nginx that means
proxy_http_version 1.1plusUpgrade/Connectionheaders, where$connection_upgradecomes from amap $http_upgradeblock in the http block. Without the map the variable is empty, no socket ever upgrades, and the site looks fine while no game works. - Raise the idle timeout. A lobby sits quiet between rounds and while the teacher explains something. nginx closes an idle proxied connection after 60 seconds by default, which drops every socket in the room.
Serve dist/ as the document root with a single-page fallback, so unknown paths
reach the router instead of returning 404.
The web server's own user has to be able to reach that directory. Deploying into
a home directory usually means it cannot: those are created without access for
other users on most current distributions, and the failure is confusing — the
fallback file cannot be read either, so instead of a permission error you get a
redirection cycle and a 500. namei -l path/to/dist/index.html shows which
directory in the chain refuses, and traverse permission (chmod o+x) on that
directory is enough; the alternative is to keep the built client somewhere the
web server already serves.
.github/workflows/deploy.yml builds, runs both checks, copies the result over
ssh and restarts the process. Nothing about the target lives in the repository;
it reads:
| Secret | |
|---|---|
DEPLOY_SSH_HOST |
hostname of the server |
DEPLOY_SSH_USER |
user that owns the app directory and the process |
DEPLOY_SSH_KEY |
a private key with no passphrase, whole. Generate one just for deploys: ssh-keygen -t ed25519 -N '' -f lms_deploy |
| Variable | |
|---|---|
DEPLOY_PATH |
absolute path to deploy into |
DEPLOY_HOST |
public hostname, for the post-deploy health check |
The workflow refuses to start if any of these is missing, since rsync --delete
against an empty path would empty the wrong directory.
There is no sign-up page. Accounts exist because someone made one on the server:
pnpm teacher add anna@example.com "Anna Klein" # prints a password once
pnpm teacher list
pnpm teacher password anna@example.com # new password
pnpm teacher remove anna@example.comThe CLI reads the same environment as the server, so make sure DB_PATH points
at the right file.
Everything durable is the one SQLite file: lobbies, results, teacher accounts
and sessions. It is in WAL mode, so sqlite3 lms.db ".backup ..." can copy it
without stopping the server.
MIT — see LICENSE.