Race a friend (or the clock) on nonogram puzzles.
Daily mode: one puzzle per grid size per day, play solo and share your time.
Multiplayer: create a room, share the link, host starts the timer — first to finish wins. No login; optional username.
I was bored. I procrastinate often with nonograms. I am also very competitive so I wanted to find a way to beat my friends at nonograms so my wasted time can be somewhat useful.
- Daily — One puzzle per size (2×2, 10×10, 15×15, 20×20) per day (Eastern). Date-seeded, no server storage. Copy-paste your score; state is saved in the browser per size.
- Multiplayer 1v1 — Create a room, pick grid size, share the room code (or link). Host starts the game; everyone sees the same puzzle and timer. First to complete correctly wins. Closing the tab counts as leaving the room.
- No account — Optional username; player id is stored in the browser. Rooms are ephemeral (see Technical notes).
npm install
cp .env.example .env.local
# Add Pusher and Upstash env vars (see below)
npm run devOpen http://localhost:3000.
| Variable | Where | Purpose |
|---|---|---|
NEXT_PUBLIC_PUSHER_KEY |
Client + server | Pusher app key (public) |
NEXT_PUBLIC_PUSHER_CLUSTER |
Client + server | e.g. us2, eu — must match Pusher dashboard |
PUSHER_APP_ID |
Server | Pusher app id |
PUSHER_APP_KEY |
Server | Same as NEXT_PUBLIC_PUSHER_KEY |
PUSHER_SECRET |
Server | Pusher secret |
UPSTASH_REDIS_REST_URL |
Server | Upstash Redis REST URL (room state) |
UPSTASH_REDIS_REST_TOKEN |
Server | Upstash Redis REST token |
Without Pusher, real-time updates (player list, game start, progress, finish) don’t sync across clients. Without Upstash, room state is in-memory and won’t be shared across serverless instances (see Technical notes).
- Create an app at pusher.com (Channels).
- Copy Key, Cluster, App ID, and Secret into
.env.local. - Use the same cluster for
NEXT_PUBLIC_PUSHER_CLUSTERand in the Pusher dashboard — mismatches break sync.
- Add Upstash Redis (or create a DB at upstash.com).
- Add
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENto your env.
If these are missing, the app falls back to in-memory state (fine for single-instance/local dev; broken on Vercel with multiple instances).
- Push to GitHub and import the repo in Vercel.
- In Settings → Environment Variables, add all of the variables above (Pusher + Upstash).
- Deploy. Upstash keeps room state consistent across serverless invocations.
- Stack: Next.js 14 (App Router), TypeScript, Tailwind. Real-time: Pusher (Channels). Room state: Upstash Redis (or in-memory fallback).
- Room state (size, host, members, game started, finished times) is stored in Redis under keys
nono:room:<roomId>. Server is the source of truth; URL has only the room code (see docs/ARCHITECTURE.md). - Host: Creator is remembered in sessionStorage so their first join claims host; server sets host/creator. If host leaves, first remaining member becomes host. Size: Set when creator joins; stored in room state; everyone else gets it from
/state. No size or host in URL. They stay host until they leave; then the first remaining member becomes host. No “(obsolete)” — host is only set by the host link. - Real-time: Join, leave, start, progress, finish broadcast over Pusher. Clients also poll
GET /api/room/[roomId]/stateevery 1.5s. - Puzzles: Multiplayer uses a deterministic puzzle per room (seed from room id + size). Daily uses date + size; no DB, no persistence of puzzles.
- Grid / timer: Grid is stored in
localStorageper room; timer and “finished” state come from the server and Pusher. On reload, the app restores grid and refetches room state.
- Host clicks Create room (picks size on home) → navigate to
/room?code=XXXXXX. They join withhost: trueandsize; server creates room, sets size and host/creator. - Others open the shared link (no
host=1), confirm username, join. They appear in the host’s waiting list; they see the host and “Waiting for host to start…” - Host clicks Start game. Server sets
startedAt, broadcastsgame-start. Everyone’s timer and grid unlock. - Progress is sent to the server and broadcast so everyone can see completion %. First to finish with a correct grid wins; finish times are stored and broadcast.
- Leave: Click Leave or close the tab. Server removes the member, reassigns host if needed, and broadcasts
player-leftand a full room-sync.
| Route | Method | Purpose |
|---|---|---|
/api/room/[roomId]/join |
POST | Add member (body: userId, username, host, size). Creator's size stored. Returns state; broadcasts join + host-changed + room-sync. |
/api/room/[roomId]/leave |
POST | Remove member (body: userId). Broadcasts player-left, host-changed, room-sync. |
/api/room/[roomId]/state |
GET | Return room state (startedAt, size, hostUserId, members, finished). |
/api/room/[roomId]/start |
POST | Set game started (body: userId). Only host. Broadcasts game-start. |
/api/room/[roomId]/progress |
POST | Report completion % (body: userId, username, percent). Broadcasts progress. |
/api/room/[roomId]/finished |
POST | Record finish time (body: userId, username, timeMs). Broadcasts finished. |
- No “delete room” — Rooms are just Redis keys and a Pusher channel. When everyone leaves, the host is cleared; the key can remain until overwritten or TTL (if you add one). Restarting or clearing Redis clears state.
- Join after start — Allowed. The client fetches
/state, getsstartedAt, and unlocks the timer and grid. You’re behind on time. - Join after everyone finished — Allowed. You see the puzzle and can play; you’ll see others’ finish times from room state.
- Tab close = leave — The app sends a beacon to
/leaveonpagehide/beforeunloadso others see you leave.
MIT.