TyperPunk
Competitive typing for the terminal and the browser. It has solo practice, live multiplayer races, code and command drills, and a practice mode that targets the keys you personally get wrong.
The web client and the terminal client share one Rust core, so both score a test the same way.
Contents
What it does
- Solo tests by word count, by time, or on a fixed passage.
- Live races against other people, or against bots when nobody else is around.
- Code and command drills. Each one explains what the line does.
- Practice mode, which generates text weighted toward the characters you mistype or hesitate on.
- Custom text. Import your own notes and work through them over several sittings.
- Accounts, personal bests, a leaderboard, and friends.
Running it
You need Rust and Node.js. The web client also needs wasm-pack, which
web/launch.sh installs if it is missing.
Web client
./web/launch.sh
This builds the WebAssembly core, merges the text packs, and serves the app
on http://localhost:4173. There is no bundler and no npm dependency tree.
Edit a file under web/src/ and reload the page.
The multiplayer, account and leaderboard features need the server as well. See Server.
Terminal client
cargo run --package typerpunk-tui
./install.sh builds it and puts it on your path.
Modes
| Mode | What it types |
|---|---|
| Random | A passage from any pack |
| Words | A fixed number of common words |
| Timed | As many words as you can before the clock runs out |
| Zen | No timer and no word limit |
| Practice | Words weighted toward your own weak keys |
| Custom | Text you import, including markdown notes |
| Lyrics | The song currently playing on Spotify |
Alongside those are the text packs, listed below.
Sixteen typing languages are available for the generated-word modes. This
sets the vocabulary you type, not the language of the interface. The
interface is English only. See TODO-ui-languages.md.
Text packs
data/packs/*.json holds the passages. Each entry looks like this:
{
"category": "quotes",
"content": "The best way out is always through.",
"attribution": "Robert Frost"
}
attribution is optional. Leave it out for original prose rather than
inventing a source. Code entries take two more fields:
{
"category": "shell",
"language": "shell",
"content": "awk -F: '{print $1, $7}' /etc/passwd",
"attribution": "awk",
"explanation": "-F sets the field separator."
}
language is one of javascript, python, rust, clike or shell, and
selects the syntax highlighting. explanation is shown while you type in
single player, and after the race in multiplayer.
After editing a pack, rebuild the merged dataset:
node scripts/merge_packs.js
This writes texts.json and web/src/data/texts.json.
Passages must be a single line. The typing input is one line, so a passage containing a newline cannot be finished.
Community submissions
Signed-in users can submit passages from the Contribute screen. Nothing reaches other players until a moderator approves it. Approved passages are served from the database and merged over the bundled packs when the client starts.
To fold approved submissions back into the repository:
node scripts/export_approved.js http://localhost:8787
node scripts/merge_packs.js
The first administrator is named by TYPERPUNK_ADMIN_USERNAME at startup.
Administrators appoint moderators from the Contribute screen.
Server
typerpunk-server provides accounts, stats, the leaderboard, friends,
multiplayer rooms and the Spotify integration. It needs PostgreSQL.
sudo -u postgres createuser --pwprompt typerpunk
sudo -u postgres createdb -O typerpunk typerpunk
sudo -u postgres createdb -O typerpunk typerpunk_test
cp crates/server/.env.example crates/server/.env
cargo run --package typerpunk-server
Migrations run at startup. Configuration is by environment variable; see
crates/server/.env.example for the full list.
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
PORT |
Listen port, default 8787 |
FRONTEND_ORIGIN |
Origin allowed by CORS |
COOKIE_SECURE |
Set to 1 behind TLS |
TYPERPUNK_ENV |
Set to production to enforce the checks below |
TYPERPUNK_ADMIN_USERNAME |
Account to make an administrator at startup |
TEXTS_JSON_PATH |
Dataset the race passages come from |
SPOTIFY_CLIENT_ID |
Spotify application ID, for Lyrics mode |
SPOTIFY_CLIENT_SECRET |
Spotify application secret |
STRIPE_SECRET_KEY |
Stripe API key, for the store |
STRIPE_WEBHOOK_SECRET |
Signing secret for the Stripe webhook |
Store
The store sells cosmetics and a supporter subscription. Checkout is hosted by Stripe: the buyer is redirected there, pays there, and returns. No card details reach this server.
Nothing is granted at checkout. Stripe calls /api/billing/webhook when the
payment succeeds, the server verifies the signature, and only then does the
item appear. Set both Stripe variables. With either one missing, the store
still displays but every checkout answers 501.
Prices come from the cosmetics, bundles and merch tables. The client
sends an item id, never an amount.
Shirts, mugs and deskmats are sold from the same store. These need an address, which Stripe collects on its own checkout page, so no postal detail is entered on this site. Postage is charged as a separate line so the buyer can see it.
A physical order unlocks nothing. It records an obligation to pack and post
something. /api/admin/orders lists what has been paid for and not yet sent,
with the address, and the Contribute screen shows the same list to an
administrator. Mark an order posted there once it goes out. Nothing emails
the buyer; that is still a manual step.
Shipping is limited to the countries in SHIPPING_COUNTRIES in
crates/server/src/billing.rs. Add to that list only where you are willing to
post and handle returns.
Deployment
Put a reverse proxy in front. Serve web/ as static files and route
/api/* and /ws/* to the server. That makes the API same-origin, so no
CORS configuration is needed in the browser.
Set TYPERPUNK_ENV=production. The server then refuses to start if:
COOKIE_SECUREis not 1, which would send the session cookie in clear.DATABASE_URLis still the development default.FRONTEND_ORIGINishttp://on a host that is not local.
A warning in a log nobody reads is not a safeguard, so these are refusals rather than warnings.
Terminate TLS at the proxy and send HSTS from there. The application sets the other security headers itself.
Secrets
Every setting is read from the environment. Copy crates/server/.env.example
to crates/server/.env for local work. That file is gitignored and is the
only place a password or a client secret belongs.
In production, prefer real environment variables to a file on disk. A systemd
unit can take them from EnvironmentFile=, with the file owned by root and
mode 600:
[Service]
EnvironmentFile=/etc/typerpunk/env
ExecStart=/usr/local/bin/typerpunk-server
User=typerpunk
Container runtimes and hosting platforms have their own secret stores. The server reads plain environment variables in every case, so nothing in the application changes.
Do not put a secret in .env.example, in a commit message, or in an issue.
If one is exposed, rotate it: change the database password, restart the
server, and invalidate sessions by clearing the sessions table.
The first administrator is created by setting TYPERPUNK_ADMIN_USERNAME to an
account that has already registered. Unset it afterwards. It exists to create
the first administrator and to recover if the last one is removed.
Development
cargo test --workspace --exclude typerpunk-steam # Rust
cd web/tests && python3 run_all.py # browser
The browser tests drive the real application with Playwright. They need both
servers running; see web/tests/README.md.
crates/steam is a Bevy desktop client and is excluded from the default test
run because it is slow to build.
Repository layout:
crates/core typing engine, shared by every client
crates/wasm WebAssembly bindings for the web client
crates/tui terminal client
crates/server HTTP and WebSocket server
crates/steam desktop client (Bevy)
web/ web client, no build step
data/packs/ text packs
scripts/ dataset tools
Terminal client parity
The terminal client has accounts, the leaderboard and friends. Multiplayer racing is on hold there: it needs a WebSocket client and a live-updating race view, which is close to the size of the whole web multiplayer build.
Security
- Passwords are hashed with Argon2. Sessions are HttpOnly, SameSite=Lax
cookies, and Secure when
COOKIE_SECUREis set. - Every query is parameterised. Every value rendered into the page is escaped, including imported file content and file names.
- Custom text is read in the browser and never uploaded.
- The Spotify and lyrics endpoints have fixed upstream hosts, so neither can be pointed elsewhere. Both are rate limited.
- The client sends a Content-Security-Policy that forbids inline script.
- Dependencies are checked against the OSV database. The server build has no known advisories.
Report a security problem by opening an issue, or privately if it is exploitable.
Licence
MIT. See LICENSE.