feat: implement core pixel canvas with real-time updates

Next.js App Router + Upstash Redis BITFIELD for 2048x2048 canvas.
- 32-color palette (5-bit encoding) from rplace.live
- Batch pixel placement API (up to 256 pixels per request)
- Stackable credit rate limiting via Redis Lua script
- SSE real-time broadcasting via Redis sorted set
- HTML5 Canvas frontend with zoom/pan and color picker
This commit is contained in:
tiennm99 committed 2026-04-16 15:56:20 +07:00
1 parent 1c6b2edcff
commit 3beaa9d43f
26 files changed
+6951

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
};
export default nextConfig;
+5902
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "rplace-init",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@upstash/redis": "^1.37.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"eslint": "^9",
"eslint-config-next": "16.2.4"
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server';
import { setPixels } from '@/lib/canvas-storage';
import { getUserId } from '@/lib/get-user-id';
import { checkAndDeductCredits } from '@/lib/rate-limiter';
import { publishPixelUpdates } from '@/lib/sse-broadcaster';
import {
CANVAS_WIDTH,
CANVAS_HEIGHT,
MAX_COLORS,
MAX_BATCH_SIZE,
} from '@/lib/constants';
/**
* POST /api/canvas/place — batch pixel placement.
* Body: { pixels: [{x, y, color}] }
* Returns: { ok, credits } or error with 400/429.
*/
export async function POST(request) {
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
}
const { pixels } = body;
if (!Array.isArray(pixels) || pixels.length === 0) {
return NextResponse.json({ error: 'pixels_required' }, { status: 400 });
}
if (pixels.length > MAX_BATCH_SIZE) {
return NextResponse.json(
{ error: 'batch_too_large', max: MAX_BATCH_SIZE },
{ status: 400 },
);
}
// Validate each pixel
for (const p of pixels) {
if (
typeof p.x !== 'number' || typeof p.y !== 'number' ||
typeof p.color !== 'number' ||
p.x < 0 || p.x >= CANVAS_WIDTH ||
p.y < 0 || p.y >= CANVAS_HEIGHT ||
p.color < 0 || p.color >= MAX_COLORS ||
!Number.isInteger(p.x) || !Number.isInteger(p.y) ||
!Number.isInteger(p.color)
) {
return NextResponse.json({ error: 'invalid_pixel', pixel: p }, { status: 400 });
}
}
// Rate limiting
const userId = getUserId(request);
const { allowed, remaining, retryAfter } = await checkAndDeductCredits(
userId,
pixels.length,
);
if (!allowed) {
return NextResponse.json(
{ error: 'rate_limited', remaining, retryAfter },
{ status: 429 },
);
}
// Write pixels to canvas
await setPixels(pixels);
// Broadcast updates for SSE consumers
await publishPixelUpdates(pixels);
return NextResponse.json({ ok: true, credits: remaining });
}
+19
View File
@@ -0,0 +1,19 @@
import { gzipSync } from 'zlib';
import { getFullCanvas } from '@/lib/canvas-storage';
/**
* GET /api/canvas — returns the full canvas as gzipped binary.
* Client decodes 5-bit packed pixels from the raw bytes.
*/
export async function GET() {
const buffer = await getFullCanvas();
const gzipped = gzipSync(buffer);
return new Response(gzipped, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'gzip',
'Cache-Control': 'public, max-age=1, s-maxage=1, stale-while-revalidate=5',
},
});
}
+66
View File
@@ -0,0 +1,66 @@
import { getUpdatesSince } from '@/lib/sse-broadcaster';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/** SSE polling interval in ms */
const POLL_INTERVAL = 500;
/** Max SSE connection duration before client must reconnect (25s for Vercel) */
const MAX_DURATION = 25000;
/**
* GET /api/canvas/stream?since=<timestamp>
* Server-Sent Events endpoint for real-time pixel updates.
* Polls Redis sorted set and streams new entries to clients.
*/
export async function GET(request) {
const url = new URL(request.url);
let since = parseInt(url.searchParams.get('since') || '0', 10);
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const startTime = Date.now();
const poll = async () => {
// Check if client disconnected or max duration reached
if (request.signal.aborted || Date.now() - startTime > MAX_DURATION) {
controller.close();
return;
}
try {
const updates = await getUpdatesSince(since + 1);
if (updates.length > 0) {
// Update the since cursor to latest timestamp
since = updates[updates.length - 1].ts;
const data = JSON.stringify(updates);
controller.enqueue(
encoder.encode(`data: ${data}\n\n`),
);
}
} catch (err) {
// Send error event, don't crash the stream
controller.enqueue(
encoder.encode(`event: error\ndata: ${err.message}\n\n`),
);
}
setTimeout(poll, POLL_INTERVAL);
};
// Send initial connection event
controller.enqueue(encoder.encode(`data: {"connected":true}\n\n`));
poll();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
+22
View File
@@ -0,0 +1,22 @@
'use client';
/**
* Zoom controls and coordinate display.
*/
export default function CanvasControls({
zoom, onZoomIn, onZoomOut, onResetZoom, cursorPos,
}) {
return (
<div className="canvas-controls">
<div className="zoom-controls">
<button onClick={onZoomOut} title="Zoom out">−</button>
<span className="zoom-level">{zoom >= 1 ? `${zoom}x` : `1/${1 / zoom}x`}</span>
<button onClick={onZoomIn} title="Zoom in">+</button>
<button onClick={onResetZoom} title="Reset zoom">⟲</button>
</div>
<div className="coords">
({cursorPos.x}, {cursorPos.y})
</div>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
'use client';
import { useRef, useEffect, useCallback, useState } from 'react';
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from '@/lib/constants';
import { decodeCanvas, indicesToRgba } from '@/lib/canvas-decoder';
/**
* Main canvas component with zoom/pan and pixel placement.
*/
export default function CanvasRenderer({
selectedColor, credits, setCredits,
zoom, setZoom, setCursorPos,
}) {
const canvasRef = useRef(null);
const imageDataRef = useRef(null);
const panRef = useRef({ x: 0, y: 0 });
const draggingRef = useRef(false);
const lastMouseRef = useRef({ x: 0, y: 0 });
const [loading, setLoading] = useState(true);
/** Render the ImageData onto the visible canvas with current zoom/pan */
const render = useCallback(() => {
const canvas = canvasRef.current;
const imageData = imageDataRef.current;
if (!canvas || !imageData) return;
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, width, height);
// Create offscreen canvas with the pixel data
const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
const offCtx = offscreen.getContext('2d');
offCtx.putImageData(imageData, 0, 0);
ctx.save();
ctx.translate(panRef.current.x, panRef.current.y);
ctx.scale(zoom, zoom);
ctx.drawImage(offscreen, 0, 0);
ctx.restore();
}, [zoom]);
/** Load full canvas from API on mount */
useEffect(() => {
async function loadCanvas() {
try {
const res = await fetch('/api/canvas');
const buffer = await res.arrayBuffer();
const indices = decodeCanvas(buffer);
const rgba = indicesToRgba(indices);
imageDataRef.current = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
render();
} catch (err) {
console.error('Failed to load canvas:', err);
} finally {
setLoading(false);
}
}
loadCanvas();
}, [render]);
/** Re-render when zoom changes */
useEffect(() => { render(); }, [render]);
/** Resize canvas to fill viewport */
useEffect(() => {
function resize() {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
render();
}
resize();
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}, [render]);
/** Convert screen coords to canvas pixel coords */
const screenToCanvas = useCallback((clientX, clientY) => {
const x = Math.floor((clientX - panRef.current.x) / zoom);
const y = Math.floor((clientY - panRef.current.y) / zoom);
return { x, y };
}, [zoom]);
/** Update a single pixel in the local ImageData */
const updateLocalPixel = useCallback((x, y, colorIndex) => {
if (!imageDataRef.current) return;
const color = COLORS_RGBA[colorIndex];
const offset = (y * CANVAS_WIDTH + x) * 4;
imageDataRef.current.data[offset] = color[0];
imageDataRef.current.data[offset + 1] = color[1];
imageDataRef.current.data[offset + 2] = color[2];
imageDataRef.current.data[offset + 3] = color[3];
}, []);
/** Place a pixel via API */
const placePixel = useCallback(async (x, y) => {
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) return;
if (credits <= 0) return;
// Optimistic update
updateLocalPixel(x, y, selectedColor);
render();
try {
const res = await fetch('/api/canvas/place', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pixels: [{ x, y, color: selectedColor }] }),
});
const data = await res.json();
if (data.ok) {
setCredits(data.credits);
}
} catch (err) {
console.error('Failed to place pixel:', err);
}
}, [selectedColor, credits, setCredits, updateLocalPixel, render]);
/** Mouse handlers for pan and place */
const handleMouseDown = useCallback((e) => {
if (e.button === 0) {
draggingRef.current = false;
lastMouseRef.current = { x: e.clientX, y: e.clientY };
}
}, []);
const handleMouseMove = useCallback((e) => {
const pos = screenToCanvas(e.clientX, e.clientY);
setCursorPos({ x: Math.max(0, pos.x), y: Math.max(0, pos.y) });
if (e.buttons === 1) {
const dx = e.clientX - lastMouseRef.current.x;
const dy = e.clientY - lastMouseRef.current.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
draggingRef.current = true;
}
panRef.current.x += dx;
panRef.current.y += dy;
lastMouseRef.current = { x: e.clientX, y: e.clientY };
render();
}
}, [screenToCanvas, setCursorPos, render]);
const handleMouseUp = useCallback((e) => {
if (e.button === 0 && !draggingRef.current) {
const pos = screenToCanvas(e.clientX, e.clientY);
placePixel(pos.x, pos.y);
}
draggingRef.current = false;
}, [screenToCanvas, placePixel]);
/** Scroll wheel zoom centered on cursor */
const handleWheel = useCallback((e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 2 : 0.5;
const newZoom = Math.max(0.25, Math.min(64, zoom * factor));
// Zoom toward cursor position
const cx = e.clientX;
const cy = e.clientY;
panRef.current.x = cx - (cx - panRef.current.x) * (newZoom / zoom);
panRef.current.y = cy - (cy - panRef.current.y) * (newZoom / zoom);
setZoom(newZoom);
}, [zoom, setZoom]);
/** Expose updateLocalPixel for SSE updates */
useEffect(() => {
window.__rplaceUpdatePixel = (x, y, color) => {
updateLocalPixel(x, y, color);
render();
};
return () => { delete window.__rplaceUpdatePixel; };
}, [updateLocalPixel, render]);
return (
<div className="canvas-container">
{loading && <div className="loading">Loading canvas...</div>}
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onWheel={handleWheel}
style={{ cursor: draggingRef.current ? 'grabbing' : 'crosshair' }}
/>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { COLORS } from '@/lib/constants';
/**
* 32-color palette grid. Click to select a color.
*/
export default function ColorPicker({ selectedColor, onSelect }) {
return (
<div className="color-picker">
{COLORS.map((hex, i) => (
<button
key={i}
className={`color-swatch ${i === selectedColor ? 'selected' : ''}`}
style={{ backgroundColor: hex }}
onClick={() => onSelect(i)}
title={`Color ${i}: ${hex}`}
aria-label={`Select color ${hex}`}
/>
))}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useEffect, useState } from 'react';
import { MAX_CREDITS } from '@/lib/constants';
/**
* Displays credit count with client-side regeneration animation.
*/
export default function UserInfo({ credits, setCredits }) {
// Animate credit regeneration client-side (1 per second)
useEffect(() => {
if (credits >= MAX_CREDITS) return;
const timer = setInterval(() => {
setCredits((c) => Math.min(c + 1, MAX_CREDITS));
}, 1000);
return () => clearInterval(timer);
}, [credits >= MAX_CREDITS, setCredits]);
return (
<div className="user-info">
<div className="credits">
<span className="credits-label">Pixels</span>
<span className="credits-value">{credits}</span>
<span className="credits-max">/ {MAX_CREDITS}</span>
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+159
View File
@@ -0,0 +1,159 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #1a1a1a;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow: hidden;
}
.app {
width: 100vw;
height: 100vh;
position: relative;
}
/* Canvas */
.canvas-container {
width: 100%;
height: 100%;
}
.canvas-container canvas {
display: block;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.5rem;
color: #888;
}
/* Color Picker */
.color-picker {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
padding: 8px;
background: rgba(0, 0, 0, 0.85);
border-radius: 8px;
backdrop-filter: blur(8px);
z-index: 10;
}
.color-swatch {
width: 32px;
height: 32px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: transform 0.1s;
}
.color-swatch:hover {
transform: scale(1.2);
z-index: 1;
}
.color-swatch.selected {
border-color: #fff;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
transform: scale(1.15);
}
/* Canvas Controls */
.canvas-controls {
position: fixed;
top: 16px;
right: 16px;
display: flex;
gap: 12px;
align-items: center;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.85);
border-radius: 8px;
backdrop-filter: blur(8px);
z-index: 10;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 6px;
}
.zoom-controls button {
width: 28px;
height: 28px;
border: 1px solid #555;
border-radius: 4px;
background: #333;
color: #fff;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-controls button:hover {
background: #555;
}
.zoom-level {
min-width: 40px;
text-align: center;
font-size: 0.85rem;
color: #ccc;
}
.coords {
font-size: 0.85rem;
color: #aaa;
font-variant-numeric: tabular-nums;
}
/* User Info */
.user-info {
position: fixed;
top: 16px;
left: 16px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.85);
border-radius: 8px;
backdrop-filter: blur(8px);
z-index: 10;
}
.credits {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 0.9rem;
}
.credits-label {
color: #888;
}
.credits-value {
font-weight: bold;
color: #4ade80;
font-variant-numeric: tabular-nums;
}
.credits-max {
color: #555;
font-size: 0.8rem;
}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useState, useCallback } from 'react';
/**
* Shared canvas UI state: selected color, credits, coordinates.
*/
export function useCanvasState() {
const [selectedColor, setSelectedColor] = useState(27); // black
const [credits, setCredits] = useState(256);
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const zoomIn = useCallback(() => {
setZoom((z) => Math.min(z * 2, 64));
}, []);
const zoomOut = useCallback(() => {
setZoom((z) => Math.max(z / 2, 0.25));
}, []);
const resetZoom = useCallback(() => setZoom(1), []);
return {
selectedColor, setSelectedColor,
credits, setCredits,
cursorPos, setCursorPos,
zoom, setZoom, zoomIn, zoomOut, resetZoom,
};
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useEffect, useRef } from 'react';
/**
* Custom hook for SSE pixel updates.
* Auto-reconnects when the stream closes (Vercel 25s limit).
* @param {function} onBatch - callback receiving array of {pixels, ts}
*/
export function useSSEUpdates(onBatch) {
const sinceRef = useRef(Date.now());
const onBatchRef = useRef(onBatch);
onBatchRef.current = onBatch;
useEffect(() => {
let es;
let reconnectTimer;
function connect() {
es = new EventSource(`/api/canvas/stream?since=${sinceRef.current}`);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Skip connection confirmation
if (data.connected) return;
// data is an array of {pixels, ts}
if (Array.isArray(data)) {
for (const batch of data) {
if (batch.ts) sinceRef.current = batch.ts;
onBatchRef.current(batch.pixels);
}
}
} catch {
// ignore parse errors
}
};
es.onerror = () => {
es.close();
// Reconnect after a short delay
reconnectTimer = setTimeout(connect, 1000);
};
}
connect();
return () => {
es?.close();
clearTimeout(reconnectTimer);
};
}, []);
}
+14
View File
@@ -0,0 +1,14 @@
import "./globals.css";
export const metadata = {
title: "rplace — Collaborative Pixel Canvas",
description: "A Reddit r/place clone. Place pixels, create art together.",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import CanvasRenderer from './components/canvas-renderer';
import ColorPicker from './components/color-picker';
import CanvasControls from './components/canvas-controls';
import UserInfo from './components/user-info';
import { useCanvasState } from './hooks/use-canvas-state';
import { useSSEUpdates } from './hooks/use-sse-updates';
export default function Home() {
const {
selectedColor, setSelectedColor,
credits, setCredits,
cursorPos, setCursorPos,
zoom, setZoom, zoomIn, zoomOut, resetZoom,
} = useCanvasState();
// Apply SSE pixel updates to the canvas
useSSEUpdates((pixels) => {
if (!window.__rplaceUpdatePixel) return;
for (const { x, y, color } of pixels) {
window.__rplaceUpdatePixel(x, y, color);
}
});
return (
<main className="app">
<CanvasRenderer
selectedColor={selectedColor}
credits={credits}
setCredits={setCredits}
zoom={zoom}
setZoom={setZoom}
setCursorPos={setCursorPos}
/>
<CanvasControls
zoom={zoom}
onZoomIn={zoomIn}
onZoomOut={zoomOut}
onResetZoom={resetZoom}
cursorPos={cursorPos}
/>
<ColorPicker
selectedColor={selectedColor}
onSelect={setSelectedColor}
/>
<UserInfo credits={credits} setCredits={setCredits} />
</main>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from './constants';
/**
* Decode 5-bit packed canvas buffer into an array of color indices.
* @param {ArrayBuffer} buffer - raw canvas bytes
* @returns {Uint8Array} color index per pixel (length = CANVAS_WIDTH * CANVAS_HEIGHT)
*/
export function decodeCanvas(buffer) {
const bytes = new Uint8Array(buffer);
const totalPixels = CANVAS_WIDTH * CANVAS_HEIGHT;
const indices = new Uint8Array(totalPixels);
let bitPos = 0;
for (let i = 0; i < totalPixels; i++) {
const byteIndex = bitPos >> 3;
const bitOffset = bitPos & 7;
// Read 5 bits spanning at most 2 bytes
const value =
((bytes[byteIndex] << 8 | (bytes[byteIndex + 1] || 0)) >> (11 - bitOffset)) & 0x1f;
indices[i] = value;
bitPos += 5;
}
return indices;
}
/**
* Convert color indices array to RGBA ImageData pixels.
* @param {Uint8Array} indices - color index per pixel
* @returns {Uint8ClampedArray} RGBA data for ImageData
*/
export function indicesToRgba(indices) {
const rgba = new Uint8ClampedArray(indices.length * 4);
for (let i = 0; i < indices.length; i++) {
const color = COLORS_RGBA[indices[i]] || COLORS_RGBA[0];
const offset = i * 4;
rgba[offset] = color[0];
rgba[offset + 1] = color[1];
rgba[offset + 2] = color[2];
rgba[offset + 3] = color[3];
}
return rgba;
}
+59
View File
@@ -0,0 +1,59 @@
import redis from './redis-client.js';
import {
CANVAS_WIDTH,
CANVAS_HEIGHT,
TOTAL_PIXELS,
BITS_PER_PIXEL,
REDIS_CANVAS_KEY,
} from './constants.js';
/** Total bytes needed for the canvas bitfield */
const CANVAS_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
/**
* Get the full canvas as a Buffer of raw bytes.
* Each pixel is 5 bits at offset (y * CANVAS_WIDTH + x).
* Returns a zero-filled buffer if canvas doesn't exist yet.
*/
export async function getFullCanvas() {
const data = await redis.get(REDIS_CANVAS_KEY);
if (!data) {
return Buffer.alloc(CANVAS_BYTES);
}
// Upstash returns base64-encoded string for binary data
if (typeof data === 'string') {
return Buffer.from(data, 'base64');
}
return Buffer.from(data);
}
/**
* Set multiple pixels in a single atomic BITFIELD command.
* @param {Array<{x: number, y: number, color: number}>} pixels
*/
export async function setPixels(pixels) {
if (!pixels.length) return;
// Build BITFIELD subcommands: SET u5 #offset value
const commands = [];
for (const { x, y, color } of pixels) {
const offset = y * CANVAS_WIDTH + x;
commands.push('SET', 'u5', `#${offset}`, color);
}
await redis.bitfield(REDIS_CANVAS_KEY, commands);
}
/**
* Get a single pixel's color index.
* @param {number} x
* @param {number} y
* @returns {Promise<number>} color index (0-31)
*/
export async function getPixel(x, y) {
const offset = y * CANVAS_WIDTH + x;
const result = await redis.bitfield(REDIS_CANVAS_KEY, [
'GET', 'u5', `#${offset}`,
]);
return result?.[0] ?? 0;
}
+36
View File
@@ -0,0 +1,36 @@
/** Canvas dimensions (configurable) */
export const CANVAS_WIDTH = 2048;
export const CANVAS_HEIGHT = 2048;
export const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
/** Color encoding */
export const BITS_PER_PIXEL = 5;
export const MAX_COLORS = 32;
/** Rate limiting — stackable credit system */
export const MAX_BATCH_SIZE = 256;
export const CREDIT_REGEN_RATE = 1; // credits per second
export const MAX_CREDITS = 256;
/** Redis keys */
export const REDIS_CANVAS_KEY = 'canvas';
export const REDIS_UPDATES_KEY = 'canvas:updates';
/** 32-color palette from rplace.live (hex values) */
export const COLORS = [
'#6d001a', '#be0039', '#ff4500', '#ffa800', '#ffd635', '#fff8b8',
'#00a368', '#00cc78', '#7eed56', '#00756f', '#009eaa', '#00ccc0',
'#2450a4', '#3690ea', '#51e9f4', '#493ac1', '#6a5cff', '#94b3ff',
'#811e9f', '#b44ac0', '#e4abff', '#de107f', '#ff3881', '#ff99aa',
'#6d482f', '#9c6926', '#ffb470', '#000000', '#515252', '#898d90',
'#d4d7d9', '#ffffff',
];
/**
* RGBA values for each color (pre-computed for canvas rendering).
* Each entry is [r, g, b, 255].
*/
export const COLORS_RGBA = COLORS.map((hex) => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff, 255];
});
+18
View File
@@ -0,0 +1,18 @@
/**
* Extract a user identifier from the request.
* Uses IP address hash for anonymous users.
* @param {Request} request
* @returns {string} user id prefixed with "anon:" or "auth:"
*/
export function getUserId(request) {
// Extract IP from Vercel/proxy headers
const forwarded = request.headers.get('x-forwarded-for');
const ip = forwarded ? forwarded.split(',')[0].trim() : '127.0.0.1';
// Simple hash for privacy (not crypto-grade, just for keying)
let hash = 0;
for (let i = 0; i < ip.length; i++) {
hash = ((hash << 5) - hash + ip.charCodeAt(i)) | 0;
}
return `anon:${Math.abs(hash).toString(36)}`;
}
+60
View File
@@ -0,0 +1,60 @@
import redis from './redis-client.js';
import { MAX_CREDITS, CREDIT_REGEN_RATE } from './constants.js';
/**
* Lua script for atomic check-and-deduct of stackable credits.
* KEYS[1] = credits:{userId}
* ARGV[1] = count (pixels to place)
* ARGV[2] = now (unix seconds)
* ARGV[3] = max credits
* ARGV[4] = regen rate (credits per second)
* Returns: [allowed (0/1), remaining, retryAfter]
*/
const CREDIT_SCRIPT = `
local data = redis.call('HGETALL', KEYS[1])
local lastUpdate = 0
local credits = tonumber(ARGV[3])
if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'lu' then lastUpdate = tonumber(data[i+1]) end
if data[i] == 'cr' then credits = tonumber(data[i+1]) end
end
end
local elapsed = tonumber(ARGV[2]) - lastUpdate
local accrued = math.min(tonumber(ARGV[3]), credits + math.floor(elapsed * tonumber(ARGV[4])))
local count = tonumber(ARGV[1])
if accrued < count then
return {0, accrued, count - accrued}
end
local remaining = accrued - count
redis.call('HSET', KEYS[1], 'lu', ARGV[2], 'cr', remaining)
redis.call('EXPIRE', KEYS[1], 86400)
return {1, remaining, 0}
`;
/**
* Check and deduct credits for a user's pixel placement.
* @param {string} userId
* @param {number} count - number of pixels to place
* @returns {Promise<{allowed: boolean, remaining: number, retryAfter: number}>}
*/
export async function checkAndDeductCredits(userId, count) {
const now = Math.floor(Date.now() / 1000);
const key = `credits:${userId}`;
const result = await redis.eval(
CREDIT_SCRIPT,
[key],
[count, now, MAX_CREDITS, CREDIT_REGEN_RATE],
);
return {
allowed: result[0] === 1,
remaining: result[1],
retryAfter: result[2],
};
}
+12
View File
@@ -0,0 +1,12 @@
import { Redis } from '@upstash/redis';
/**
* Singleton Upstash Redis client.
* Reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN from env.
*/
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
export default redis;
+30
View File
@@ -0,0 +1,30 @@
import redis from './redis-client.js';
import { REDIS_UPDATES_KEY } from './constants.js';
/**
* Publish a batch of pixel updates to Redis for SSE consumers.
* Uses a sorted set with timestamp scores for ordered retrieval.
* @param {Array<{x: number, y: number, color: number}>} pixels
*/
export async function publishPixelUpdates(pixels) {
const now = Date.now();
const entry = JSON.stringify({ pixels, ts: now });
// Store with timestamp score, auto-expire old entries
await redis.zadd(REDIS_UPDATES_KEY, { score: now, member: entry });
// Trim entries older than 60 seconds
await redis.zremrangebyscore(REDIS_UPDATES_KEY, 0, now - 60000);
}
/**
* Get all pixel updates since a given timestamp.
* @param {number} since - timestamp in ms
* @returns {Promise<Array<{pixels: Array, ts: number}>>}
*/
export async function getUpdatesSince(since) {
const entries = await redis.zrangebyscore(
REDIS_UPDATES_KEY,
since,
'+inf',
);
return entries.map((e) => (typeof e === 'string' ? JSON.parse(e) : e));
}