add implementation plan for rplace pixel canvas

7-phase plan: setup, canvas backend (Redis BITFIELD), rate limiting
(stackable credits), SSE real-time updates, frontend canvas, auth, deploy.
This commit is contained in:
tiennm99 committed 2026-04-16 15:24:55 +07:00
1 parent 62188d26d2
commit 1c6b2edcff
8 files changed
+1114

No files matched your search

@@ -0,0 +1,140 @@
---
phase: 1
title: "Project Setup"
status: pending
effort: 1.5h
priority: P1
---
# Phase 1 — Project Setup
## Context Links
- [Next.js App Router docs](https://nextjs.org/docs/app)
- [Upstash Redis SDK](https://github.com/upstash/upstash-redis)
## Overview
Initialize Next.js project with App Router, install dependencies, configure environment variables, and establish project structure with kebab-case naming.
## Requirements
### Functional
- Next.js App Router project scaffolded
- All dependencies installed
- Environment config for Upstash Redis + NextAuth
- Project directory structure established
### Non-functional
- JavaScript only (no TypeScript)
- Files under 200 lines
- kebab-case file naming
## Dependencies to Install
```
next react react-dom
@upstash/redis # Redis client (REST-based, Vercel-friendly)
next-auth # OAuth (Google, GitHub)
```
Dev dependencies:
```
eslint eslint-config-next
```
## Project Structure
```
src/
├── app/
│ ├── layout.js
│ ├── page.js
│ ├── api/
│ │ ├── canvas/
│ │ │ ├── route.js # GET full canvas
│ │ │ ├── place/
│ │ │ │ └── route.js # POST batch pixel placement
│ │ │ └── stream/
│ │ │ └── route.js # GET SSE stream
│ │ └── auth/
│ │ └── [...nextauth]/
│ │ └── route.js # NextAuth catch-all
│ ├── components/
│ │ ├── canvas-renderer.js # HTML5 Canvas rendering
│ │ ├── color-picker.js # 32-color palette UI
│ │ ├── canvas-controls.js # Zoom/pan controls
│ │ └── user-info.js # Auth status + cooldown display
│ └── globals.css
├── lib/
│ ├── redis-client.js # Upstash Redis singleton
│ ├── canvas-storage.js # BITFIELD read/write helpers
│ ├── rate-limiter.js # Stackable credit system
│ ├── sse-broadcaster.js # Pub/Sub → SSE bridge
│ ├── auth-options.js # NextAuth config
│ └── constants.js # Canvas size, colors, limits
└── .env.example
```
## Implementation Steps
1. Run `npx create-next-app@latest . --js --app --eslint --no-tailwind --no-src-dir --import-alias "@/*"` (adjust if src dir preferred — using `src/` per structure above, so add `--src-dir`)
2. Install production deps: `npm i @upstash/redis next-auth`
3. Create `.env.example` with required vars:
```
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
```
4. Create `src/lib/constants.js` with canvas config:
```js
export const CANVAS_WIDTH = 2048;
export const CANVAS_HEIGHT = 2048;
export const BITS_PER_PIXEL = 5;
export const MAX_COLORS = 32;
export const MAX_BATCH_SIZE = 256;
export const CREDIT_REGEN_RATE = 1; // per second
export const MAX_CREDITS = 256;
export const REDIS_CANVAS_KEY = 'canvas';
export const REDIS_PUBSUB_CHANNEL = 'canvas:updates';
export const COLORS = [
'#6d001a','#be0039','#ff4500','#ffa800','#ffd635','#fff8b8',
'#00a368','#00cc78','#7eed56','#00756f','#009eaa','#00ccc0',
'#2450a4','#3690ea','#51e9f4','#493ac1','#6a5cff','#94b3ff',
'#811e9f','#b44ac0','#e4abff','#de107f','#ff3881','#ff99aa',
'#6d482f','#9c6926','#ffb470','#000000','#515252','#898d90',
'#d4d7d9','#ffffff',
];
```
5. Create `src/lib/redis-client.js` — Upstash Redis singleton
6. Create stub files for remaining `lib/` and `api/` routes
7. Verify `npm run dev` starts without errors
## Todo List
- [ ] Scaffold Next.js project
- [ ] Install dependencies
- [ ] Create `.env.example`
- [ ] Create `constants.js` with palette and config
- [ ] Create `redis-client.js` singleton
- [ ] Create directory structure with stub files
- [ ] Verify dev server starts clean
## Success Criteria
- `npm run dev` runs without errors
- Project structure matches spec
- All stub files exist and export empty functions/components
- `.env.example` documents all required vars
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| create-next-app flags change | Low | Low | Check docs, adjust flags |
| Upstash SDK version mismatch | Low | Med | Pin version in package.json |
## Rollback
Delete generated files, re-scaffold. No data at risk.
@@ -0,0 +1,143 @@
---
phase: 2
title: "Canvas Backend"
status: pending
effort: 3h
priority: P1
blocked_by: [1]
---
# Phase 2 — Canvas Backend
## Context Links
- [Redis BITFIELD command](https://redis.io/docs/latest/commands/bitfield/)
- [Upstash Redis REST API](https://docs.upstash.com/redis/features/restapi)
## Overview
Implement Redis BITFIELD-based canvas storage and two API routes: GET full canvas (binary), POST batch pixel placement.
## Key Insights
- 5 bits per pixel → offset = `(y * CANVAS_WIDTH + x) * 5` for bit-level, or use `u5 #(y * CANVAS_WIDTH + x)` for field-level indexing
- Upstash `@upstash/redis` supports BITFIELD via `redis.bitfield(key, ...commands)`
- Single BITFIELD command can batch multiple SET subcommands → one round-trip for 256 pixels
- Full canvas = `2048 * 2048 * 5 / 8 = 2,621,440 bytes` (~2.5MB raw, ~1.5MB gzip)
## Data Flow
```
GET /api/canvas:
Client → API Route → redis.get("canvas") as Buffer → gzip → Response (binary)
POST /api/canvas/place:
Client → API Route
→ Validate batch (coords, color indices, size ≤ 256)
→ Check rate limit credits
→ redis.bitfield("canvas", ...SET commands)
→ Publish update to Pub/Sub
→ Return {ok: true, remaining_credits}
```
## Architecture
### `src/lib/canvas-storage.js`
```js
// getFullCanvas() → Buffer (raw BITFIELD bytes)
// setPixels(pixels: [{x, y, color}]) → void (batch BITFIELD SET)
// getPixel(x, y) → colorIndex (single BITFIELD GET)
```
### `src/app/api/canvas/route.js`
```js
// GET handler:
// 1. Call getFullCanvas()
// 2. Gzip compress
// 3. Return with Content-Type: application/octet-stream
// + Content-Encoding: gzip
// + Cache-Control: public, max-age=1, stale-while-revalidate=5
```
### `src/app/api/canvas/place/route.js`
```js
// POST handler:
// 1. Parse body: { pixels: [{x, y, color}] }
// 2. Validate: all coords in range, color 0-31, batch ≤ 256
// 3. Identify user (IP or auth session)
// 4. Check/deduct rate limit credits
// 5. Call setPixels(pixels)
// 6. Publish batch to Redis Pub/Sub
// 7. Return { ok: true, credits: remaining }
```
## Related Code Files
### Create
- `src/lib/canvas-storage.js`
- `src/app/api/canvas/route.js`
- `src/app/api/canvas/place/route.js`
### Modify
- `src/lib/redis-client.js` (if stub needs fleshing out)
## Implementation Steps
1. **Implement `canvas-storage.js`**
- `getFullCanvas()`: Use `redis.get(REDIS_CANVAS_KEY)` — Upstash returns base64, decode to Buffer
- `setPixels(pixels)`: Build BITFIELD command array: for each pixel, push `['SET', 'u5', `#${y * W + x}`, color]`, execute as single `redis.bitfield()`
- `getPixel(x, y)`: `redis.bitfield(key, ['GET', 'u5', `#${y * W + x}`])`
- Handle empty canvas (key doesn't exist) → return zeroed buffer
2. **Implement GET `/api/canvas`**
- Import `getFullCanvas`
- Compress with `zlib.gzipSync()`
- Return `new Response(gzipped, { headers })` with proper content headers
- Add `Cache-Control: public, max-age=1, s-maxage=1, stale-while-revalidate=5`
3. **Implement POST `/api/canvas/place`**
- Parse JSON body
- Validate input schema:
- `pixels` is array, length 1-256
- Each pixel: `x` int 0-2047, `y` int 0-2047, `color` int 0-31
- Extract user identity (IP from `request.headers.get('x-forwarded-for')` or fallback)
- Rate limiting call (stub for now, Phase 3)
- Call `setPixels(validatedPixels)`
- Publish to Pub/Sub (stub for now, Phase 4)
- Return JSON response
4. **Initialize canvas** — add a utility or on-demand initialization: if canvas key missing, SET empty buffer of correct size
## Todo List
- [ ] Implement `canvas-storage.js` with getFullCanvas, setPixels, getPixel
- [ ] Handle empty/missing canvas key initialization
- [ ] Implement GET `/api/canvas` with gzip compression
- [ ] Implement POST `/api/canvas/place` with validation
- [ ] Add input validation helpers
- [ ] Test with curl / httpie against local dev
- [ ] Verify BITFIELD offset calculation is correct
## Success Criteria
- GET `/api/canvas` returns gzipped binary of correct size (2.5MB uncompressed)
- POST `/api/canvas/place` with valid payload writes pixels and returns success
- POST with invalid coords/colors returns 400
- POST with >256 pixels returns 400
- Empty canvas initializes correctly on first read
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Upstash BITFIELD API differences from raw Redis | Med | High | Test early; check Upstash docs for BITFIELD support |
| Buffer encoding issues (base64 vs binary) | Med | Med | Log and compare byte lengths; add unit tests |
| BITFIELD command size limit per request | Low | Med | Upstash allows large commands; if limited, chunk into batches of 50 |
## Failure Modes
1. **Redis unavailable** → API returns 503, client retries with backoff
2. **Corrupt canvas data** → Validate BITFIELD size on read; re-init if wrong size
3. **Race condition on concurrent writes** → BITFIELD is atomic per command; batch SET is atomic → safe
## Rollback
Remove API route files and canvas-storage.js. No persistent side effects beyond Redis data (flush key).
@@ -0,0 +1,166 @@
---
phase: 3
title: "Rate Limiting"
status: pending
effort: 2h
priority: P1
blocked_by: [2]
---
# Phase 3 — Rate Limiting (Stackable Credits)
## Context Links
- [Token bucket algorithm](https://en.wikipedia.org/wiki/Token_bucket)
## Overview
Implement stackable credit system: users accumulate 1 credit/second (max 256). Each pixel placement costs 1 credit. Batch placement deducts batch size from credits. All state stored in Redis Hash per user.
## Key Insights
- This is a **token bucket** pattern stored in Redis
- No background process needed — calculate credits on-demand from elapsed time
- Atomic check-and-deduct prevents race conditions via Redis scripting or MULTI/EXEC
- User key: `credits:{userId}` where userId = IP hash (anonymous) or user ID (authenticated)
## Data Flow
```
POST /api/canvas/place:
1. Identify user → userId
2. HGETALL credits:{userId} → {lastUpdate, credits}
3. elapsed = now - lastUpdate
4. newCredits = min(MAX_CREDITS, storedCredits + floor(elapsed))
5. if newCredits < batchSize → reject 429
6. remaining = newCredits - batchSize
7. HSET credits:{userId} lastUpdate=now credits=remaining
8. Proceed with pixel placement
```
## Architecture
### `src/lib/rate-limiter.js`
```js
// checkAndDeductCredits(userId, count) → { allowed: bool, remaining: int, retryAfter?: int }
//
// Algorithm:
// 1. Get stored state from Redis Hash
// 2. Calculate accrued credits from elapsed time
// 3. If sufficient: deduct and update; return allowed=true
// 4. If insufficient: return allowed=false with retryAfter seconds
//
// Edge cases:
// - First-time user (no hash exists) → initialize with MAX_CREDITS
// - Clock skew → use Redis server time (TIME command) if needed
```
### User Identity Extraction
```js
// src/lib/get-user-id.js
// getUserId(request) → string
// 1. Check NextAuth session → user.id
// 2. Fallback: hash of x-forwarded-for or request IP
// 3. Prefix: "auth:" or "anon:" to avoid collision
```
## Related Code Files
### Create
- `src/lib/rate-limiter.js`
- `src/lib/get-user-id.js`
### Modify
- `src/app/api/canvas/place/route.js` — integrate rate limiter
## Implementation Steps
1. **Create `get-user-id.js`**
- Extract IP from `x-forwarded-for` header (first IP if multiple)
- Hash IP with simple hash (e.g., substring of SHA-256) for privacy
- If NextAuth session exists, use `session.user.id` with `auth:` prefix
- Anonymous users get `anon:` prefix
2. **Create `rate-limiter.js`**
- `checkAndDeductCredits(userId, count)`:
- `redis.hgetall(`credits:${userId}`)` → parse lastUpdate, credits
- If null (new user): set credits = MAX_CREDITS, lastUpdate = now
- Calculate: `accrued = min(MAX_CREDITS, stored + floor((now - lastUpdate) / 1000))`
- If `accrued < count`: return `{ allowed: false, remaining: accrued, retryAfter: count - accrued }`
- Else: `redis.hset(key, { lastUpdate: now, credits: accrued - count })`, return `{ allowed: true, remaining: accrued - count }`
- Use seconds (Unix timestamp) for lastUpdate
- **Race condition mitigation**: Use Redis Lua script or pipeline with WATCH for atomicity. Upstash supports `redis.eval()` for Lua scripts.
3. **Integrate into place route**
- Import `getUserId`, `checkAndDeductCredits`
- Before pixel write: check credits
- On rejection: return 429 with `{ error: 'rate_limited', retryAfter, remaining }`
- On success: include `remaining` credits in response
4. **Add credits info endpoint** (optional, could be part of place response)
- GET `/api/credits` → returns current credit count for user (calculated, not stored)
## Lua Script for Atomicity
```lua
-- KEYS[1] = credits:{userId}
-- ARGV[1] = count (pixels to place)
-- ARGV[2] = now (unix seconds)
-- ARGV[3] = max credits
-- ARGV[4] = regen rate (credits per second)
local data = redis.call('HGETALL', KEYS[1])
local lastUpdate = 0
local credits = tonumber(ARGV[3]) -- default max for new users
if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'lastUpdate' then lastUpdate = tonumber(data[i+1]) end
if data[i] == 'credits' then credits = tonumber(data[i+1]) end
end
end
local elapsed = tonumber(ARGV[2]) - lastUpdate
local accrued = math.min(tonumber(ARGV[3]), credits + math.floor(elapsed * tonumber(ARGV[4])))
local count = tonumber(ARGV[1])
if accrued < count then
return {0, accrued, count - accrued} -- denied, remaining, retryAfter
end
local remaining = accrued - count
redis.call('HSET', KEYS[1], 'lastUpdate', ARGV[2], 'credits', remaining)
return {1, remaining, 0} -- allowed, remaining, 0
```
## Todo List
- [ ] Create `get-user-id.js` with IP extraction + hashing
- [ ] Create `rate-limiter.js` with credit calculation logic
- [ ] Implement Lua script for atomic check-and-deduct
- [ ] Integrate into POST `/api/canvas/place`
- [ ] Return 429 with retryAfter on rate limit
- [ ] Test: new user gets full credits
- [ ] Test: credits deplete and regenerate correctly
- [ ] Test: batch larger than available credits rejected
## Success Criteria
- New user can place 256 pixels immediately
- After depleting credits, requests return 429 with correct retryAfter
- Credits regenerate at 1/sec (verified by waiting and retrying)
- Concurrent requests don't grant double credits (Lua script atomicity)
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Upstash Lua script limitations | Med | High | Test EVAL support early; fallback to non-atomic HGETALL+HSET |
| IP spoofing for unlimited credits | Med | Low | Vercel provides real IP; rate limit is soft defense anyway |
| Clock drift between app and Redis | Low | Low | Use Redis TIME or consistent Date.now() |
## Failure Modes
1. **Redis EVAL not supported** → Fallback to HGETALL + HSET (small race window acceptable for MVP)
2. **IP header missing** → Use fallback `127.0.0.1` hash (all anonymous users share limit — degrade gracefully)
3. **Hash key explosion** (many unique IPs) → Set TTL on credit hashes (e.g., 24h expiry via EXPIRE)
## Rollback
Remove rate-limiter.js, get-user-id.js. Remove rate limit check from place route (it was a stub before). Place route works without rate limiting.
@@ -0,0 +1,170 @@
---
phase: 4
title: "Real-time Updates (SSE + Pub/Sub)"
status: pending
effort: 3h
priority: P1
blocked_by: [2]
---
# Phase 4 — Real-time Updates
## Context Links
- [MDN Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
- [Upstash Redis Pub/Sub](https://docs.upstash.com/redis/howto/pubsub)
## Overview
Implement SSE endpoint that streams pixel updates to connected clients. When pixels are placed, the place route publishes deltas to Redis Pub/Sub. The SSE route subscribes and forwards to clients.
## Key Insights
- Vercel serverless functions have max execution time (10s free, 60s pro). SSE on Vercel works via **streaming responses** with Edge Runtime.
- Use **Edge Runtime** for SSE route — no cold start, streaming support
- Upstash Redis Pub/Sub works differently from traditional Redis: use `@upstash/redis` REST-based pub/sub or polling approach
- Alternative: Use Upstash's `@upstash/redis` with `subscribe` (if available) or implement polling-based SSE
- **Practical approach for Vercel**: SSE endpoint polls Redis for updates using a sorted set or list as message queue, rather than true Pub/Sub (which requires persistent connection)
## Data Flow
```
Pixel Placement:
place/route.js → setPixels() → redis.publish("canvas:updates", JSON.stringify(batch))
+ redis.lpush("canvas:queue", JSON.stringify({ts, pixels}))
SSE Stream:
stream/route.js (Edge Runtime):
1. Client connects via EventSource
2. Send initial heartbeat
3. Poll loop: redis.lrange("canvas:queue", ...) for new updates since client's last seen ts
4. Send each batch as SSE event
5. Trim old entries periodically (LTRIM)
Alternative (simpler, recommended for MVP):
stream/route.js:
1. Client connects with ?since={timestamp}
2. Server polls Redis list every 500ms
3. Sends new batches as SSE data events
4. Client reconnects on disconnect (EventSource auto-reconnects)
```
## Architecture
### Message Format (SSE event data)
```json
{
"ts": 1713200000000,
"pixels": [
{"x": 100, "y": 200, "color": 5},
{"x": 101, "y": 200, "color": 5}
]
}
```
### `src/lib/sse-broadcaster.js`
```js
// publishPixelUpdates(pixels) → void
// - Add timestamped batch to Redis sorted set (score = timestamp)
// - ZADD canvas:updates {score: Date.now(), member: JSON.stringify(batch)}
// - ZREMRANGEBYSCORE to trim entries older than 60s (keep queue bounded)
// getUpdatesSince(since) → Array<batch>
// - ZRANGEBYSCORE canvas:updates since +inf
// - Parse and return batches
```
### `src/app/api/canvas/stream/route.js`
```js
// Edge Runtime for streaming
export const runtime = 'edge';
// GET handler:
// 1. Create ReadableStream
// 2. In stream: poll getUpdatesSince() every 500ms
// 3. Send SSE-formatted events for each batch
// 4. Send heartbeat comment every 15s to keep connection alive
// 5. Respect AbortSignal for cleanup
```
## Related Code Files
### Create
- `src/lib/sse-broadcaster.js`
- `src/app/api/canvas/stream/route.js`
### Modify
- `src/app/api/canvas/place/route.js` — add publishPixelUpdates() call after successful placement
## Implementation Steps
1. **Implement `sse-broadcaster.js`**
- `publishPixelUpdates(pixels)`:
- `redis.zadd('canvas:updates', { score: Date.now(), member: JSON.stringify({ ts: Date.now(), pixels }) })`
- `redis.zremrangebyscore('canvas:updates', 0, Date.now() - 60000)` — trim old
- `getUpdatesSince(since)`:
- `redis.zrangebyscore('canvas:updates', since, '+inf')`
- Parse each member, return array
2. **Implement SSE stream route**
- Use Edge Runtime (`export const runtime = 'edge'`)
- Create `ReadableStream` with `start(controller)`:
```js
const encoder = new TextEncoder();
let lastSeen = parseInt(url.searchParams.get('since') || '0');
const interval = setInterval(async () => {
const updates = await getUpdatesSince(lastSeen + 1);
for (const update of updates) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(update)}\n\n`));
lastSeen = Math.max(lastSeen, update.ts);
}
}, 500);
// heartbeat every 15s
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(': heartbeat\n\n'));
}, 15000);
```
- Return `new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' } })`
3. **Integrate publishing into place route**
- After `setPixels()` succeeds, call `publishPixelUpdates(pixels)`
4. **Handle edge cases**
- AbortSignal / client disconnect → clear intervals
- Empty poll → no-op (don't send empty events)
- Reconnection: client sends `Last-Event-ID` or `?since=` param
## Todo List
- [ ] Create `sse-broadcaster.js` with ZADD/ZRANGEBYSCORE helpers
- [ ] Create SSE stream route with Edge Runtime
- [ ] Implement polling loop with heartbeat
- [ ] Integrate publishPixelUpdates into place route
- [ ] Handle client disconnect cleanup
- [ ] Test SSE stream with curl: `curl -N localhost:3000/api/canvas/stream`
- [ ] Test update delivery latency (<1s)
## Success Criteria
- SSE endpoint streams events to connected client
- Placing a pixel triggers SSE event within 1 second
- Heartbeat keeps connection alive
- Client reconnect resumes from last seen timestamp
- Old updates (>60s) cleaned up automatically
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Vercel Edge Runtime limits SSE duration | High | High | Document limit; client auto-reconnects via EventSource; accept 30s reconnect cycle |
| Upstash REST latency for 500ms polling | Med | Med | Acceptable for MVP; upgrade to WebSocket/Ably if needed |
| Sorted set grows unbounded | Med | Med | ZREMRANGEBYSCORE trims old; add ZCARD check as safety |
## Failure Modes
1. **SSE connection drops** → EventSource auto-reconnects; `?since=` param ensures no missed updates
2. **Redis sorted set too large** → Trim runs on every publish; worst case: add ZCARD limit check
3. **High-frequency updates overwhelm client** → Batch multiple updates per SSE event; client-side throttle rendering
4. **Edge Runtime timeout** → Client reconnects; stateless polling means no server-side state lost
## Rollback
Remove stream route and sse-broadcaster.js. Remove publish call from place route. Frontend falls back to periodic full canvas refresh (degrade to polling).
@@ -0,0 +1,190 @@
---
phase: 5
title: "Frontend Canvas"
status: pending
effort: 5h
priority: P1
blocked_by: [2, 3, 4]
---
# Phase 5 — Frontend Canvas
## Overview
Build interactive HTML5 Canvas UI: load full canvas from API, render 2048x2048 grid, support zoom/pan, color picker with 32-color palette, pixel placement on click, real-time SSE updates.
## Key Insights
- HTML5 Canvas with `ImageData` is the performant way to render millions of pixels
- Use `OffscreenCanvas` or direct `putImageData` for bulk updates
- Zoom/pan via CSS `transform` on a wrapper or by scaling canvas draw calls
- Decode 5-bit packed binary into RGBA ImageData on client side
- Use `requestAnimationFrame` for smooth rendering
## Data Flow
```
Initial Load:
1. fetch('/api/canvas') → gzipped binary (auto-decompressed by browser)
2. Decode 5-bit packed buffer → Uint8Array of color indices
3. Map color indices → RGBA via palette lookup
4. Create ImageData, putImageData to canvas
Live Updates (SSE):
1. EventSource('/api/canvas/stream?since=0')
2. On message: parse pixel batch
3. For each pixel: update ImageData at (x,y), queue re-render
4. Batch re-renders via requestAnimationFrame
Pixel Placement:
1. User clicks canvas → translate screen coords to canvas coords (account for zoom/pan)
2. Validate selected color
3. POST /api/canvas/place with [{x, y, color}]
4. Optimistic update: paint pixel immediately
5. On error: revert pixel
```
## Architecture
### Components
```
src/app/page.js
└── Client-side canvas app
├── CanvasRenderer — HTML5 Canvas element, ImageData management
├── ColorPicker — 32-color palette grid
├── CanvasControls — Zoom buttons, coordinates display
└── UserInfo — Credit counter, auth status
```
### `src/app/components/canvas-renderer.js`
Core rendering component. Manages:
- Canvas element ref
- ImageData buffer (2048x2048 RGBA)
- Zoom level and pan offset
- Mouse/touch event handlers for pan, zoom, click-to-place
- SSE connection lifecycle
### `src/app/components/color-picker.js`
- Grid of 32 color swatches
- Selected color highlighted
- Click to select
### `src/app/components/canvas-controls.js`
- Zoom in/out buttons
- Reset view button
- Coordinates display (current hover position)
- Scroll wheel zoom
### `src/app/components/user-info.js`
- Display remaining credits (poll or derive from last placement response)
- Login/logout button (Phase 6)
- Cooldown timer visualization
## Related Code Files
### Create
- `src/app/components/canvas-renderer.js`
- `src/app/components/color-picker.js`
- `src/app/components/canvas-controls.js`
- `src/app/components/user-info.js`
- `src/app/hooks/use-canvas-state.js` — shared state hook
- `src/app/hooks/use-sse-updates.js` — SSE connection hook
### Modify
- `src/app/page.js` — compose components
- `src/app/globals.css` — canvas styles
- `src/app/layout.js` — metadata, viewport
## Implementation Steps
1. **Binary decoding utility** (`src/lib/canvas-decoder.js` — client-side)
- `decodeCanvas(buffer)` → Uint8Array of color indices
- Read 5-bit values from packed binary: bit manipulation with DataView
- `indicesToImageData(indices, palette)` → ImageData (RGBA)
2. **Canvas renderer component**
- `useRef` for canvas element
- On mount: fetch `/api/canvas`, decode, render with `putImageData`
- Track zoom (1x-40x) and pan offset in state
- Apply transform: scale canvas context or use CSS transform
- Mouse events:
- `mousedown` + `mousemove` → pan (when not placing)
- `click` → place pixel (when color selected)
- `wheel` → zoom in/out centered on cursor
- Touch events for mobile: pinch-to-zoom, drag-to-pan
3. **SSE updates hook**
- `useSSEUpdates(onBatch)` custom hook
- Create `EventSource('/api/canvas/stream?since={ts}')`
- On message: parse JSON, call `onBatch(pixels)`
- Handle reconnection (EventSource does this natively)
- Track last event timestamp for reconnect `since` param
4. **Color picker component**
- Display 32 colors in 4x8 or 8x4 grid
- CSS grid layout
- Selected state with border/highlight
- Keyboard shortcuts (number keys for quick select)
5. **Canvas controls**
- Zoom level display (e.g., "4x")
- +/- buttons
- "Reset" to fit canvas in viewport
- Coordinate display updating on mousemove
6. **User info component**
- Display credit count from last placement response
- Animate credit regeneration client-side (increment every second)
- Show "Ready" / "Cooldown: Xs" status
7. **Main page composition**
- Import all components
- Shared state via `use-canvas-state.js` hook or props
- Layout: canvas fills viewport, color picker bottom, controls top-right, user-info top-left
## Todo List
- [ ] Create binary decoder (5-bit unpacking to RGBA)
- [ ] Create canvas-renderer with zoom/pan
- [ ] Create SSE updates hook
- [ ] Create color-picker component
- [ ] Create canvas-controls component
- [ ] Create user-info component
- [ ] Compose in page.js
- [ ] Add mobile touch support (pinch-zoom, drag-pan)
- [ ] Style with globals.css
- [ ] Test: full canvas loads and renders
- [ ] Test: click places pixel with optimistic update
- [ ] Test: SSE updates render in real-time
- [ ] Test: zoom/pan works smoothly
## Success Criteria
- Canvas loads and renders 2048x2048 pixels from API
- Zoom in/out works (1x to 40x), smooth with mouse wheel
- Pan by click-drag works
- Color picker shows 32 colors, selection is visible
- Clicking canvas with selected color sends POST and updates pixel
- SSE updates from other users appear within 1 second
- Mobile: pinch-zoom and drag-pan functional
- Credits display updates after placement
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| 2.5MB canvas download slow on mobile | Med | Med | Gzip reduces to ~1.5MB; show loading indicator |
| Canvas rendering performance at 40x zoom | Med | Med | Only render visible viewport tiles; use `drawImage` with source rect |
| 5-bit decoding bugs (off-by-one) | Med | High | Unit test decoder with known byte sequences |
| Touch event conflicts (pan vs place) | Med | Med | Long-press to place on mobile; short drag = pan |
## Failure Modes
1. **Canvas download fails** → Show error + retry button; cache last successful state in localStorage
2. **SSE disconnects** → EventSource auto-reconnects; on reconnect, fetch full canvas to resync
3. **Optimistic update wrong** → On POST error, revert pixel to previous color from ImageData backup
4. **Memory pressure (2048x2048 ImageData = 16MB RGBA)** → Acceptable for modern browsers; warn if <2GB RAM detected
## Rollback
Revert page.js to stub. Remove component files. Backend remains functional (testable via curl).
@@ -0,0 +1,148 @@
---
phase: 6
title: "Authentication"
status: pending
effort: 3h
priority: P2
blocked_by: [5]
---
# Phase 6 — Authentication
## Overview
Add optional Google/GitHub OAuth via NextAuth.js. Anonymous users continue working via IP-based identity. Authenticated users get stable identity (no credit reset on IP change).
## Key Insights
- NextAuth.js App Router integration uses route handler at `app/api/auth/[...nextauth]/route.js`
- JWT strategy (no database session) — stateless, Vercel-friendly
- Anonymous users work immediately — auth is opt-in enhancement
- Transition: when user logs in, optionally migrate credits from anon identity to auth identity
## Data Flow
```
Anonymous:
Request → getUserId() → hash(IP) → "anon:abc123"
Authenticated:
Request → NextAuth session → session.user.id → "auth:google-12345"
Login Flow:
1. User clicks "Sign in" → NextAuth OAuth flow
2. Redirect to Google/GitHub → consent → callback
3. NextAuth creates JWT session cookie
4. Subsequent requests include session → getUserId returns auth ID
5. Optional: migrate credits from anon key to auth key
```
## Architecture
### `src/lib/auth-options.js`
```js
// NextAuth configuration
// Providers: Google, GitHub
// Strategy: JWT (no database)
// Callbacks: include user ID in session
// Pages: custom sign-in page (optional, default works for MVP)
```
### `src/app/api/auth/[...nextauth]/route.js`
```js
// Standard NextAuth route handler
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth-options';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
```
### Modify `src/lib/get-user-id.js`
```js
// Updated flow:
// 1. getServerSession(authOptions)
// 2. If session: return `auth:${session.user.id}`
// 3. Else: return `anon:${hash(ip)}`
```
## Related Code Files
### Create
- `src/lib/auth-options.js`
- `src/app/api/auth/[...nextauth]/route.js`
### Modify
- `src/lib/get-user-id.js` — add session check
- `src/app/components/user-info.js` — add login/logout buttons
- `src/app/layout.js` — wrap with SessionProvider (client-side)
## Implementation Steps
1. **Create `auth-options.js`**
- Configure Google and GitHub providers from env vars
- JWT strategy, no database adapter
- Add `session` callback to expose provider account ID
- Add `jwt` callback to persist user ID in token
2. **Create NextAuth route handler**
- Standard catch-all route at `api/auth/[...nextauth]`
3. **Update `get-user-id.js`**
- Import `getServerSession` from `next-auth`
- Check session first, fallback to IP hash
- Handle edge case: session exists but user.id missing
4. **Update `user-info.js` component**
- Import `useSession` from `next-auth/react`
- Show "Sign in" button when not authenticated
- Show user avatar/name + "Sign out" when authenticated
- Use `signIn()` and `signOut()` from next-auth/react
5. **Add SessionProvider wrapper**
- Create `src/app/providers.js` — client component wrapping `SessionProvider`
- Import in `layout.js`
6. **Credit migration (optional, nice-to-have)**
- On first authenticated request: check if anon key has credits
- If so: transfer credits from anon to auth key, delete anon key
- Skip if complexity not worth it for MVP
## Todo List
- [ ] Create `auth-options.js` with Google + GitHub providers
- [ ] Create NextAuth route handler
- [ ] Create `providers.js` with SessionProvider
- [ ] Update layout.js with providers wrapper
- [ ] Update `get-user-id.js` with session check
- [ ] Update `user-info.js` with login/logout UI
- [ ] Test Google OAuth flow end-to-end
- [ ] Test GitHub OAuth flow end-to-end
- [ ] Test anonymous fallback still works
- [ ] Test credits persist across sessions for authenticated users
## Success Criteria
- Anonymous users can place pixels without signing in
- Google OAuth login/logout works
- GitHub OAuth login/logout works
- Authenticated user has stable identity (credits persist)
- Session persists across page refreshes (JWT cookie)
- No regression in anonymous flow
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| OAuth provider setup complexity (client ID/secret) | Low | Med | Document setup steps in README |
| NextAuth version breaking changes | Low | Med | Pin version; test before upgrade |
| SessionProvider SSR hydration issues | Med | Med | Wrap in client component boundary |
## Security Considerations
- NEXTAUTH_SECRET must be strong random string (32+ chars)
- OAuth callback URLs must be registered with providers
- JWT tokens are httpOnly cookies — no XSS exposure
- Rate limit keys use provider user ID — no spoofing possible
- CSRF protection built into NextAuth
## Rollback
Remove auth-options.js, NextAuth route, providers.js. Revert get-user-id.js to IP-only. Revert user-info.js to remove login buttons. App works fully anonymous.
@@ -0,0 +1,95 @@
---
phase: 7
title: "Polish & Deploy"
status: pending
effort: 2.5h
priority: P2
blocked_by: [6]
---
# Phase 7 — Polish & Deploy
## Overview
Production hardening: Vercel deployment config, Upstash Redis provisioning, environment setup, performance optimization, error handling polish, and documentation.
## Implementation Steps
1. **Vercel Configuration**
- Create `vercel.json` if needed (usually not required for Next.js)
- Set Edge Runtime for SSE route
- Configure function regions (closest to Upstash Redis region)
- Set environment variables in Vercel dashboard
2. **Upstash Redis Setup**
- Create Upstash database (choose region matching Vercel)
- Enable eviction policy: noeviction (canvas data must persist)
- Set maxmemory appropriately (canvas = ~3MB + credit hashes + update queue)
- Copy REST URL and token to Vercel env vars
3. **Performance Optimization**
- Canvas API: verify gzip compression working (check Content-Encoding header)
- Add `Cache-Control` headers: canvas GET (short TTL), stream (no-cache)
- Consider canvas snapshot caching at edge (Vercel Edge Config or KV)
- Lazy load non-critical UI components
- Optimize binary decoder: use DataView for efficient 5-bit reads
4. **Error Handling Polish**
- Global error boundary for React components
- API routes: consistent error response format `{ error: string, code?: string }`
- SSE: graceful reconnection with exponential backoff (EventSource default)
- Canvas load failure: retry with backoff, show user-friendly error
- Redis connection failure: 503 response with retry header
5. **Canvas Initialization**
- Admin/setup script to initialize empty canvas in Redis
- Or: auto-initialize on first GET request if key missing
- Add `GET /api/canvas/info` endpoint: returns canvas dimensions, total pixels placed, etc.
6. **Meta & SEO**
- Open Graph tags
- Favicon
- Page title and description
- Mobile viewport meta
7. **Documentation**
- Update README.md with:
- Project description
- Setup instructions (local dev + Vercel deploy)
- Environment variables reference
- Architecture overview
- Update docs/ directory per documentation management rules
## Todo List
- [ ] Configure Vercel deployment settings
- [ ] Provision Upstash Redis database
- [ ] Set all environment variables
- [ ] Add error boundaries and consistent error responses
- [ ] Add canvas initialization logic
- [ ] Optimize gzip and caching headers
- [ ] Add meta tags and favicon
- [ ] Update README with setup instructions
- [ ] Update docs/ (architecture, code standards)
- [ ] Deploy to Vercel and smoke test
- [ ] Test full flow: load canvas → place pixels → see real-time updates
- [ ] Test OAuth flows in production
## Success Criteria
- App deployed to Vercel and accessible via URL
- Canvas loads within 3 seconds on broadband
- Pixel placement works end-to-end in production
- SSE updates work in production
- OAuth works with production callback URLs
- No console errors in production build
- Redis memory usage is predictable and bounded
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Vercel Edge Runtime SSE timeout | High | Med | Document 30s limit; client reconnects automatically |
| Upstash free tier limits (10K commands/day) | Med | High | Monitor usage; upgrade plan if needed; batch reads |
| Cold start latency | Med | Low | Edge Runtime eliminates cold starts for SSE; serverless routes accept ~200ms |
## Rollback
Vercel supports instant rollback to previous deployment. Redis data persists independently. Rollback = redeploy previous commit.
@@ -0,0 +1,62 @@
---
title: "rplace — Reddit r/place Clone"
description: "Full implementation plan for a 2048x2048 collaborative pixel canvas with real-time updates"
status: pending
priority: P1
effort: 20h
branch: main
tags: [nextjs, redis, sse, canvas, real-time]
created: 2026-04-16
---
# rplace Implementation Plan
## Architecture
```
Browser (HTML5 Canvas + SSE client)
| GET /api/canvas → full canvas binary (gzip)
| POST /api/canvas/place → batch pixel placement (up to 256px)
| GET /api/canvas/stream → SSE delta updates
| GET/POST /api/auth/* → NextAuth.js routes
v
Next.js App Router (Vercel serverless)
|
v
Upstash Redis
├── BITFIELD "canvas" (5-bit per pixel, 2048x2048 = 2.62MB)
├── HASH "credits:{id}" → {lastUpdate, credits}
└── Pub/Sub channel "canvas:updates"
```
## Phases
| # | Phase | Status | Effort | File |
|---|-------|--------|--------|------|
| 1 | Project Setup | Pending | 1.5h | [phase-01](./phase-01-project-setup.md) |
| 2 | Canvas Backend | Pending | 3h | [phase-02](./phase-02-canvas-backend.md) |
| 3 | Rate Limiting | Pending | 2h | [phase-03](./phase-03-rate-limiting.md) |
| 4 | Real-time Updates | Pending | 3h | [phase-04](./phase-04-real-time-updates.md) |
| 5 | Frontend Canvas | Pending | 5h | [phase-05](./phase-05-frontend-canvas.md) |
| 6 | Authentication | Pending | 3h | [phase-06](./phase-06-authentication.md) |
| 7 | Polish & Deploy | Pending | 2.5h | [phase-07](./phase-07-polish-and-deploy.md) |
## Dependencies
```
Phase 1 (Setup)
└─> Phase 2 (Canvas Backend)
├─> Phase 3 (Rate Limiting)
└─> Phase 4 (Real-time)
└─> Phase 5 (Frontend) ← also depends on Phase 2, 3
└─> Phase 6 (Auth)
└─> Phase 7 (Polish & Deploy)
```
## Key Decisions
- **JavaScript only** — no TypeScript (user preference)
- **SSE over WebSocket** — free on Vercel serverless, simpler
- **Redis BITFIELD** — 5-bit color encoding, single key for entire canvas
- **Stackable credits** — not fixed cooldown timer; allows batch placement
- **Anonymous-first** — IP-based identity, OAuth optional