diff --git a/README.md b/README.md index 5fdfec4..1454bad 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,157 @@ # rplace -[Reddit's r/place](https://www.reddit.com/r/place/) clone — a collaborative pixel art canvas. +A collaborative pixel art canvas inspired by [Reddit's r/place](https://www.reddit.com/r/place/). Place pixels, create art together in real-time. + +## Features + +- **2048x2048 canvas** with 32-color palette (from [rplace.live](https://rplace.live/)) +- **Real-time updates** via WebSocket (Cloudflare Durable Objects) +- **Batch pixel placement** up to 32 pixels per request +- **Stackable credit system** — earn 1 pixel/sec, stack up to 256, spend in batches +- **Zoom/pan** with mouse wheel + drag (desktop) and pinch-zoom + drag (mobile) +- **Long-press to place** on touch devices +- **Credit bar** with visual regeneration feedback + +## Tech Stack + +| Layer | Technology | +|---|---| +| Frontend | [Svelte 5](https://svelte.dev/) (runes) + HTML5 Canvas | +| Backend | [Hono](https://hono.dev/) on Cloudflare Workers | +| Real-time | WebSocket via Cloudflare Durable Objects | +| Storage | [Upstash Redis](https://upstash.com/) (BITFIELD for canvas, Lua for rate limiting) | +| Build | [Vite](https://vite.dev/) | + +## Architecture + +``` +Browser (Svelte SPA + WebSocket) + | GET /api/canvas → full canvas binary (2.5MB raw) + | POST /api/place → batch pixel placement + | WS /api/ws → Durable Object broadcast room + v +Cloudflare Worker (Hono) + ├── Canvas API (read/write pixels via Redis BITFIELD) + ├── Rate Limiter (Lua script, atomic token bucket) + └── Durable Object (WebSocket broadcast to all clients) + ↕ +Upstash Redis + ├── BITFIELD "canvas" (5-bit per pixel, 2048x2048 = 2.62MB) + └── HASH "credits:{userId}" (lastUpdate + credits) +``` + +## Getting Started + +### Prerequisites + +- [Node.js](https://nodejs.org/) 18+ +- [Cloudflare account](https://dash.cloudflare.com/) (free tier works) +- [Upstash Redis](https://console.upstash.com/) database (free tier works) + +### Setup + +```bash +# Clone and install +git clone +cd rplace +npm install + +# Configure environment +cp .env.example .env +# Edit .env with your Upstash Redis credentials + +# For wrangler (Cloudflare Workers CLI) +npx wrangler secret put UPSTASH_REDIS_REST_URL +npx wrangler secret put UPSTASH_REDIS_REST_TOKEN +``` + +### Development + +```bash +# Run worker locally (serves both API and frontend) +npm run dev + +# Or run frontend and worker separately +npm run dev:client # Vite dev server on :5173 (proxies /api to :8787) +npm run dev # Wrangler dev server on :8787 +``` + +### Deploy + +```bash +npm run deploy # Builds frontend + deploys worker to Cloudflare +``` + +## Project Structure + +``` +src/ +├── worker.js # Hono API entry point +├── durable-objects/ +│ └── canvas-room.js # WebSocket broadcast room +├── lib/ +│ ├── constants.js # Config, palette, limits (shared) +│ ├── redis-client.js # Upstash Redis factory +│ ├── canvas-storage.js # BITFIELD read/write +│ ├── canvas-decoder.js # 5-bit → RGBA (client-side) +│ ├── rate-limiter.js # Lua token bucket +│ └── get-user-id.js # IP-based identity +├── client/ +│ ├── main.js # Svelte mount +│ ├── App.svelte # Root + WebSocket + credit timer +│ ├── app.css # Global styles +│ └── components/ +│ ├── CanvasRenderer.svelte # Canvas + zoom/pan + touch +│ ├── ColorPicker.svelte # 32-color palette grid +│ ├── CanvasControls.svelte # Zoom buttons + coordinates +│ └── UserInfo.svelte # Credit counter + bar +└── index.html # Vite entry +``` + +## API + +### `GET /api/canvas` + +Returns the full canvas as raw binary (5-bit packed, ~2.5MB). + +### `POST /api/place` + +Place pixels on the canvas. + +```json +{ + "pixels": [ + { "x": 100, "y": 200, "color": 27 } + ] +} +``` + +**Response:** `{ "ok": true, "credits": 255 }` + +**Errors:** +- `400` — invalid pixel data or batch > 32 +- `429` — rate limited (includes `retryAfter` seconds) + +### `WS /api/ws` + +WebSocket for real-time pixel updates. Messages are JSON: + +```json +{ "type": "pixels", "pixels": [{ "x": 100, "y": 200, "color": 27 }] } +``` + +## Configuration + +Key constants in `src/lib/constants.js`: + +| Constant | Default | Description | +|---|---|---| +| `CANVAS_WIDTH` | 2048 | Canvas width in pixels | +| `CANVAS_HEIGHT` | 2048 | Canvas height in pixels | +| `MAX_COLORS` | 32 | Number of colors in palette | +| `MAX_BATCH_SIZE` | 32 | Max pixels per placement request | +| `MAX_CREDITS` | 256 | Max stackable credits | +| `CREDIT_REGEN_RATE` | 1 | Credits earned per second | ## Credits & References @@ -15,3 +166,7 @@ - [redis-challenge by alfredosalzillo](https://github.com/alfredosalzillo/redis-challenge) - [place by dynastic](https://github.com/dynastic/place) - [rplace.live](https://rplace.live/) — color palette reference + +## License + +MIT diff --git a/docs/code-standards.md b/docs/code-standards.md new file mode 100644 index 0000000..29064d9 --- /dev/null +++ b/docs/code-standards.md @@ -0,0 +1,50 @@ +# Code Standards + +## Language & Style + +- **JavaScript** (ES modules, no TypeScript) +- **Svelte 5** with runes ($state, $props, $derived, $effect) +- **kebab-case** for JS files, **PascalCase** for Svelte components +- Files under 200 lines +- No semicolons omission — use semicolons consistently + +## Project Layout + +``` +src/ +├── worker.js # Worker entry (Hono routes) +├── durable-objects/ # Cloudflare Durable Objects +├── lib/ # Shared libraries (worker + client) +├── client/ # Svelte SPA +│ ├── components/ # Svelte components (PascalCase) +│ ├── main.js # Mount entry +│ └── App.svelte # Root component +└── index.html # Vite entry +``` + +## Conventions + +### Worker (src/worker.js, src/lib/*) + +- Functions receive `env` parameter for Cloudflare bindings (Redis credentials, DO bindings) +- No global state — Workers are stateless between requests +- Use `@upstash/redis/cloudflare` (REST-based, not TCP) +- Bitfield operations use builder pattern: `redis.bitfield(key).set().exec()` + +### Client (src/client/*) + +- Svelte 5 runes only (`$state`, `$props`, `$derived`, `$effect`) +- No stores — pass state via props and callbacks +- Canvas rendering is imperative (OffscreenCanvas + putImageData) +- Touch and mouse handlers coexist on the same canvas element + +### Shared (src/lib/constants.js, src/lib/canvas-decoder.js) + +- Imported by both worker and client +- Vite tree-shakes worker-only code from client bundle +- Constants are the single source of truth for canvas dimensions, colors, limits + +## API Response Format + +Success: `{ ok: true, credits: N }` +Error: `{ error: "error_code", ...details }` with appropriate HTTP status diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md new file mode 100644 index 0000000..e79b100 --- /dev/null +++ b/docs/deployment-guide.md @@ -0,0 +1,74 @@ +# Deployment Guide + +## Prerequisites + +- Node.js 18+ +- Cloudflare account (free tier) +- Upstash Redis database (free tier) +- `wrangler` CLI (installed as dev dependency) + +## Step 1: Create Upstash Redis Database + +1. Go to [console.upstash.com](https://console.upstash.com/) +2. Create a new Redis database +3. Choose a region close to your users +4. Copy the **REST URL** and **REST Token** + +## Step 2: Configure Secrets + +```bash +# Set secrets in Cloudflare (not in code) +npx wrangler secret put UPSTASH_REDIS_REST_URL +npx wrangler secret put UPSTASH_REDIS_REST_TOKEN +``` + +For local development, create `.dev.vars`: + +``` +UPSTASH_REDIS_REST_URL=https://your-url.upstash.io +UPSTASH_REDIS_REST_TOKEN=your-token +``` + +## Step 3: Deploy + +```bash +npm run deploy +``` + +This runs `vite build` (compiles Svelte → dist/) then `wrangler deploy` (uploads Worker + static assets). + +## Step 4: Verify + +1. Visit your Worker URL (e.g., `https://rplace.your-subdomain.workers.dev`) +2. Canvas should load (empty/dark on first visit) +3. Select a color, click to place a pixel +4. Open a second browser tab — pixel should appear via WebSocket + +## Custom Domain + +```bash +# Add a custom domain via Cloudflare dashboard or: +npx wrangler domains add rplace.yourdomain.com +``` + +## Monitoring + +- **Cloudflare dashboard**: Worker analytics, request logs, DO metrics +- **Upstash console**: Redis command count, memory usage, latency + +## Cost Estimates (Free Tier) + +| Resource | Free Limit | rplace Usage | +|---|---|---| +| CF Workers | 100K requests/day | Canvas reads + pixel placements | +| CF Durable Objects | Free with Workers | WebSocket connections | +| Upstash Redis | 10K commands/day | BITFIELD reads/writes + rate limiting | + +For hobby traffic (< few hundred users/day), free tiers are sufficient. Upstash pay-as-you-go ($0.2/100K commands) is the first thing to hit limits. + +## Troubleshooting + +- **Canvas loads empty**: Check Upstash credentials in secrets +- **Pixels don't persist**: Verify BITFIELD support — test with `redis-cli BITFIELD canvas SET u5 #0 1` +- **WebSocket not connecting**: Ensure Durable Object migration ran (check `wrangler.json` migrations) +- **Rate limiting not working**: Verify `redis.eval()` works on your Upstash tier (Lua scripting) diff --git a/docs/system-architecture.md b/docs/system-architecture.md new file mode 100644 index 0000000..6ccb822 --- /dev/null +++ b/docs/system-architecture.md @@ -0,0 +1,81 @@ +# System Architecture + +## Overview + +rplace is a collaborative pixel canvas deployed as a single Cloudflare Worker. The frontend (Svelte SPA) is served as static assets, the API (Hono) handles pixel operations, and a Durable Object manages WebSocket broadcasting. + +## Data Flow + +### Pixel Placement + +``` +1. User clicks canvas → optimistic render + credit deduction +2. POST /api/place { pixels: [{x, y, color}] } +3. Worker validates input (bounds, types, batch size ≤ 32) +4. Worker checks credits via Lua script (atomic check-and-deduct) +5. Worker writes pixels via Redis BITFIELD (atomic batch) +6. Worker sends pixels to Durable Object /broadcast +7. Durable Object fans out to all WebSocket clients +8. Response: { ok: true, credits: N } +``` + +### Canvas Loading + +``` +1. Client fetches GET /api/canvas +2. Worker reads Redis key via GETRANGE → raw binary +3. Client receives ~2.5MB (5-bit packed pixels) +4. Client decodes 5-bit values → color indices → RGBA ImageData +5. Renders onto HTML5 Canvas with OffscreenCanvas +``` + +### Real-time Updates + +``` +1. Client connects WS /api/ws +2. Worker upgrades to Durable Object WebSocket +3. DO stores connection in memory Set +4. On pixel placement → DO broadcasts JSON to all connections +5. Client updates local ImageData + re-renders +6. On disconnect → auto-reconnect with exponential backoff (1s→30s) +``` + +## Storage + +### Redis BITFIELD (Canvas) + +- Key: `canvas` +- Encoding: 5 bits per pixel (u5), 32 colors +- Size: `2048 * 2048 * 5 / 8 = 2,621,440 bytes` (~2.5MB) +- Offset: `y * CANVAS_WIDTH + x` +- Atomic batch writes: single BITFIELD command with chained .set() calls + +### Redis HASH (Credits) + +- Key pattern: `credits:{userId}` +- Fields: `lu` (last update, unix seconds), `cr` (current credits) +- TTL: 24 hours (auto-expire inactive users) +- Accessed via Lua script for atomic check-and-deduct + +## Rate Limiting + +Token bucket algorithm implemented as a Lua script: + +``` +On placement request: +1. Read stored credits + lastUpdate from HASH +2. Calculate accrued = stored + floor(elapsed_seconds * regen_rate) +3. Cap at MAX_CREDITS (256) +4. If accrued < requested → reject (429) +5. Else → deduct, update HASH, return remaining +``` + +New users start with full credits (256). Anonymous identity via CF-Connecting-IP hash. + +## Security + +- **Rate limiting**: Atomic Lua script prevents race conditions +- **Identity**: CF-Connecting-IP (set by Cloudflare, unspoofable) +- **Input validation**: Bounds checking, type checking, integer validation on all pixel data +- **Batch cap**: Max 32 pixels per request to limit burst damage +- **DO isolation**: /broadcast route only reachable via DO stub, not externally