{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "pong-01", "description": "Retro Pong game with Matrix display", "dependencies": [ "@upstash/redis" ], "registryDependencies": [ "https://ui.elevenlabs.io/r/matrix.json", "card" ], "files": [ { "path": "blocks/pong-01/page.tsx", "content": "import { PongGame } from \"@/components/pong-game\"\n\nexport default function Page() {\n return \n}\n", "type": "registry:page", "target": "app/pong/page.tsx" }, { "path": "blocks/pong-01/components/pong-game.tsx", "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\n\nimport {\n GameEngine,\n type GameState,\n} from \"@/lib/game-engine\"\nimport { PlayerIndicator } from \"@/components/player-indicator\"\nimport { digits, Matrix, type Frame } from \"@/components/ui/matrix\"\n\nexport function PongGame() {\n const [gameState, setGameState] = useState(\"title\")\n const [playerScore, setPlayerScore] = useState(0)\n const [aiScore, setAIScore] = useState(0)\n const [currentFrame, setCurrentFrame] = useState(() =>\n Array(7)\n .fill(0)\n .map(() => Array(21).fill(0))\n )\n\n const engineRef = useRef(null)\n const playerInputRef = useRef(0)\n const lastTimeRef = useRef(0)\n const animationFrameRef = useRef(0)\n\n const renderFrameFromEngine = (): Frame => {\n const frame: Frame = Array(7)\n .fill(0)\n .map(() => Array(21).fill(0))\n\n if (!engineRef.current || engineRef.current.data.state === \"title\") {\n const pongText = [\n [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],\n [0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0],\n [0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0],\n [0, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 0],\n [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0],\n [0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0],\n [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],\n ]\n return pongText\n }\n\n if (engineRef.current.data.state === \"gameOver\") {\n if (engineRef.current.data.winner === \"player\") {\n // WIN\n const winText = [\n [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],\n [0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0],\n [0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0],\n [0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0],\n [0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0],\n [0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0],\n [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],\n ]\n return winText\n } else {\n // LOSE\n const loseText = [\n [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],\n [0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0],\n [0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],\n [0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 0],\n [0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0],\n [0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0],\n [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],\n ]\n return loseText\n }\n }\n\n const data = engineRef.current.data\n\n for (let col = 0; col < 21; col++) {\n if (col === 10) {\n for (let row = 0; row < 7; row += 2) {\n frame[row][col] = 0.2\n }\n }\n }\n\n const playerPaddleTop = Math.floor(data.playerPaddle.y)\n for (let i = 0; i < 3; i++) {\n const row = playerPaddleTop + i\n if (row >= 0 && row < 7) {\n frame[row][0] = 1\n }\n }\n\n const aiPaddleTop = Math.floor(data.aiPaddle.y)\n for (let i = 0; i < 3; i++) {\n const row = aiPaddleTop + i\n if (row >= 0 && row < 7) {\n frame[row][20] = 1\n }\n }\n\n const ballCol = Math.floor(data.ball.x)\n const ballRow = Math.floor(data.ball.y)\n if (ballCol >= 0 && ballCol < 21 && ballRow >= 0 && ballRow < 7) {\n frame[ballRow][ballCol] = 1\n }\n\n for (let i = 0; i < data.ball.trail.length; i++) {\n const trailCol = Math.floor(data.ball.trail[i].x)\n const trailRow = Math.floor(data.ball.trail[i].y)\n if (trailCol >= 0 && trailCol < 21 && trailRow >= 0 && trailRow < 7) {\n frame[trailRow][trailCol] = Math.max(\n frame[trailRow][trailCol],\n 0.5 - i * 0.15\n )\n }\n }\n\n return frame\n }\n\n useEffect(() => {\n engineRef.current = new GameEngine()\n setCurrentFrame(renderFrameFromEngine())\n\n function gameLoop(timestamp: number) {\n if (!engineRef.current) return\n\n if (lastTimeRef.current === 0) {\n lastTimeRef.current = timestamp\n }\n\n const deltaTime = Math.min((timestamp - lastTimeRef.current) / 1000, 0.1)\n lastTimeRef.current = timestamp\n\n engineRef.current.update(deltaTime, playerInputRef.current)\n\n const data = engineRef.current.data\n setGameState(data.state)\n setPlayerScore(data.playerScore)\n setAIScore(data.aiScore)\n\n setCurrentFrame(renderFrameFromEngine())\n\n if (data.state === \"playing\" || data.state === \"paused\") {\n animationFrameRef.current = requestAnimationFrame(gameLoop)\n }\n }\n\n const handleKeyDown = (e: KeyboardEvent) => {\n if (e.key === \"ArrowUp\") {\n e.preventDefault()\n playerInputRef.current = -1\n } else if (e.key === \"ArrowDown\") {\n e.preventDefault()\n playerInputRef.current = 1\n } else if (e.key === \" \") {\n e.preventDefault()\n const state = engineRef.current?.data.state\n if (state === \"title\" || state === \"gameOver\") {\n if (engineRef.current) {\n engineRef.current.startGame()\n setCurrentFrame(renderFrameFromEngine())\n lastTimeRef.current = 0\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n animationFrameRef.current = requestAnimationFrame(gameLoop)\n }\n }\n } else if (e.key === \"p\" || e.key === \"P\") {\n e.preventDefault()\n const state = engineRef.current?.data.state\n if (state === \"playing\" || state === \"paused\") {\n if (engineRef.current) {\n engineRef.current.togglePause()\n if (engineRef.current.data.state === \"playing\") {\n lastTimeRef.current = 0\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n animationFrameRef.current = requestAnimationFrame(gameLoop)\n } else {\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }\n }\n }\n }\n }\n\n const handleKeyUp = (e: KeyboardEvent) => {\n if (e.key === \"ArrowUp\" || e.key === \"ArrowDown\") {\n playerInputRef.current = 0\n }\n }\n\n window.addEventListener(\"keydown\", handleKeyDown)\n window.addEventListener(\"keyup\", handleKeyUp)\n\n return () => {\n window.removeEventListener(\"keydown\", handleKeyDown)\n window.removeEventListener(\"keyup\", handleKeyUp)\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }\n }, [])\n\n return (\n
\n
\n \n
\n
\n
\n Player\n
\n \n
\n\n \n\n
\n
\n ELEVENLABS\n
\n \n
\n
\n\n
\n {gameState === \"title\" && (\n
\n Press Space to\n start\n
\n )}\n\n {gameState === \"playing\" && (\n
\n \n \n Move  · \n P Pause\n
\n )}\n\n {gameState === \"paused\" && (\n
\n PAUSED - Press P{\" \"}\n to resume\n
\n )}\n\n {gameState === \"gameOver\" && (\n
\n Press Space to\n play again\n
\n )}\n
\n
\n
\n )\n}\n", "type": "registry:component", "target": "components/pong-game.tsx" }, { "path": "blocks/pong-01/components/player-indicator.tsx", "content": "\"use client\"\n\nimport { useEffect, useState } from \"react\"\n\nimport { updatePlayerHeartbeat } from \"@/actions/pong-actions\"\nimport { digits, Matrix, type Frame } from \"@/components/ui/matrix\"\n\nconst HEARTBEAT_INTERVAL_MS = 5000\nconst PULSE_INTERVAL_MS = 1000\nconst PLAYER_COUNT_DIGITS = 4\n\n/**\n * PlayerIndicator displays a live count of active players\n * with a pulsing dot indicator and matrix-style digit display.\n *\n * @remarks\n * Uses Upstash Redis via server actions to track active players.\n * Each player sends a heartbeat every 5 seconds.\n */\nexport function PlayerIndicator() {\n const [userCount, setUserCount] = useState(1)\n const [pulse, setPulse] = useState(1)\n const [playerId] = useState(() =>\n Math.random().toString(36).substring(7)\n )\n\n useEffect(() => {\n // Pulse animation for the live indicator dot\n const pulseInterval = setInterval(() => {\n setPulse((prev) => (prev === 1 ? 0.5 : 1))\n }, PULSE_INTERVAL_MS)\n\n // Heartbeat to track active players via server action\n const sendHeartbeat = async () => {\n try {\n const count = await updatePlayerHeartbeat(playerId)\n setUserCount(count)\n } catch (error) {\n console.error(\"Failed to send heartbeat:\", error)\n }\n }\n\n // Send initial heartbeat immediately\n void sendHeartbeat()\n const heartbeatInterval = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS)\n\n return () => {\n clearInterval(pulseInterval)\n clearInterval(heartbeatInterval)\n }\n }, [playerId])\n\n // Single pixel pulse indicator\n const pulsePattern: Frame = [[pulse]]\n\n // Convert count to array of digits with leading zeros (e.g., 0042)\n const countDigits: number[] = userCount\n .toString()\n .padStart(PLAYER_COUNT_DIGITS, \"0\")\n .split(\"\")\n .map(Number)\n\n return (\n
\n \n
\n {countDigits.map((digit, index) => (\n \n ))}\n \n playing\n \n
\n
\n )\n}\n", "type": "registry:component", "target": "components/player-indicator.tsx" }, { "path": "blocks/pong-01/components/game-engine.ts", "content": "import { soundManager } from \"@/lib/sound-manager\"\n\nexport type GameState =\n | \"title\"\n | \"countdown\"\n | \"playing\"\n | \"paused\"\n | \"gameOver\"\n\nexport interface Ball {\n x: number\n y: number\n velX: number\n velY: number\n trail: Array<{ x: number; y: number }>\n}\n\nexport interface Paddle {\n y: number\n targetY: number\n}\n\nexport interface GameData {\n state: GameState\n ball: Ball\n playerPaddle: Paddle\n aiPaddle: Paddle\n playerScore: number\n aiScore: number\n countdown: number\n winner: \"player\" | \"ai\" | null\n}\n\nconst FIELD_WIDTH = 21\nconst FIELD_HEIGHT = 7\nconst PADDLE_HEIGHT = 3\nconst MAX_SPEED = 0.4\nconst INITIAL_SPEED = 0.15\nconst SPEED_INCREASE = 1.08\nconst WIN_SCORE = 3\nconst AI_REACTION_SPEED = 0.14\nconst AI_PREDICTION_ERROR = 1.2\n\nexport class GameEngine {\n data: GameData\n\n constructor() {\n this.data = this.createInitialState()\n }\n\n private createInitialState(): GameData {\n return {\n state: \"title\",\n ball: {\n x: FIELD_WIDTH / 2,\n y: FIELD_HEIGHT / 2,\n velX: INITIAL_SPEED,\n velY: INITIAL_SPEED * 0.8,\n trail: [],\n },\n playerPaddle: {\n y: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,\n targetY: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,\n },\n aiPaddle: {\n y: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,\n targetY: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,\n },\n playerScore: 0,\n aiScore: 0,\n countdown: 3,\n winner: null,\n }\n }\n\n startGame() {\n this.data = {\n ...this.createInitialState(),\n state: \"playing\",\n }\n soundManager.play(\"gameStart\")\n }\n\n resetBall(toPlayer: boolean) {\n this.data.ball = {\n x: FIELD_WIDTH / 2,\n y: FIELD_HEIGHT / 2,\n velX: toPlayer ? -INITIAL_SPEED : INITIAL_SPEED,\n velY: (Math.random() - 0.5) * INITIAL_SPEED * 1.5,\n trail: [],\n }\n }\n\n update(deltaTime: number, playerInput: number) {\n if (this.data.state !== \"playing\") return\n\n this.updatePaddles(deltaTime, playerInput)\n this.updateBall(deltaTime)\n this.checkCollisions()\n }\n\n private updatePaddles(deltaTime: number, playerInput: number) {\n if (playerInput !== 0) {\n const moveSpeed = 50\n const movement = playerInput * deltaTime * moveSpeed\n\n this.data.playerPaddle.y = Math.max(\n 0,\n Math.min(\n FIELD_HEIGHT - PADDLE_HEIGHT,\n this.data.playerPaddle.y + movement\n )\n )\n this.data.playerPaddle.targetY = this.data.playerPaddle.y\n }\n\n const predictedY =\n this.data.ball.y +\n (this.data.ball.velY / Math.abs(this.data.ball.velX)) *\n (FIELD_WIDTH - 2 - this.data.ball.x) +\n (Math.random() - 0.5) * AI_PREDICTION_ERROR\n\n this.data.aiPaddle.targetY = Math.max(\n 0,\n Math.min(FIELD_HEIGHT - PADDLE_HEIGHT, predictedY - PADDLE_HEIGHT / 2)\n )\n\n const aiDiff = this.data.aiPaddle.targetY - this.data.aiPaddle.y\n this.data.aiPaddle.y +=\n Math.sign(aiDiff) *\n Math.min(Math.abs(aiDiff), AI_REACTION_SPEED * deltaTime * 60)\n }\n\n private updateBall(deltaTime: number) {\n this.data.ball.trail.unshift({\n x: this.data.ball.x,\n y: this.data.ball.y,\n })\n\n if (this.data.ball.trail.length > 3) {\n this.data.ball.trail.pop()\n }\n\n this.data.ball.x += this.data.ball.velX * deltaTime * 60\n this.data.ball.y += this.data.ball.velY * deltaTime * 60\n }\n\n private checkCollisions() {\n if (this.data.ball.y <= 0.5) {\n this.data.ball.y = 0.5\n this.data.ball.velY = Math.abs(this.data.ball.velY)\n soundManager.play(\"wallHit\")\n }\n\n if (this.data.ball.y >= FIELD_HEIGHT - 0.5) {\n this.data.ball.y = FIELD_HEIGHT - 0.5\n this.data.ball.velY = -Math.abs(this.data.ball.velY)\n soundManager.play(\"wallHit\")\n }\n\n if (\n this.data.ball.x <= 1 &&\n this.data.ball.velX < 0 &&\n this.data.ball.y >= this.data.playerPaddle.y - 0.5 &&\n this.data.ball.y <= this.data.playerPaddle.y + PADDLE_HEIGHT + 0.5\n ) {\n this.data.ball.x = 1\n this.data.ball.velX = Math.abs(this.data.ball.velX) * SPEED_INCREASE\n\n const hitPos =\n (this.data.ball.y - this.data.playerPaddle.y - PADDLE_HEIGHT / 2) /\n (PADDLE_HEIGHT / 2)\n this.data.ball.velY += hitPos * 0.15\n\n const speed = Math.sqrt(\n this.data.ball.velX ** 2 + this.data.ball.velY ** 2\n )\n if (speed > MAX_SPEED) {\n this.data.ball.velX = (this.data.ball.velX / speed) * MAX_SPEED\n this.data.ball.velY = (this.data.ball.velY / speed) * MAX_SPEED\n }\n\n soundManager.play(\"paddleHit\")\n }\n\n if (\n this.data.ball.x >= FIELD_WIDTH - 2 &&\n this.data.ball.velX > 0 &&\n this.data.ball.y >= this.data.aiPaddle.y - 0.5 &&\n this.data.ball.y <= this.data.aiPaddle.y + PADDLE_HEIGHT + 0.5\n ) {\n this.data.ball.x = FIELD_WIDTH - 2\n this.data.ball.velX = -Math.abs(this.data.ball.velX) * SPEED_INCREASE\n\n const hitPos =\n (this.data.ball.y - this.data.aiPaddle.y - PADDLE_HEIGHT / 2) /\n (PADDLE_HEIGHT / 2)\n this.data.ball.velY += hitPos * 0.15\n\n const speed = Math.sqrt(\n this.data.ball.velX ** 2 + this.data.ball.velY ** 2\n )\n if (speed > MAX_SPEED) {\n this.data.ball.velX = (this.data.ball.velX / speed) * MAX_SPEED\n this.data.ball.velY = (this.data.ball.velY / speed) * MAX_SPEED\n }\n\n soundManager.play(\"paddleHit\")\n }\n\n if (this.data.ball.x < 0) {\n this.data.aiScore++\n soundManager.play(\"score\")\n\n if (this.data.aiScore >= WIN_SCORE) {\n this.data.state = \"gameOver\"\n this.data.winner = \"ai\"\n soundManager.play(\"win\")\n } else {\n this.resetBall(false)\n }\n }\n\n if (this.data.ball.x > FIELD_WIDTH) {\n this.data.playerScore++\n soundManager.play(\"score\")\n\n if (this.data.playerScore >= WIN_SCORE) {\n this.data.state = \"gameOver\"\n this.data.winner = \"player\"\n soundManager.play(\"win\")\n } else {\n this.resetBall(true)\n }\n }\n }\n\n togglePause() {\n if (this.data.state === \"playing\") {\n this.data.state = \"paused\"\n } else if (this.data.state === \"paused\") {\n this.data.state = \"playing\"\n }\n }\n\n tickCountdown() {\n if (this.data.state !== \"countdown\") return\n\n this.data.countdown--\n if (this.data.countdown <= 0) {\n this.data.state = \"playing\"\n }\n }\n}\n", "type": "registry:file", "target": "lib/game-engine.ts" }, { "path": "blocks/pong-01/components/sound-manager.ts", "content": "type SoundType = \"paddleHit\" | \"wallHit\" | \"score\" | \"gameStart\" | \"win\"\n\nclass SoundManager {\n private sounds: Map = new Map()\n private volume = 0.3\n\n constructor() {\n if (typeof window !== \"undefined\") {\n this.loadSounds()\n }\n }\n\n private loadSounds() {\n const soundFiles: Record = {\n paddleHit: \"https://ui.elevenlabs.io/sounds/pong/paddle_hit.mp3\",\n wallHit: \"https://ui.elevenlabs.io/sounds/pong/wall_hit.mp3\",\n score: \"https://ui.elevenlabs.io/sounds/pong/score.mp3\",\n gameStart: \"https://ui.elevenlabs.io/sounds/pong/game_start.mp3\",\n win: \"https://ui.elevenlabs.io/sounds/pong/win.mp3\",\n }\n\n for (const [key, path] of Object.entries(soundFiles)) {\n try {\n const audio = new Audio(path)\n audio.volume = this.volume\n audio.preload = \"auto\"\n this.sounds.set(key as SoundType, audio)\n } catch {\n console.warn(`Failed to load sound: ${path}`)\n }\n }\n }\n\n play(sound: SoundType) {\n const audio = this.sounds.get(sound)\n if (!audio) return\n\n audio.currentTime = 0\n audio.play().catch(() => {\n // Ignore play errors (e.g., user hasn't interacted with page yet)\n })\n }\n\n setVolume(vol: number) {\n this.volume = Math.max(0, Math.min(1, vol))\n this.sounds.forEach((audio) => {\n audio.volume = this.volume\n })\n }\n}\n\nexport const soundManager = new SoundManager()\n", "type": "registry:file", "target": "lib/sound-manager.ts" }, { "path": "blocks/pong-01/actions.ts", "content": "\"use server\"\n\nimport { Redis } from \"@upstash/redis\"\n\nconst redis = new Redis({\n url: process.env.KV_REST_API_URL!,\n token: process.env.KV_REST_API_TOKEN!,\n})\n\n/** Player is considered inactive after 10 seconds without a heartbeat */\nconst PLAYER_TIMEOUT_MS = 10_000\n\n/** Redis key for the sorted set storing player timestamps */\nconst PLAYERS_KEY = \"pong:players\"\n\n/**\n * Updates a player's heartbeat and returns the current count of active players.\n *\n * @param playerId - Unique identifier for the player\n * @returns The total number of currently active players\n *\n * @remarks\n * This function performs three operations:\n * 1. Updates or adds the player's timestamp in Redis\n * 2. Removes players who haven't sent a heartbeat in 10 seconds\n * 3. Returns the count of remaining active players\n *\n * Uses Redis sorted sets with timestamps as scores for efficient\n * range-based cleanup of stale entries.\n */\nexport async function updatePlayerHeartbeat(playerId: string): Promise {\n if (!playerId || typeof playerId !== \"string\") {\n throw new Error(\"Invalid playerId\")\n }\n\n const now = Date.now()\n\n await redis.zadd(PLAYERS_KEY, { score: now, member: playerId })\n\n await redis.zremrangebyscore(PLAYERS_KEY, 0, now - PLAYER_TIMEOUT_MS)\n\n const count = await redis.zcard(PLAYERS_KEY)\n\n return count\n}\n", "type": "registry:file", "target": "actions/pong-actions.ts" } ], "meta": { "iframeHeight": "700px", "container": "w-full bg-surface min-h-svh flex px-4 py-12 items-center md:py-20 justify-center min-w-0", "mobile": "component" }, "categories": [ "audio" ], "type": "registry:block" }