Spelljammer Ship Tracker
A phone-friendly web app for tracking Spelljammer ships during D&D sessions. Supports multi-ship CRUD, real-time sync via WebSocket, and a battle rules reference.
See App plan.md for the full product spec.
Tech Stack
- Backend: Node.js + Fastify + Socket.IO + better-sqlite3 + Zod
- Frontend: React 19 + Vite + react-router-dom + zustand + socket.io-client
- Language: TypeScript everywhere
- Database: SQLite (file-based, zero config)
- Deployment: Docker + Docker Compose
Prerequisites
- Node.js >= 18 (tested with v18.20.8)
- npm >= 8
- Docker + Docker Compose (for production deployment only)
Development Setup
# Install dependencies for both server and web
cd server && npm install
cd ../web && npm install
cd ..
# Or use the convenience script:
npm install # installs root deps (concurrently)
npm run install:all # installs server + web deps
Running in Development
Option A: Two terminals
# Terminal 1 — Backend (port 3000)
cd server && npm run dev
# Terminal 2 — Frontend (port 5173, proxies API to 3000)
cd web && npm run dev
Option B: Single command
npm run dev
Then open http://localhost:5173 in your browser.
The Vite dev server proxies /api and /socket.io requests to the Fastify server, so everything works from a single URL.
Production Deployment (Docker)
# Build and start
docker compose up -d --build
# Open http://localhost:3000
The Docker image:
- Multi-stage build (web build → server build → production)
- Serves both API and frontend from a single container on port 3000
- SQLite database stored in a Docker volume at
/data/spelljammer.sqlite
Stopping
docker compose down
Database Backup
# Copy the SQLite file out of the container
docker cp spelljammer-ships:/data/spelljammer.sqlite ./backup-$(date +%F).sqlite
Reverse Proxy (Optional)
If you want HTTPS, put Caddy or nginx in front:
# Example Caddyfile
spelljammer.yourdomain.com {
reverse_proxy localhost:3000
}
Testing
API Tests
Start the server, then run the test script:
cd server && npm run dev
# In another terminal:
bash server/test-api.sh
This runs through the full CRUD lifecycle: create ship → update stats → add weapons → fire (reduce ammo) → delete.
Real-Time Sync Test
- Open the app in two browser tabs
- Create a ship in one tab — it should appear in the other when you refresh the list
- Open the same ship in both tabs
- Change hull points in one — the other should update within ~1 second
- Add/edit/delete weapons — verify sync
Mobile Test
- Use Chrome DevTools (F12 → device toggle) to simulate a phone
- Or open the URL from your phone on the same network
Project Structure
├── server/ # Fastify backend
│ ├── src/
│ │ ├── index.ts # Entry point (Fastify + Socket.IO bootstrap)
│ │ ├── config.ts # Environment config
│ │ ├── db/ # SQLite connection + migrations
│ │ ├── routes/ # REST API endpoints
│ │ ├── services/ # Database query logic
│ │ ├── socket/ # Socket.IO event handlers
│ │ └── validation/ # Zod schemas
│ └── test-api.sh # curl-based API test script
├── web/ # React frontend
│ ├── src/
│ │ ├── App.tsx # Router setup
│ │ ├── socket.ts # Socket.IO client
│ │ ├── store/ # Zustand state stores
│ │ ├── pages/ # Route pages
│ │ ├── components/ # UI components
│ │ ├── rules/ # Battle reference content
│ │ └── types/ # TypeScript interfaces
│ └── vite.config.ts # Dev proxy config
├── Dockerfile # Multi-stage production build
├── docker-compose.yml # Single-service compose
└── App plan.md # Full product specification
API Reference
Ships
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/ships |
List all ships |
POST |
/api/ships |
Create a new ship |
GET |
/api/ships/:id |
Get ship with weapons |
PATCH |
/api/ships/:id |
Update ship fields |
DELETE |
/api/ships/:id |
Delete ship + weapons |
Weapons
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/ships/:id/weapons |
Add weapon to ship |
PATCH |
/api/weapons/:weaponId |
Update weapon |
DELETE |
/api/weapons/:weaponId |
Delete weapon |
WebSocket Events
| Direction | Event | Purpose |
|---|---|---|
| Client → Server | ship:join |
Join a ship's room |
| Client → Server | ship:leave |
Leave a ship's room |
| Client → Server | ship:update |
Update ship stats |
| Client → Server | weapon:create |
Add a weapon |
| Client → Server | weapon:update |
Update a weapon |
| Client → Server | weapon:delete |
Delete a weapon |
| Server → Client | ship:state |
Full ship+weapons state (on join) |
| Server → Client | ship:patched |
Ship field(s) updated |
| Server → Client | weapon:created |
New weapon added |
| Server → Client | weapon:patched |
Weapon field(s) updated |
| Server → Client | weapon:deleted |
Weapon removed |
Troubleshooting
Server won't start: Check that port 3000 is free (lsof -i :3000 or netstat -an | grep 3000).
WebSocket not connecting: In development, make sure Vite proxy is configured (check web/vite.config.ts). The proxy forwards /socket.io to port 3000.
Database locked: If the server crashes without clean shutdown, the SQLite WAL/journal files may remain. Delete data/spelljammer.sqlite-wal and data/spelljammer.sqlite-shm, then restart.
Docker build fails on better-sqlite3: The node:18-alpine image includes prebuilt binaries. If issues persist, switch to node:18-slim in the Dockerfile.