This repository contains the code for a multiplayer Scrabble game. I wrote it because my wife and I got increasingly frustrated by the sluggish Web 1.0 interface that http://thepixiepit.co.uk/ provides.
I stumbled over http://code.google.com/p/html-scrabble/ one day, which implemented the interactive parts of a Scrabble board in a very nice manner. The implementation was lacking the game logic and server parts, so I forked the project and added the missing pieces.
The game has since been rewritten as a modern TypeScript monorepo with React, Express, and PostgreSQL, but the spirit of the original remains.
There is a fork of the original game which has reorganized source code, more languages, automatic players, better touch device support and an active maintainer. Have a look at https://github.com/cdot/CrosswordGame before you consider changing this version.
- Two to four players
- Czech, English, Estonian, French, German, Hungarian, Slovenian, Dutch and Turkish letter sets
- Real-time multiplayer via WebSockets
- Scalable, responsive UI (desktop and mobile)
- Desktop notification support
- Sound effects
- Tile placement by drag & drop or keyboard entry
- Chat
- Standard Scrabble rules including "Challenge" with simple penalty
- No dictionary enforced
- Player statistics
- Magic link authentication (no passwords)
- Client: React 19, Vite, Zustand, Tailwind CSS 4, Socket.IO, @dnd-kit
- Server: Express 5, Socket.IO, Drizzle ORM, PostgreSQL, jose (JWT), nodemailer
- Shared: Core game logic (board, tiles, scoring, validation)
- Runtime: Node.js >= 20, pnpm
pnpm installThe server is configured via environment variables:
DATABASE_URL— PostgreSQL connection string (default:postgres://localhost:5432/scrabble)PORT— Server port (default: 3000)BASE_URL— Public URL of the serverMAIL_SENDER— Sender address for invitation emailsSMTP_HOST,SMTP_PORT,SMTP_SECURE,SMTP_USER,SMTP_PASS— SMTP configuration
# Development (run in separate terminals)
pnpm dev # Client dev server (port 5173, proxies API to :3000)
pnpm dev:server # Server with auto-reload (port 3000)
# Production build
pnpm -r build
# Database migrations
pnpm --filter @scrabble/server db:migrate
# Tests
pnpm test # Shared package tests
pnpm test:all # All packagesThe server requires PostgreSQL. On FreeBSD:
# Install PostgreSQL
sudo pkg install postgresql16-server
# Enable and start the service
sudo sysrc postgresql_enable=YES
sudo service postgresql initdb
sudo service postgresql start
# Create the database and user
sudo -u postgres createuser scrabble
sudo -u postgres createdb -O scrabble scrabbleThe Drizzle schema migrations are run as part of the deployment steps below.
sudo pw useradd scrabble -d /opt/scrabble -s /usr/sbin/nologin -c "Scrabble service"Clone the repository and build:
sudo mkdir -p /opt/scrabble
sudo chown -R scrabble:scrabble /opt/scrabble
sudo -u scrabble git clone https://github.com/hanshuebner/html-scrabble.git /opt/scrabble
cd /opt/scrabble
sudo -u scrabble pnpm install --frozen-lockfile
sudo -u scrabble pnpm -r buildA FreeBSD rc.d service script is provided in deploy/scrabble.rc.
sudo cp deploy/scrabble.rc /usr/local/etc/rc.d/scrabble
sudo touch /var/log/scrabble.log
sudo chown scrabble:scrabble /var/log/scrabble.log
sudo chmod +x /usr/local/etc/rc.d/scrabble
sudo sysrc scrabble_enable=YESCreate /opt/scrabble/.env with the service configuration:
sudo -u scrabble tee /opt/scrabble/.env <<'EOF'
DATABASE_URL=postgres://scrabble@localhost:5432/scrabble
PORT=3000
BASE_URL=https://your-domain.com/
MAIL_SENDER=scrabble@your-domain.com
SMTP_HOST=smtp.your-domain.com
SMTP_PORT=587
SMTP_USER=...
SMTP_PASS=...
EOFRun database migrations and start the service:
cd /opt/scrabble
sudo -u scrabble pnpm --filter @scrabble/server db:migrate
sudo service scrabble startAfter pulling new code, run deploy/deploy.sh which installs dependencies
and restarts the service.
The repository includes a GitHub Actions workflow (.github/workflows/ci.yml)
that runs on every push and pull request to master:
- Test job — installs dependencies, builds all packages, runs lint, format check, database migrations, and tests against a PostgreSQL service container.
- Deploy job — on pushes to
masteronly, builds the project, rsyncs the built artifacts to the production server, and runsdeploy/deploy.shvia SSH.
To enable automated deployment, configure these GitHub repository secrets:
SSH_PRIVATE_KEY— private key for connecting to the server (see below)DEPLOY_HOST— hostname or IP of the production serverDEPLOY_USER—scrabble
On the production server, generate a dedicated key pair and configure it
for the scrabble user:
ssh-keygen -t ed25519 -f scrabble-deploy -C "github-actions-deploy" -N ""
sudo mkdir -p /opt/scrabble/.ssh
sudo cp scrabble-deploy.pub /opt/scrabble/.ssh/authorized_keys
sudo chmod 700 /opt/scrabble/.ssh
sudo chmod 600 /opt/scrabble/.ssh/authorized_keys
sudo chown -R scrabble:scrabble /opt/scrabble/.sshCopy the private key content for the next step, then delete the key files:
cat scrabble-deploy
# Copy this output — you will paste it into GitHub below
rm scrabble-deploy scrabble-deploy.pubIn GitHub, go to the repository Settings > Secrets and variables > Actions and add the following repository secrets:
SSH_PRIVATE_KEY— paste the private key content copied aboveDEPLOY_HOST— the server hostname or IPDEPLOY_USER—scrabble
The deploy script uses sudo service scrabble restart, so the scrabble
user needs passwordless sudo for that command:
sudo visudo -f /usr/local/etc/sudoersAdd this line:
scrabble ALL=(root) NOPASSWD: /usr/sbin/service scrabble *
The original server stored game data in a data.db file using the
dirty append-only database with
icebox serialization. A migration script
converts this data for import into the new PostgreSQL database.
cd /opt/scrabble
pnpm --filter @scrabble/server exec tsx scripts/migrate-from-dirty.ts /path/to/data.dbThis reads the dirty database, deduplicates entries (keeping the last write
for each game key), thaws icebox-serialized objects, and writes a
data-migrated.json file. The script prints a summary of each game found.
Make sure the database is set up and migrations have been run (see above), then start the server and import:
service scrabble start
curl -X POST http://localhost:3000/api/games/import \
-H "Content-Type: application/json" \
-d @/path/to/data-migrated.jsonThe endpoint returns a JSON object with the count of imported games and any errors:
{ "imported": 42, "errors": [] }All game state is preserved: board positions, player racks, scores, turn
history, and end-game results. The previousMove field (used for challenges)
is not migrated as its old serialization format is incompatible.
The initial migration had two bugs that caused turn data (moves, placements) not to be stored in the database for imported legacy games:
- Placement extraction —
migrate-from-dirty.tslooked for placements insidet.move.placements, but the legacy data stores them on the turn object itself (t.placements). Fixed by also checkingt.placementsandt.move.tilesPlaced. - moveData nesting —
importGame()ingame-service.tsstored the entire imported turn object asmoveData, causing placements to end up one level too deep (move_data.moveData.placementsinstead ofmove_data.placements). Fixed by normalizing the structure on import.
If you ran the initial import before these fixes, the imported games will have malformed turn data in the database (placements nested incorrectly). Stats derived from turns (bingos, highest word, tiles placed) will be wrong.
To fix this, first regenerate the migrated JSON (so placements are extracted correctly), then run the backfill script:
# Re-export with the fixed migration script
cd packages/server
npx tsx scripts/migrate-from-dirty.ts /path/to/data.db
# Backfill turns for legacy games
DATABASE_URL="postgres://..." npx tsx scripts/backfill-turns.ts /path/to/data-migrated.jsonThe backfill script matches games by key against the migrated JSON, deletes existing malformed turns, and re-inserts them with the correct structure. Games that have been modified since import (turn count differs from legacy data) are skipped. It is safe to run multiple times.
- Human players only. No computer players are available.
- No dictionary. Any word can be entered.
- Unlicensed. "Scrabble" is a registered trademark by Hasbro and Spear, and the word is used in this program without permission.
Enjoy, Hans (hans.huebner@gmail.com)