# Pong 01

> Retro Pong game with Matrix display

A shadcn/ui block from the [@elevenlabs-ui](https://ui.elevenlabs.io/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/elevenlabs-ui/pong-01).

- Page, with a live preview: https://shadcn.exchange/r/elevenlabs-ui/pong-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/elevenlabs-ui/pong-01-light-a8f2fbe2cfb592d6.png
- Registry item: https://ui.elevenlabs.io/r/pong-01.json
- Categories: audio
- Listed: 2026-10-17, source last changed 2026-10-17

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/elevenlabs-ui/pong-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@upstash/redis`
- Registry items: `https://ui.elevenlabs.io/r/matrix.json`, `card`

## Files

### `blocks/pong-01/page.tsx`

```tsx
import { PongGame } from "@/components/pong-game"

export default function Page() {
  return <PongGame />
}
```

### `blocks/pong-01/components/pong-game.tsx`

```tsx
"use client"

import { useEffect, useRef, useState } from "react"

import {
  GameEngine,
  type GameState,
} from "@/lib/game-engine"
import { PlayerIndicator } from "@/components/player-indicator"
import { digits, Matrix, type Frame } from "@/components/ui/matrix"

export function PongGame() {
  const [gameState, setGameState] = useState<GameState>("title")
  const [playerScore, setPlayerScore] = useState(0)
  const [aiScore, setAIScore] = useState(0)
  const [currentFrame, setCurrentFrame] = useState<Frame>(() =>
    Array(7)
      .fill(0)
      .map(() => Array(21).fill(0))
  )

  const engineRef = useRef<GameEngine | null>(null)
  const playerInputRef = useRef(0)
  const lastTimeRef = useRef<number>(0)
  const animationFrameRef = useRef<number>(0)

  const renderFrameFromEngine = (): Frame => {
    const frame: Frame = Array(7)
      .fill(0)
      .map(() => Array(21).fill(0))

    if (!engineRef.current || engineRef.current.data.state === "title") {
      const pongText = [
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0],
        [0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0],
        [0, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 0],
        [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0],
        [0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
      ]
      return pongText
    }

    if (engineRef.current.data.state === "gameOver") {
      if (engineRef.current.data.winner === "player") {
        // WIN
        const winText = [
          [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
          [0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0],
          [0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0],
          [0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0],
          [0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0],
          [0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0],
          [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        ]
        return winText
      } else {
        // LOSE
        const loseText = [
          [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
          [0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0],
          [0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
          [0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 0],
          [0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0],
          [0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0],
          [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        ]
        return loseText
      }
    }

    const data = engineRef.current.data

    for (let col = 0; col < 21; col++) {
      if (col === 10) {
        for (let row = 0; row < 7; row += 2) {
          frame[row][col] = 0.2
        }
      }
    }

    const playerPaddleTop = Math.floor(data.playerPaddle.y)
    for (let i = 0; i < 3; i++) {
      const row = playerPaddleTop + i
      if (row >= 0 && row < 7) {
        frame[row][0] = 1
      }
    }

    const aiPaddleTop = Math.floor(data.aiPaddle.y)
    for (let i = 0; i < 3; i++) {
      const row = aiPaddleTop + i
      if (row >= 0 && row < 7) {
        frame[row][20] = 1
      }
    }

    const ballCol = Math.floor(data.ball.x)
    const ballRow = Math.floor(data.ball.y)
    if (ballCol >= 0 && ballCol < 21 && ballRow >= 0 && ballRow < 7) {
      frame[ballRow][ballCol] = 1
    }

    for (let i = 0; i < data.ball.trail.length; i++) {
      const trailCol = Math.floor(data.ball.trail[i].x)
      const trailRow = Math.floor(data.ball.trail[i].y)
      if (trailCol >= 0 && trailCol < 21 && trailRow >= 0 && trailRow < 7) {
        frame[trailRow][trailCol] = Math.max(
          frame[trailRow][trailCol],
          0.5 - i * 0.15
        )
      }
    }

    return frame
  }

  useEffect(() => {
    engineRef.current = new GameEngine()
    setCurrentFrame(renderFrameFromEngine())

    function gameLoop(timestamp: number) {
      if (!engineRef.current) return

      if (lastTimeRef.current === 0) {
        lastTimeRef.current = timestamp
      }

      const deltaTime = Math.min((timestamp - lastTimeRef.current) / 1000, 0.1)
      lastTimeRef.current = timestamp

      engineRef.current.update(deltaTime, playerInputRef.current)

      const data = engineRef.current.data
      setGameState(data.state)
      setPlayerScore(data.playerScore)
      setAIScore(data.aiScore)

      setCurrentFrame(renderFrameFromEngine())

      if (data.state === "playing" || data.state === "paused") {
        animationFrameRef.current = requestAnimationFrame(gameLoop)
      }
    }

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "ArrowUp") {
        e.preventDefault()
        playerInputRef.current = -1
      } else if (e.key === "ArrowDown") {
        e.preventDefault()
        playerInputRef.current = 1
      } else if (e.key === " ") {
        e.preventDefault()
        const state = engineRef.current?.data.state
        if (state === "title" || state === "gameOver") {
          if (engineRef.current) {
            engineRef.current.startGame()
            setCurrentFrame(renderFrameFromEngine())
            lastTimeRef.current = 0
            if (animationFrameRef.current) {
              cancelAnimationFrame(animationFrameRef.current)
            }
            animationFrameRef.current = requestAnimationFrame(gameLoop)
          }
        }
      } else if (e.key === "p" || e.key === "P") {
        e.preventDefault()
        const state = engineRef.current?.data.state
        if (state === "playing" || state === "paused") {
          if (engineRef.current) {
            engineRef.current.togglePause()
            if (engineRef.current.data.state === "playing") {
              lastTimeRef.current = 0
              if (animationFrameRef.current) {
                cancelAnimationFrame(animationFrameRef.current)
              }
              animationFrameRef.current = requestAnimationFrame(gameLoop)
            } else {
              if (animationFrameRef.current) {
                cancelAnimationFrame(animationFrameRef.current)
              }
            }
          }
        }
      }
    }

    const handleKeyUp = (e: KeyboardEvent) => {
      if (e.key === "ArrowUp" || e.key === "ArrowDown") {
        playerInputRef.current = 0
      }
    }

    window.addEventListener("keydown", handleKeyDown)
    window.addEventListener("keyup", handleKeyUp)

    return () => {
      window.removeEventListener("keydown", handleKeyDown)
      window.removeEventListener("keyup", handleKeyUp)
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current)
      }
    }
  }, [])

  return (
    <div className="flex min-h-[600px] w-full flex-col items-center justify-center gap-8 p-8">
      <div className="flex flex-col items-center gap-2">
        <PlayerIndicator />
        <div className="flex items-center gap-8">
          <div className="flex flex-col items-center gap-2">
            <div className="text-muted-foreground font-mono text-[9px] tracking-wider uppercase">
              Player
            </div>
            <Matrix
              rows={7}
              cols={5}
              pattern={digits[playerScore] || digits[0]}
              size={12}
              gap={2}
              ariaLabel="Player score"
            />
          </div>

          <Matrix
            rows={7}
            cols={21}
            pattern={currentFrame}
            size={16}
            gap={3}
            ariaLabel="Pong game field"
          />

          <div className="flex flex-col items-center gap-2">
            <div className="text-muted-foreground font-mono text-[9px] tracking-wider uppercase">
              ELEVENLABS
            </div>
            <Matrix
              rows={7}
              cols={5}
              pattern={digits[aiScore] || digits[0]}
              size={12}
              gap={2}
              ariaLabel="ElevenLabs score"
            />
          </div>
        </div>

        <div className="flex h-[28px] flex-col items-center justify-center gap-2">
          {gameState === "title" && (
            <div className="text-muted-foreground text-center text-xs">
              Press <kbd className="bg-muted rounded px-2 py-1">Space</kbd> to
              start
            </div>
          )}

          {gameState === "playing" && (
            <div className="text-muted-foreground text-center text-xs">
              <kbd className="bg-muted rounded px-2 py-1">↑</kbd>
              <kbd className="bg-muted mx-1 rounded px-2 py-1">↓</kbd>
              Move &nbsp;·&nbsp;
              <kbd className="bg-muted rounded px-2 py-1">P</kbd> Pause
            </div>
          )}

          {gameState === "paused" && (
            <div className="text-muted-foreground text-center text-xs">
              PAUSED - Press <kbd className="bg-muted rounded px-2 py-1">P</kbd>{" "}
              to resume
            </div>
          )}

          {gameState === "gameOver" && (
            <div className="text-muted-foreground text-center text-xs">
              Press <kbd className="bg-muted rounded px-2 py-1">Space</kbd> to
              play again
            </div>
          )}
        </div>
      </div>
    </div>
  )
}
```

### `blocks/pong-01/components/player-indicator.tsx`

```tsx
"use client"

import { useEffect, useState } from "react"

import { updatePlayerHeartbeat } from "@/actions/pong-actions"
import { digits, Matrix, type Frame } from "@/components/ui/matrix"

const HEARTBEAT_INTERVAL_MS = 5000
const PULSE_INTERVAL_MS = 1000
const PLAYER_COUNT_DIGITS = 4

/**
 * PlayerIndicator displays a live count of active players
 * with a pulsing dot indicator and matrix-style digit display.
 *
 * @remarks
 * Uses Upstash Redis via server actions to track active players.
 * Each player sends a heartbeat every 5 seconds.
 */
export function PlayerIndicator() {
  const [userCount, setUserCount] = useState<number>(1)
  const [pulse, setPulse] = useState<number>(1)
  const [playerId] = useState<string>(() =>
    Math.random().toString(36).substring(7)
  )

  useEffect(() => {
    // Pulse animation for the live indicator dot
    const pulseInterval = setInterval(() => {
      setPulse((prev) => (prev === 1 ? 0.5 : 1))
    }, PULSE_INTERVAL_MS)

    // Heartbeat to track active players via server action
    const sendHeartbeat = async () => {
      try {
        const count = await updatePlayerHeartbeat(playerId)
        setUserCount(count)
      } catch (error) {
        console.error("Failed to send heartbeat:", error)
      }
    }

    // Send initial heartbeat immediately
    void sendHeartbeat()
    const heartbeatInterval = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS)

    return () => {
      clearInterval(pulseInterval)
      clearInterval(heartbeatInterval)
    }
  }, [playerId])

  // Single pixel pulse indicator
  const pulsePattern: Frame = [[pulse]]

  // Convert count to array of digits with leading zeros (e.g., 0042)
  const countDigits: number[] = userCount
    .toString()
    .padStart(PLAYER_COUNT_DIGITS, "0")
    .split("")
    .map(Number)

  return (
    <div className="flex items-center gap-1.5">
      <Matrix
        rows={1}
        cols={1}
        pattern={pulsePattern}
        size={4}
        gap={0}
        ariaLabel="Live indicator"
      />
      <div className="flex items-center gap-0.5">
        {countDigits.map((digit, index) => (
          <Matrix
            key={index}
            rows={7}
            cols={5}
            pattern={digits[digit]}
            size={3}
            gap={0.5}
            ariaLabel={`Player count digit ${digit}`}
          />
        ))}
        <span className="text-muted-foreground ml-1 font-mono text-[8px] tracking-wider uppercase">
          playing
        </span>
      </div>
    </div>
  )
}
```

### `blocks/pong-01/components/game-engine.ts`

```ts
import { soundManager } from "@/lib/sound-manager"

export type GameState =
  | "title"
  | "countdown"
  | "playing"
  | "paused"
  | "gameOver"

export interface Ball {
  x: number
  y: number
  velX: number
  velY: number
  trail: Array<{ x: number; y: number }>
}

export interface Paddle {
  y: number
  targetY: number
}

export interface GameData {
  state: GameState
  ball: Ball
  playerPaddle: Paddle
  aiPaddle: Paddle
  playerScore: number
  aiScore: number
  countdown: number
  winner: "player" | "ai" | null
}

const FIELD_WIDTH = 21
const FIELD_HEIGHT = 7
const PADDLE_HEIGHT = 3
const MAX_SPEED = 0.4
const INITIAL_SPEED = 0.15
const SPEED_INCREASE = 1.08
const WIN_SCORE = 3
const AI_REACTION_SPEED = 0.14
const AI_PREDICTION_ERROR = 1.2

export class GameEngine {
  data: GameData

  constructor() {
    this.data = this.createInitialState()
  }

  private createInitialState(): GameData {
    return {
      state: "title",
      ball: {
        x: FIELD_WIDTH / 2,
        y: FIELD_HEIGHT / 2,
        velX: INITIAL_SPEED,
        velY: INITIAL_SPEED * 0.8,
        trail: [],
      },
      playerPaddle: {
        y: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,
        targetY: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,
      },
      aiPaddle: {
        y: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,
        targetY: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,
      },
      playerScore: 0,
      aiScore: 0,
      countdown: 3,
      winner: null,
    }
  }

  startGame() {
    this.data = {
      ...this.createInitialState(),
      state: "playing",
    }
    soundManager.play("gameStart")
  }

  resetBall(toPlayer: boolean) {
    this.data.ball = {
      x: FIELD_WIDTH / 2,
      y: FIELD_HEIGHT / 2,
      velX: toPlayer ? -INITIAL_SPEED : INITIAL_SPEED,
      velY: (Math.random() - 0.5) * INITIAL_SPEED * 1.5,
      trail: [],
    }
  }

  update(deltaTime: number, playerInput: number) {
    if (this.data.state !== "playing") return

    this.updatePaddles(deltaTime, playerInput)
    this.updateBall(deltaTime)
    this.checkCollisions()
  }

  private updatePaddles(deltaTime: number, playerInput: number) {
    if (playerInput !== 0) {
      const moveSpeed = 50
      const movement = playerInput * deltaTime * moveSpeed

      this.data.playerPaddle.y = Math.max(
        0,
        Math.min(
          FIELD_HEIGHT - PADDLE_HEIGHT,
          this.data.playerPaddle.y + movement
        )
      )
      this.data.playerPaddle.targetY = this.data.playerPaddle.y
    }

    const predictedY =
      this.data.ball.y +
      (this.data.ball.velY / Math.abs(this.data.ball.velX)) *
        (FIELD_WIDTH - 2 - this.data.ball.x) +
      (Math.random() - 0.5) * AI_PREDICTION_ERROR

    this.data.aiPaddle.targetY = Math.max(
      0,
      Math.min(FIELD_HEIGHT - PADDLE_HEIGHT, predictedY - PADDLE_HEIGHT / 2)
    )

    const aiDiff = this.data.aiPaddle.targetY - this.data.aiPaddle.y
    this.data.aiPaddle.y +=
      Math.sign(aiDiff) *
      Math.min(Math.abs(aiDiff), AI_REACTION_SPEED * deltaTime * 60)
  }

  private updateBall(deltaTime: number) {
    this.data.ball.trail.unshift({
      x: this.data.ball.x,
      y: this.data.ball.y,
    })

    if (this.data.ball.trail.length > 3) {
      this.data.ball.trail.pop()
    }

    this.data.ball.x += this.data.ball.velX * deltaTime * 60
    this.data.ball.y += this.data.ball.velY * deltaTime * 60
  }

  private checkCollisions() {
    if (this.data.ball.y <= 0.5) {
      this.data.ball.y = 0.5
      this.data.ball.velY = Math.abs(this.data.ball.velY)
      soundManager.play("wallHit")
    }

    if (this.data.ball.y >= FIELD_HEIGHT - 0.5) {
      this.data.ball.y = FIELD_HEIGHT - 0.5
      this.data.ball.velY = -Math.abs(this.data.ball.velY)
      soundManager.play("wallHit")
    }

    if (
      this.data.ball.x <= 1 &&
      this.data.ball.velX < 0 &&
      this.data.ball.y >= this.data.playerPaddle.y - 0.5 &&
      this.data.ball.y <= this.data.playerPaddle.y + PADDLE_HEIGHT + 0.5
    ) {
      this.data.ball.x = 1
      this.data.ball.velX = Math.abs(this.data.ball.velX) * SPEED_INCREASE

      const hitPos =
        (this.data.ball.y - this.data.playerPaddle.y - PADDLE_HEIGHT / 2) /
        (PADDLE_HEIGHT / 2)
      this.data.ball.velY += hitPos * 0.15

      const speed = Math.sqrt(
        this.data.ball.velX ** 2 + this.data.ball.velY ** 2
      )
      if (speed > MAX_SPEED) {
        this.data.ball.velX = (this.data.ball.velX / speed) * MAX_SPEED
        this.data.ball.velY = (this.data.ball.velY / speed) * MAX_SPEED
      }

      soundManager.play("paddleHit")
    }

    if (
      this.data.ball.x >= FIELD_WIDTH - 2 &&
      this.data.ball.velX > 0 &&
      this.data.ball.y >= this.data.aiPaddle.y - 0.5 &&
      this.data.ball.y <= this.data.aiPaddle.y + PADDLE_HEIGHT + 0.5
    ) {
      this.data.ball.x = FIELD_WIDTH - 2
      this.data.ball.velX = -Math.abs(this.data.ball.velX) * SPEED_INCREASE

      const hitPos =
        (this.data.ball.y - this.data.aiPaddle.y - PADDLE_HEIGHT / 2) /
        (PADDLE_HEIGHT / 2)
      this.data.ball.velY += hitPos * 0.15

      const speed = Math.sqrt(
        this.data.ball.velX ** 2 + this.data.ball.velY ** 2
      )
      if (speed > MAX_SPEED) {
        this.data.ball.velX = (this.data.ball.velX / speed) * MAX_SPEED
        this.data.ball.velY = (this.data.ball.velY / speed) * MAX_SPEED
      }

      soundManager.play("paddleHit")
    }

    if (this.data.ball.x < 0) {
      this.data.aiScore++
      soundManager.play("score")

      if (this.data.aiScore >= WIN_SCORE) {
        this.data.state = "gameOver"
        this.data.winner = "ai"
        soundManager.play("win")
      } else {
        this.resetBall(false)
      }
    }

    if (this.data.ball.x > FIELD_WIDTH) {
      this.data.playerScore++
      soundManager.play("score")

      if (this.data.playerScore >= WIN_SCORE) {
        this.data.state = "gameOver"
        this.data.winner = "player"
        soundManager.play("win")
      } else {
        this.resetBall(true)
      }
    }
  }

  togglePause() {
    if (this.data.state === "playing") {
      this.data.state = "paused"
    } else if (this.data.state === "paused") {
      this.data.state = "playing"
    }
  }

  tickCountdown() {
    if (this.data.state !== "countdown") return

    this.data.countdown--
    if (this.data.countdown <= 0) {
      this.data.state = "playing"
    }
  }
}
```

### `blocks/pong-01/components/sound-manager.ts`

```ts
type SoundType = "paddleHit" | "wallHit" | "score" | "gameStart" | "win"

class SoundManager {
  private sounds: Map<SoundType, HTMLAudioElement> = new Map()
  private volume = 0.3

  constructor() {
    if (typeof window !== "undefined") {
      this.loadSounds()
    }
  }

  private loadSounds() {
    const soundFiles: Record<SoundType, string> = {
      paddleHit: "https://ui.elevenlabs.io/sounds/pong/paddle_hit.mp3",
      wallHit: "https://ui.elevenlabs.io/sounds/pong/wall_hit.mp3",
      score: "https://ui.elevenlabs.io/sounds/pong/score.mp3",
      gameStart: "https://ui.elevenlabs.io/sounds/pong/game_start.mp3",
      win: "https://ui.elevenlabs.io/sounds/pong/win.mp3",
    }

    for (const [key, path] of Object.entries(soundFiles)) {
      try {
        const audio = new Audio(path)
        audio.volume = this.volume
        audio.preload = "auto"
        this.sounds.set(key as SoundType, audio)
      } catch {
        console.warn(`Failed to load sound: ${path}`)
      }
    }
  }

  play(sound: SoundType) {
    const audio = this.sounds.get(sound)
    if (!audio) return

    audio.currentTime = 0
    audio.play().catch(() => {
      // Ignore play errors (e.g., user hasn't interacted with page yet)
    })
  }

  setVolume(vol: number) {
    this.volume = Math.max(0, Math.min(1, vol))
    this.sounds.forEach((audio) => {
      audio.volume = this.volume
    })
  }
}

export const soundManager = new SoundManager()
```

### `blocks/pong-01/actions.ts`

```ts
"use server"

import { Redis } from "@upstash/redis"

const redis = new Redis({
  url: process.env.KV_REST_API_URL!,
  token: process.env.KV_REST_API_TOKEN!,
})

/** Player is considered inactive after 10 seconds without a heartbeat */
const PLAYER_TIMEOUT_MS = 10_000

/** Redis key for the sorted set storing player timestamps */
const PLAYERS_KEY = "pong:players"

/**
 * Updates a player's heartbeat and returns the current count of active players.
 *
 * @param playerId - Unique identifier for the player
 * @returns The total number of currently active players
 *
 * @remarks
 * This function performs three operations:
 * 1. Updates or adds the player's timestamp in Redis
 * 2. Removes players who haven't sent a heartbeat in 10 seconds
 * 3. Returns the count of remaining active players
 *
 * Uses Redis sorted sets with timestamps as scores for efficient
 * range-based cleanup of stale entries.
 */
export async function updatePlayerHeartbeat(playerId: string): Promise<number> {
  if (!playerId || typeof playerId !== "string") {
    throw new Error("Invalid playerId")
  }

  const now = Date.now()

  await redis.zadd(PLAYERS_KEY, { score: now, member: playerId })

  await redis.zremrangebyscore(PLAYERS_KEY, 0, now - PLAYER_TIMEOUT_MS)

  const count = await redis.zcard(PLAYERS_KEY)

  return count
}
```
