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 · mediumanimationmotion
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)
}
}