A collaborative pixel art canvas inspired by Reddit's r/place. Place pixels, create art together in real-time.
| Layer | Technology |
|---|---|
| Frontend | Svelte 5 (runes) + HTML5 Canvas |
| Backend | Hono on Cloudflare Workers |
| Real-time | WebSocket via Cloudflare Durable Objects |
| Storage | Upstash Redis (BITFIELD for canvas, SET NX EX for rate limiting) |
| Build | Vite |
Browser (Svelte SPA + WebSocket)
| GET /api/canvas → full canvas binary (16MB raw, ~5MB gzip)
| 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 (SET NX EX — atomic per-user cooldown)
└── Durable Object (WebSocket broadcast to all clients)
↕
Upstash Redis
├── STRING "canvas:v2" (1 byte per pixel, 4096×4096 = 16 MB)
└── STRING "cooldown:{userId}" (1s TTL, blocks repeat requests)
# 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
# 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
npm run deploy # Builds frontend + deploys worker to Cloudflare
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 # Raw bytes → RGBA (client-side; u8 = identity indices)
│ ├── rate-limiter.js # SET NX EX cooldown
│ ├── image-uploader.js # Browser-side batched uploader
│ └── get-user-id.js # IP-based identity
├── client/
│ ├── main.js # Svelte mount
│ ├── App.svelte # Root + WebSocket
│ ├── app.css # Global styles
│ └── components/
│ ├── CanvasRenderer.svelte # Canvas + zoom/pan + touch
│ ├── ColorPicker.svelte # Favorites strip + 256-color grid + custom picker
│ ├── CanvasControls.svelte # Zoom buttons + coordinates
│ ├── DrawToolbar.svelte # Paint / submit / undo / redo
│ └── ImageImporter.svelte # Image-to-canvas uploader
└── index.html # Vite entry
GET /api/canvasReturns the full canvas as raw binary (1 byte per pixel, 16 MB — Cloudflare gzips it on the edge).
POST /api/placePlace pixels on the canvas.
{
"pixels": [
{ "x": 100, "y": 200, "color": 27 }
]
}
Response: { "ok": true }
Errors:
400 — invalid pixel data or batch > 2048413 — request body too large429 — rate limited (includes retryAfter seconds)WS /api/wsWebSocket for real-time pixel updates. Messages are JSON:
{ "type": "pixels", "pixels": [{ "x": 100, "y": 200, "color": 27 }] }
Key constants in src/lib/constants.js:
| Constant | Default | Description |
|---|---|---|
CANVAS_WIDTH |
4096 | Canvas width in pixels |
CANVAS_HEIGHT |
4096 | Canvas height in pixels |
MAX_COLORS |
256 | Number of palette entries |
BITS_PER_PIXEL |
8 | Byte-aligned (storage = W × H bytes) |
MAX_BATCH_SIZE |
2048 | Max pixels per placement request |
REQUEST_COOLDOWN_SEC |
1 | Minimum seconds between requests per user |
MIT