Files
rplace/docs/system-architecture.md
T
tiennm99 cfbac2a586 feat(canvas): 4096^2 canvas, 256-color palette (u8 byte-aligned), custom picker (#5)
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)
2026-04-18 13:47:01 +07:00

2.9 KiB
Raw Blame History

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