mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
add project documentation and detailed README
- README: features, architecture diagram, setup guide, API reference, configuration table, project structure - docs/system-architecture.md: data flow, storage design, rate limiting - docs/code-standards.md: conventions, project layout, API format - docs/deployment-guide.md: step-by-step CF Workers + Upstash deploy
This commit is contained in:
1 parent
078ccaa70e
commit
fc49de154a
4 files changed
+361
-1
No files matched your search
@@ -1,6 +1,157 @@
|
|||||||
# rplace
|
# 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 <repo-url>
|
||||||
|
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
|
## Credits & References
|
||||||
|
|
||||||
@@ -15,3 +166,7 @@
|
|||||||
- [redis-challenge by alfredosalzillo](https://github.com/alfredosalzillo/redis-challenge)
|
- [redis-challenge by alfredosalzillo](https://github.com/alfredosalzillo/redis-challenge)
|
||||||
- [place by dynastic](https://github.com/dynastic/place)
|
- [place by dynastic](https://github.com/dynastic/place)
|
||||||
- [rplace.live](https://rplace.live/) — color palette reference
|
- [rplace.live](https://rplace.live/) — color palette reference
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user