mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
Canvas: - CANVAS_W/H = 4096, total 16,777,216 pixels - BITS_PER_PIXEL = 8 (byte-aligned) — raw Redis bytes are palette indices - Canvas-decoder becomes an identity wrap/copy - Storage BITFIELD uses u8; offset = y*W + x - Redis key versioned to rplace:canvas:v2 so old 32-color/2048^2 data is orphaned (operators can DEL the old key to reclaim memory) Palette: - 256 entries, generated deterministically: - 0..15 = 16-step grayscale ramp (pure black -> pure white) - 16..255 = 240 HSL wheel (4 lightness rings x 60 hues @ 82% saturation) - nearestPaletteIndex(r,g,b) helper for custom-color snapping UI: - ColorPicker: 16-swatch favorites strip (grays + 8 accents) + current-color swatch + expand toggle for the full 16x16 grid + "Custom..." button that opens the native <input type="color"> and snaps to nearest palette entry - Default selected color bumped to index 0 (black) Tests + docs: - canvas-decoder tests rewritten for identity u8 decode - canvas-storage tests updated for u8 offsets - image-to-palette tests anchored to PALETTE_BLACK=0 / PALETTE_WHITE=15 and COLORS_RGBA[i] probes (no more hardcoded old 32-color indices) - integration test uses u8 BITFIELD and canvas-aware bounds - README, system-architecture, deployment-guide updated (storage math, migration note for orphaned old key)
82 lines
2.9 KiB
Markdown
82 lines
2.9 KiB
Markdown
# 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 draws on canvas → optimistic render into a pending buffer
|
||
2. User hits Submit → POST /api/place { pixels: [{x, y, color}] }
|
||
3. Worker validates input (bounds, types, batch size ≤ 2048)
|
||
4. Worker checks cooldown via SET NX EX (atomic per-user lock)
|
||
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 }
|
||
```
|
||
|
||
### Canvas Loading
|
||
|
||
```
|
||
1. Client fetches GET /api/canvas
|
||
2. Worker reads Redis key via GETRANGE → raw binary (16 MB, gzip-compressed by CF edge)
|
||
3. Client receives 16 MB of bytes — each byte is a palette index (u8, byte-aligned)
|
||
4. Client maps indices → RGBA ImageData via COLORS_RGBA lookup
|
||
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 STRING / BITFIELD (Canvas)
|
||
|
||
- Key: `rplace:canvas:v2` (bumped from the old `rplace:canvas` so the 32-color/2048² data is ignored on rollout)
|
||
- Encoding: 8 bits per pixel (u8), 256-color palette — byte-aligned, so raw Redis bytes are the pixel indices directly
|
||
- Size: `4096 × 4096 × 1 = 16,777,216 bytes` (16 MB)
|
||
- Offset: `y * CANVAS_WIDTH + x`
|
||
- Atomic batch writes: single BITFIELD command chaining `SET u8 #offset color` per pixel
|
||
- Reads via GETRANGE return the whole buffer; Cloudflare edge handles gzip
|
||
|
||
### Redis STRING (Cooldown)
|
||
|
||
- Key pattern: `cooldown:{userId}`
|
||
- Value: `"1"` (presence is the signal; content is irrelevant)
|
||
- TTL: `REQUEST_COOLDOWN_SEC` (1s) — auto-expires, no explicit cleanup
|
||
- Atomic via `SET key "1" NX EX 1`
|
||
|
||
## Rate Limiting
|
||
|
||
Fixed-window cooldown, one request per second per user:
|
||
|
||
```
|
||
On placement request:
|
||
1. SET cooldown:{userId} "1" NX EX 1
|
||
2. If reply == "OK" → allow (key set, TTL 1s)
|
||
3. If reply == null → reject (429, retryAfter = 1)
|
||
```
|
||
|
||
Batch size is independent of the cooldown; it is validated separately
|
||
(MAX_BATCH_SIZE = 2048). Anonymous identity via CF-Connecting-IP hash.
|
||
|
||
## Security
|
||
|
||
- **Rate limiting**: Atomic `SET NX EX` 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 2048 pixels per request + request body size guard
|
||
- **DO isolation**: /broadcast route only reachable via DO stub, not externally
|