shadimage
Chanhdai
blocknot-found-01source: ok · refreshed

Not Found 01

A 404 page with a playable brick breaker game.

npx shadcn add "https://chanhdai.com/r/not-found-01.json"
free

Where it goes

error-state

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion
iconslucide-react

Registry dependencies

buttonempty

Files11 · source cached 10/2/2026, 12:38:52 AM

src/registry/blocks/not-found-01/page.tsx
import Link from "next/link"
import { ArrowRightIcon, SearchXIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import { Daikanoid } from "@/registry/blocks/not-found-01/components/daikanoid"

export default function NotFound() {
  return (
    <div>
      <Empty className="py-12 max-lg:min-h-svh">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <SearchXIcon />
          </EmptyMedia>

          <EmptyTitle className="text-base">Page not found</EmptyTitle>

          <EmptyDescription>
            The page you are looking for does not exist or has been moved.
          </EmptyDescription>
        </EmptyHeader>

        <EmptyContent>
          <Button
            variant="outline"
            nativeButton={false}
            render={<Link href="/" />}
          >
            Go to Home
            <ArrowRightIcon />
          </Button>
        </EmptyContent>
      </Empty>

      {/* The canvas is fixed at 800x600, so the game is desktop only. */}
      <section className="place-items-center pb-6 max-lg:hidden">
        <Daikanoid />
      </section>
    </div>
  )
}
src/registry/blocks/not-found-01/components/daikanoid/index.tsx
"use client"

import dynamic from "next/dynamic"

export const Daikanoid = dynamic(
  () => import("./component").then((mod) => mod.Daikanoid),
  {
    ssr: false,
    loading: () => <div className="h-150 w-200 ring-1 ring-border" />,
  }
)
src/registry/blocks/not-found-01/components/daikanoid/component.tsx
// Inspired by departuremono.com

"use client"

import { useEffect, useRef } from "react"
import { useReducedMotion } from "motion/react"
import { useTheme } from "next-themes"
import p5 from "p5"

import { cn } from "@/lib/utils"

import { Ball } from "./ball"
import { resetGame } from "./brick"
import { Colors, loadColors } from "./colors"
import {
  BALL_DARK_URL,
  BALL_LIGHT_URL,
  FONT_URL,
  PADDLE_DARK_URL,
  PADDLE_LIGHT_URL,
  SOUND_BOUNCE_URL,
  SOUND_BREAK_URL,
  SOUND_GAME_OVER_URL,
} from "./constants"
import { getLogoIndex } from "./logos"
import { Paddle } from "./paddle"
import type { GameState } from "./types"
import { UI } from "./ui"

export function Daikanoid({
  className,
  defaultLogo,
  ...props
}: Omit<React.ComponentPropsWithRef<"canvas">, "children"> & {
  defaultLogo?: string
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const shouldReduceMotion = useReducedMotion()
  const { resolvedTheme } = useTheme()

  useEffect(() => {
    loadColors()

    const el = canvasRef.current!

    const state: GameState = {
      canvas: null,

      enableGame: false,
      enableSounds: !shouldReduceMotion,

      score: 0,
      bricks: [],
      logoIndex: getLogoIndex(defaultLogo),

      soundBounce: null,
      soundBreak: null,
      soundGameOver: null,

      ballImage: null,
      paddleImage: null,
    }

    let font: p5.Font
    let sketch: p5
    let paddle: Paddle
    let ball: Ball
    let ui: UI

    function game(p: p5) {
      sketch = p

      p.preload = () => {
        font = p.loadFont(FONT_URL)

        state.soundBounce = p.createAudio(SOUND_BOUNCE_URL)
        state.soundBreak = p.createAudio(SOUND_BREAK_URL)
        state.soundGameOver = p.createAudio(SOUND_GAME_OVER_URL)

        state.ballImage = p.loadImage(
          resolvedTheme === "dark" ? BALL_DARK_URL : BALL_LIGHT_URL
        )
        state.paddleImage = p.loadImage(
          resolvedTheme === "dark" ? PADDLE_DARK_URL : PADDLE_LIGHT_URL
        )
      }

      p.setup = () => {
        state.canvas = p.createCanvas(800, 600, p.P2D, el)
        paddle = new Paddle(p, state)
        ball = new Ball(p, state)
        ui = new UI(p, state)

        p.imageMode(p.CENTER)
        p.textFont(font)
        p.background(Colors.background)
        p.fill(Colors.foreground)
        p.noStroke()

        resetGame(p, state)

        state.canvas.mouseClicked(() => {
          state.enableGame = true
          ball.reset()
          return false
        })

        state.canvas.touchStarted(() => {
          state.enableGame = true
          return false
        })
      }

      p.draw = () => {
        p.background(Colors.background)

        if (state.bricks.length === 0) {
          p.fill(Colors.foreground)
          p.textAlign(p.CENTER, p.CENTER)
          p.textSize(80)
          p.text("404", p.width / 2, p.height / 2 - 11)
          return
        }

        paddle.show()
        paddle.move()

        ball.show()
        if (state.enableGame) {
          ball.move()
          ball.checkEdges()
          ball.checkPaddle(paddle)
        }

        for (const brick of state.bricks) {
          brick.show()
        }
        p.noStroke()

        ball.checkBricks(state.bricks)
        ui.show()
      }
    }

    const handleKeyPress = (e: KeyboardEvent) => {
      if (e.key === " ") {
        if (state.bricks.length === 0) {
          state.enableGame = false
          ball.reset()
          resetGame(sketch, state)
          return
        }

        state.enableGame = true
        ball.reset()
      }
    }
    window.addEventListener("keypress", handleKeyPress)

    const p5Instance = new p5(game)

    return () => {
      window.removeEventListener("keypress", handleKeyPress)
      p5Instance.remove()
    }
  }, [shouldReduceMotion, resolvedTheme, defaultLogo])

  return (
    <canvas
      ref={canvasRef}
      className={cn("h-150 w-200 ring-1 ring-border", className)}
      {...props}
    />
  )
}
src/registry/blocks/not-found-01/components/daikanoid/ball.ts
import type p5 from "p5"

import type { Brick } from "./brick"
import { BALL_SIZE, BALL_SPEED, BRICK_SCORE, uncheckedClamp } from "./constants"
import type { GameState } from "./types"

export class Ball {
  p: p5
  state: GameState
  radius: number
  x: number
  y: number
  xSpeed: number
  ySpeed: number

  constructor(p: p5, state: GameState) {
    this.p = p
    this.state = state
    this.radius = BALL_SIZE / 2
    this.x = p.width * 0.5
    this.y = p.height * 0.6
    this.xSpeed = 5
    this.ySpeed = 5
  }

  show() {
    this.p.imageMode(this.p.CENTER)
    this.p.image(this.state.ballImage!, this.x, this.y, BALL_SIZE, BALL_SIZE)
  }

  move() {
    this.x += this.xSpeed
    this.y += this.ySpeed
  }

  checkEdges() {
    if (this.x < this.radius || this.x > this.p.width - this.radius) {
      this.x = uncheckedClamp(this.radius, this.p.width - this.radius, this.x)
      this.xSpeed *= -1

      if (this.state.enableSounds) {
        playSound(this.state.soundBounce!)
      }
    }

    if (this.y < this.radius) {
      this.y = uncheckedClamp(this.radius, this.p.height - this.radius, this.y)
      this.ySpeed *= -1

      if (this.state.enableSounds) {
        playSound(this.state.soundBounce!)
      }
    }

    if (this.y > this.p.height) {
      this.state.enableGame = false

      if (this.state.enableSounds) {
        playSound(this.state.soundGameOver!)
      }

      this.reset()
    }
  }

  checkPaddle(paddle: { x: number; y: number; width: number }) {
    if (
      this.y + this.radius > paddle.y &&
      this.x > paddle.x &&
      this.x < paddle.x + paddle.width
    ) {
      this.ySpeed *= -1
      this.y = paddle.y - this.radius

      if (this.state.enableSounds) {
        playSound(this.state.soundBounce!)
      }
    }
  }

  reset() {
    this.x = this.p.width * 0.5
    this.y = this.p.height * 0.6

    const vector = this.p
      .createVector(Math.random() * 2 - 1, Math.random())
      .normalize()
      .mult(BALL_SPEED)

    this.xSpeed = vector.x
    this.ySpeed = vector.y
  }

  checkBricks(bricks: Brick[]) {
    let collision = false

    for (let i = bricks.length - 1; i >= 0; --i) {
      const brick = bricks[i]

      if (
        this.x + this.radius > brick.x &&
        this.x - this.radius < brick.x + brick.w &&
        this.y - this.radius < brick.y + brick.h &&
        this.y + this.radius > brick.y
      ) {
        collision ||= true
        this.state.score += BRICK_SCORE
        bricks.splice(i, 1)

        if (this.state.enableSounds) {
          playSound(this.state.soundBreak!)
        }
      }
    }

    if (collision) this.ySpeed *= -1
  }
}

function playSound(sound: p5.MediaElement) {
  const el = sound.elt as HTMLAudioElement
  el.currentTime = 0
  el.volume = 0.3
  el.play().catch(() => {})
}
src/registry/blocks/not-found-01/components/daikanoid/brick.ts
import type p5 from "p5"

import { Colors } from "./colors"
import { BRICK_HEIGHT, BRICK_SHADOW_THICKNESS } from "./constants"
import { LOGOS } from "./logos"
import type { GameState } from "./types"

export class Brick {
  p: p5
  c: p5.Color | string = Colors.brick
  x: number
  y: number
  w: number
  h: number

  constructor(p: p5, x: number, y: number, w: number, h: number) {
    this.p = p
    this.x = x
    this.y = y
    this.w = w
    this.h = h
  }

  color(c: p5.Color | string): this {
    this.c = c
    return this
  }

  show() {
    this.p.strokeWeight(4)
    this.p.stroke(Colors.background)
    this.p.fill(this.c.toString())
    this.p.rect(this.x, this.y, this.w, this.h)
    this.p.noStroke()

    this.p.fill(Colors.brickHighlight)
    this.p.rect(this.x + 2, this.y + 2, this.w - 4, BRICK_SHADOW_THICKNESS)
    this.p.rect(
      this.x + 2,
      this.y + 2 + BRICK_SHADOW_THICKNESS,
      BRICK_SHADOW_THICKNESS,
      this.h - 4 - BRICK_SHADOW_THICKNESS
    )

    this.p.fill(Colors.brickShadow)
    this.p.rect(
      this.x + 2,
      this.y + this.h - 5,
      this.w - 4,
      BRICK_SHADOW_THICKNESS
    )
    this.p.rect(
      this.x + this.w - 5,
      this.y + 2,
      BRICK_SHADOW_THICKNESS,
      this.h - 4 - BRICK_SHADOW_THICKNESS
    )
  }
}

export function resetGame(p: p5, state: GameState) {
  state.score = 0

  // Advance the index so each reset cycles to a different logo.
  const logo = LOGOS[state.logoIndex % LOGOS.length]
  state.logoIndex = (state.logoIndex + 1) % LOGOS.length

  const { brickWidth, pattern } = logo
  const colScale = logo.colScale ?? 1
  const rowScale = logo.rowScale ?? 1
  const cs = logo.colOffset ?? 0
  const rs = logo.rowOffset ?? 0

  const bricks: Brick[] = []
  for (let py = 0; py < pattern.length; ++py) {
    const row = pattern[py]
    for (let px = 0; px < row.length; ++px) {
      if (row[px] !== "X") continue

      for (let dx = 0; dx < colScale; ++dx) {
        for (let dy = 0; dy < rowScale; ++dy) {
          const col = cs + px * colScale + dx
          const brickRow = rs + py * rowScale + dy
          bricks.push(
            new Brick(
              p,
              col * brickWidth,
              brickRow * BRICK_HEIGHT,
              brickWidth,
              BRICK_HEIGHT
            ).color(Colors.brick)
          )
        }
      }
    }
  }

  state.bricks = bricks
}
src/registry/blocks/not-found-01/components/daikanoid/paddle.ts
import type p5 from "p5"

import {
  PADDLE_HEIGHT,
  PADDLE_SPEED,
  PADDLE_WIDTH,
  uncheckedClamp,
} from "./constants"
import type { GameState } from "./types"

export class Paddle {
  p: p5
  state: GameState

  width: number
  height: number

  x: number
  y: number

  constructor(p: p5, state: GameState) {
    this.p = p
    this.state = state

    this.width = PADDLE_WIDTH
    this.height = PADDLE_HEIGHT

    this.x = p.width / 2 - this.width / 2
    this.y = p.height - this.height

    state.canvas!.mouseMoved((e: MouseEvent) => {
      this.x = uncheckedClamp(
        PADDLE_HEIGHT / 2,
        p.width - this.width - PADDLE_HEIGHT / 2,
        e.offsetX - PADDLE_WIDTH / 2
      )
      return false
    })

    state.canvas!.touchMoved((e: TouchEvent) => {
      const rect = (
        state.canvas!.elt as HTMLCanvasElement
      ).getBoundingClientRect()

      const touch = e.touches[0]

      this.x = uncheckedClamp(
        PADDLE_HEIGHT / 2,
        p.width - this.width - PADDLE_HEIGHT / 2,
        touch.clientX - rect.left - PADDLE_WIDTH / 2
      )

      return false
    })
  }

  show() {
    this.p.imageMode(this.p.CORNER)
    this.p.image(
      this.state.paddleImage!,
      this.x,
      this.y,
      PADDLE_WIDTH,
      PADDLE_HEIGHT
    )

    // this.p.fill(Colors.paddle)
    // this.p.rect(this.x, this.y, PADDLE_WIDTH, PADDLE_HEIGHT)

    // this.p.fill(Colors.paddleHighlight)
    // this.p.rect(this.x, this.y + 3, PADDLE_WIDTH, 3)

    // this.p.fill(Colors.paddleShadow)
    // this.p.rect(this.x, this.y + PADDLE_HEIGHT - 3, PADDLE_WIDTH, 3)
  }

  move() {
    if (this.p.keyIsDown(this.p.LEFT_ARROW) && this.x > PADDLE_HEIGHT / 2) {
      this.x -= PADDLE_SPEED
    } else if (
      this.p.keyIsDown(this.p.RIGHT_ARROW) &&
      this.x < this.p.width - this.width - PADDLE_HEIGHT / 2
    ) {
      this.x += PADDLE_SPEED
    }

    this.x = uncheckedClamp(
      PADDLE_HEIGHT / 2,
      this.p.width - this.width - PADDLE_HEIGHT / 2,
      this.x
    )
  }

  automove(ball: { x: number }) {
    this.x = uncheckedClamp(
      PADDLE_HEIGHT / 2,
      this.p.width - this.width - PADDLE_HEIGHT / 2,
      ball.x - this.width / 2
    )
  }
}
src/registry/blocks/not-found-01/components/daikanoid/logos.ts
/**
 * A logo is authored as a grid of "pixels": "X" places a brick, any other
 * character leaves a gap. The board is built from this data in `resetGame`
 * (brick.ts). Brick width is per-logo (height stays `BRICK_HEIGHT`), so a
 * smaller width yields a finer grid for more detailed logos.
 */
export interface LogoDef {
  name: string
  /** Brick width in px (canvas is 800 wide → `800 / brickWidth` columns). */
  brickWidth: number
  pattern: string[]
  /** Bricks per pixel horizontally (default 1). */
  colScale?: number
  /** Bricks per pixel vertically (default 1). */
  rowScale?: number
  /** Left offset in bricks (default 0). */
  colOffset?: number
  /** Top offset in bricks (default 0). */
  rowOffset?: number
}

const chanhdai: LogoDef = {
  name: "ChanhDai",
  brickWidth: 80,
  rowScale: 3,
  colOffset: 1,
  rowOffset: 0,
  pattern: [".XX.XXX.", "X...X..X", "X...X..X", ".XX.XXX."],
}

const eve: LogoDef = {
  name: "eve",
  brickWidth: 40,
  colOffset: 2,
  rowOffset: 1,
  pattern: [
    "XXXXXXX...XXXXXX",
    "XXXXXXX...XXXXXX",
    ".........X......",
    "XXX......X...XXX",
    "XXX.....X....XXX",
    "........X.......",
    "XXXX...X....XXXX",
    "XXXX...X....XXXX",
  ],
}

const vercel: LogoDef = {
  name: "Vercel",
  brickWidth: 40,
  colOffset: 4,
  rowOffset: 0,
  pattern: [
    ".....XX.....",
    ".....XX.....",
    "....XXXX....",
    "....XXXX....",
    "...XXXXXX...",
    "...XXXXXX...",
    "..XXXXXXXX..",
    "..XXXXXXXX..",
    ".XXXXXXXXXX.",
    ".XXXXXXXXXX.",
    "XXXXXXXXXXXX",
    "XXXXXXXXXXXX",
  ],
}

export const LOGOS: LogoDef[] = [chanhdai, eve, vercel]

export function getLogoIndex(name?: string | null): number {
  if (!name) return 0
  const index = LOGOS.findIndex(
    (logo) => logo.name.toLowerCase() === name.toLowerCase()
  )
  return index === -1 ? 0 : index
}
src/registry/blocks/not-found-01/components/daikanoid/colors.ts
function getCSSVariable(name: string) {
  return getComputedStyle(document.documentElement)
    .getPropertyValue(name)
    .trim()
}

function initColors() {
  return {
    background: getCSSVariable("--dk-background"),
    foreground: getCSSVariable("--dk-foreground"),
    mutedForeground: getCSSVariable("--dk-muted-foreground"),
    brick: getCSSVariable("--dk-brick"),
    brickHighlight: getCSSVariable("--dk-brick-highlight"),
    brickShadow: getCSSVariable("--dk-brick-shadow"),
  } as const
}

export let Colors = {} as ReturnType<typeof initColors>

export function loadColors() {
  Colors = initColors()
}
src/registry/blocks/not-found-01/components/daikanoid/constants.ts
export const BALL_SIZE = 21
export const BALL_SPEED = 10

export const PADDLE_WIDTH = 96
export const PADDLE_HEIGHT = 24
export const PADDLE_SPEED = 16

// Brick width is now defined per-logo (see logos.ts); height stays fixed.
export const BRICK_HEIGHT = 24
export const BRICK_SHADOW_THICKNESS = 3
export const BRICK_SCORE = 10

export const FONT_URL =
  "https://assets.chanhdai.com/fonts/DepartureMono-Regular.otf"

export const SOUND_BOUNCE_URL =
  "https://assets.chanhdai.com/sounds/daikanoid/bounce.mp3"
export const SOUND_BREAK_URL =
  "https://assets.chanhdai.com/sounds/daikanoid/break.mp3"
export const SOUND_GAME_OVER_URL =
  "https://assets.chanhdai.com/sounds/daikanoid/game-over.mp3"

export const BALL_LIGHT_URL =
  "https://assets.chanhdai.com/images/daikanoid/ball-light.png?v=2"
export const BALL_DARK_URL =
  "https://assets.chanhdai.com/images/daikanoid/ball-dark.png?v=2"

export const PADDLE_LIGHT_URL =
  "https://assets.chanhdai.com/images/daikanoid/paddle-light.png?v=2"
export const PADDLE_DARK_URL =
  "https://assets.chanhdai.com/images/daikanoid/paddle-dark.png?v=2"

export function uncheckedClamp(
  min: number,
  max: number,
  value: number
): number {
  return Math.min(Math.max(value, min), max)
}
src/registry/blocks/not-found-01/components/daikanoid/types.ts
import type p5 from "p5"

import type { Brick } from "./brick"

export interface GameState {
  canvas: p5.Renderer | null

  enableGame: boolean
  enableSounds: boolean

  score: number
  bricks: Brick[]
  logoIndex: number

  soundBounce: p5.MediaElement | null
  soundBreak: p5.MediaElement | null
  soundGameOver: p5.MediaElement | null
  ballImage: p5.Image | null
  paddleImage: p5.Image | null
}
src/registry/blocks/not-found-01/components/daikanoid/ui.ts
import type p5 from "p5"

import { Colors } from "./colors"
import type { GameState } from "./types"

export class UI {
  p: p5
  state: GameState

  constructor(p: p5, state: GameState) {
    this.p = p
    this.state = state
  }

  show() {
    this.p.fill(Colors.foreground)
    this.p.textSize(16)
    this.p.textAlign(this.p.LEFT, this.p.TOP)
    this.p.text(this.state.score.toString().padStart(3, "0"), 3, 0)

    this.p.fill(Colors.mutedForeground)
    this.p.textSize(12)
    this.p.textAlign(this.p.RIGHT, this.p.TOP)
    this.p.text("FIG_404", this.p.width - 3, 0)
  }
}

More from Chanhdai

Browse all components in this library →