open-weights-breakout / app /LogoBreakout.tsx
burtenshaw's picture
burtenshaw HF Staff
Simplify start overlay and remove grid
2fc2dfb verified
Raw
History Blame Contribute Delete
20.4 kB
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const WIDTH = 1120;
const HEIGHT = 700;
const BRICK_COLUMNS = 8;
const ACCENTS = ["#2d9cdb", "#7fba00", "#ffb900", "#f25022", "#67e8f9"];
const LOGOS = [
["AI21", "ai21"],
["AMD", "amd"],
["American Innovators Network", "american-innovators-network"],
["AMP", "amp"],
["Andreessen Horowitz", "andreessen-horowitz"],
["Arcee AI", "arcee-ai"],
["Arena", "arena"],
["Baseten", "baseten"],
["Black Forest Labs", "black-forest-labs"],
["Block", "block"],
["Box", "box"],
["Cisco", "cisco"],
["Cloudflare", "cloudflare"],
["Cohere", "cohere"],
["CrowdStrike", "crowdstrike"],
["Dell Technologies", "dell-technologies"],
["DoorDash", "doordash"],
["Emergence Capital", "emergence-capital"],
["Fireworks AI", "fireworks-ai"],
["Genspark", "genspark"],
["GitHub", "github"],
["Google", "google"],
["Hugging Face", "hugging-face"],
["IBM", "ibm"],
["Inferact", "inferact"],
["Interconnects AI", "interconnects-ai"],
["The Linux Foundation", "linux-foundation"],
["Mariana Minerals", "mariana-minerals"],
["Meta", "meta"],
["Microsoft", "microsoft"],
["Mistral", "mistral"],
["Morph", "morph"],
["Mozilla", "mozilla"],
["Nebius", "nebius"],
["Nous Research", "nous-research"],
["NVIDIA", "nvidia"],
["Ollama", "ollama"],
["OpenAI", "openai"],
["OpenClaw", "openclaw"],
["Palantir", "palantir"],
["Palo Alto Networks", "palo-alto-networks"],
["Periodic Labs", "periodic-labs"],
["Perplexity", "perplexity"],
["Prime Intellect", "prime-intellect"],
["Reflection", "reflection"],
["Replit", "replit"],
["ServiceNow", "servicenow"],
["Telnyx", "telnyx"],
["Trajectory", "trajectory"],
["Y Combinator", "y-combinator"],
] as const;
type Status = "loading" | "ready" | "running" | "paused" | "won" | "lost";
type Brick = {
x: number;
y: number;
width: number;
height: number;
alive: boolean;
name: string;
image: HTMLImageElement;
accent: string;
};
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
life: number;
color: string;
};
type Game = {
status: Status;
score: number;
lives: number;
destroyed: number;
paddle: { x: number; y: number; width: number; height: number };
ball: { x: number; y: number; vx: number; vy: number; radius: number };
bricks: Brick[];
particles: Particle[];
trail: { x: number; y: number; life: number }[];
logoImages: HTMLImageElement[];
paddleImage: HTMLImageElement;
};
const loadImage = (src: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Could not load ${src}`));
image.src = src;
});
const createBricks = (images: HTMLImageElement[]): Brick[] => {
const marginX = 28;
const gapX = 10;
const gapY = 7;
const width = (WIDTH - marginX * 2 - gapX * (BRICK_COLUMNS - 1)) / BRICK_COLUMNS;
const height = 64;
const startY = 32;
return LOGOS.map(([name], index) => {
const column = index % BRICK_COLUMNS;
const row = Math.floor(index / BRICK_COLUMNS);
return {
x: marginX + column * (width + gapX),
y: startY + row * (height + gapY),
width,
height,
alive: true,
name,
image: images[index],
accent: ACCENTS[row % ACCENTS.length],
};
});
};
const makeGame = (
logoImages: HTMLImageElement[],
paddleImage: HTMLImageElement,
): Game => {
const paddle = {
x: WIDTH / 2 - 118,
y: HEIGHT - 57,
width: 236,
height: 32,
};
return {
status: "ready",
score: 0,
lives: 3,
destroyed: 0,
paddle,
ball: {
x: WIDTH / 2,
y: paddle.y - 13,
vx: 3.1,
vy: -5.7,
radius: 9,
},
bricks: createBricks(logoImages),
particles: [],
trail: [],
logoImages,
paddleImage,
};
};
const roundRect = (
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) => {
context.beginPath();
context.roundRect(x, y, width, height, radius);
};
export function LogoBreakout() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const gameRef = useRef<Game | null>(null);
const keysRef = useRef(new Set<string>());
const soundRef = useRef<{ muted: boolean; context: AudioContext | null }>({
muted: false,
context: null,
});
const [status, setStatus] = useState<Status>("loading");
const [score, setScore] = useState(0);
const [lives, setLives] = useState(3);
const [destroyed, setDestroyed] = useState(0);
const [muted, setMuted] = useState(false);
const tone = useCallback((frequency: number, duration = 0.055) => {
if (soundRef.current.muted) return;
const AudioContextClass =
window.AudioContext ||
(
window as typeof window & {
webkitAudioContext?: typeof AudioContext;
}
).webkitAudioContext;
if (!AudioContextClass) return;
const audioContext =
soundRef.current.context ?? new AudioContextClass();
soundRef.current.context = audioContext;
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const now = audioContext.currentTime;
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(frequency, now);
gain.gain.setValueAtTime(0.025, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
oscillator.connect(gain);
gain.connect(audioContext.destination);
oscillator.start(now);
oscillator.stop(now + duration);
}, []);
const syncUi = useCallback((game: Game) => {
setStatus(game.status);
setScore(game.score);
setLives(game.lives);
setDestroyed(game.destroyed);
}, []);
const launch = useCallback(() => {
const game = gameRef.current;
if (!game) return;
if (game.status === "won" || game.status === "lost") {
const fresh = makeGame(game.logoImages, game.paddleImage);
fresh.status = "running";
gameRef.current = fresh;
syncUi(fresh);
tone(520, 0.08);
return;
}
if (game.status === "ready") {
game.status = "running";
setStatus("running");
tone(520, 0.08);
}
}, [syncUi, tone]);
const restart = useCallback(() => {
const game = gameRef.current;
if (!game) return;
const fresh = makeGame(game.logoImages, game.paddleImage);
gameRef.current = fresh;
syncUi(fresh);
}, [syncUi]);
const togglePause = useCallback(() => {
const game = gameRef.current;
if (!game) return;
if (game.status === "running") {
game.status = "paused";
setStatus("paused");
} else if (game.status === "paused") {
game.status = "running";
setStatus("running");
}
}, []);
useEffect(() => {
let cancelled = false;
const prepare = async () => {
try {
const [logoImages, paddleImage] = await Promise.all([
Promise.all(
LOGOS.map(([, slug]) => loadImage(`/logos/${slug}.png`)),
),
loadImage("/anthropic-wordmark.png"),
]);
if (cancelled) return;
const game = makeGame(logoImages, paddleImage);
gameRef.current = game;
syncUi(game);
} catch {
if (!cancelled) setStatus("lost");
}
};
prepare();
return () => {
cancelled = true;
};
}, [syncUi]);
useEffect(() => {
const keyDown = (event: KeyboardEvent) => {
if (["ArrowLeft", "ArrowRight", " ", "p", "P"].includes(event.key)) {
event.preventDefault();
}
keysRef.current.add(event.key);
if (event.key === " ") launch();
if (event.key === "p" || event.key === "P") togglePause();
};
const keyUp = (event: KeyboardEvent) => {
keysRef.current.delete(event.key);
};
window.addEventListener("keydown", keyDown);
window.addEventListener("keyup", keyUp);
return () => {
window.removeEventListener("keydown", keyDown);
window.removeEventListener("keyup", keyUp);
};
}, [launch, togglePause]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
let frame = 0;
let previousTime = performance.now();
const burst = (game: Game, brick: Brick) => {
for (let index = 0; index < 13; index += 1) {
const angle = Math.random() * Math.PI * 2;
const speed = 1.5 + Math.random() * 3.5;
game.particles.push({
x: game.ball.x,
y: game.ball.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1,
color: brick.accent,
});
}
};
const loseLife = (game: Game) => {
game.lives -= 1;
game.trail = [];
if (game.lives <= 0) {
game.status = "lost";
tone(120, 0.28);
} else {
game.status = "ready";
game.ball.x = game.paddle.x + game.paddle.width / 2;
game.ball.y = game.paddle.y - game.ball.radius - 2;
game.ball.vx = 3.1;
game.ball.vy = -5.7;
tone(180, 0.18);
}
syncUi(game);
};
const hitBrick = (game: Game, brick: Brick) => {
brick.alive = false;
game.destroyed += 1;
game.score += 100;
burst(game, brick);
tone(610 + (game.destroyed % 7) * 48);
if (game.destroyed === LOGOS.length) {
game.status = "won";
tone(880, 0.32);
}
syncUi(game);
};
const update = (game: Game, delta: number) => {
const paddleSpeed = 10 * delta;
if (keysRef.current.has("ArrowLeft")) game.paddle.x -= paddleSpeed;
if (keysRef.current.has("ArrowRight")) game.paddle.x += paddleSpeed;
game.paddle.x = Math.max(
14,
Math.min(WIDTH - game.paddle.width - 14, game.paddle.x),
);
if (game.status === "ready") {
game.ball.x = game.paddle.x + game.paddle.width / 2;
game.ball.y = game.paddle.y - game.ball.radius - 2;
}
game.particles.forEach((particle) => {
particle.x += particle.vx * delta;
particle.y += particle.vy * delta;
particle.vy += 0.09 * delta;
particle.life -= 0.025 * delta;
});
game.particles = game.particles.filter((particle) => particle.life > 0);
game.trail.forEach((point) => {
point.life -= 0.08 * delta;
});
game.trail = game.trail.filter((point) => point.life > 0);
if (game.status !== "running") return;
const { ball, paddle } = game;
game.trail.push({ x: ball.x, y: ball.y, life: 1 });
if (game.trail.length > 15) game.trail.shift();
ball.x += ball.vx * delta;
ball.y += ball.vy * delta;
if (ball.x - ball.radius <= 0) {
ball.x = ball.radius;
ball.vx = Math.abs(ball.vx);
tone(260);
} else if (ball.x + ball.radius >= WIDTH) {
ball.x = WIDTH - ball.radius;
ball.vx = -Math.abs(ball.vx);
tone(260);
}
if (ball.y - ball.radius <= 0) {
ball.y = ball.radius;
ball.vy = Math.abs(ball.vy);
tone(290);
}
if (
ball.vy > 0 &&
ball.y + ball.radius >= paddle.y &&
ball.y - ball.radius <= paddle.y + paddle.height &&
ball.x >= paddle.x &&
ball.x <= paddle.x + paddle.width
) {
ball.y = paddle.y - ball.radius;
const offset =
(ball.x - (paddle.x + paddle.width / 2)) / (paddle.width / 2);
const speed = Math.min(9.2, Math.hypot(ball.vx, ball.vy) + 0.08);
const angle = offset * 1.08;
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.max(4.6, Math.cos(angle) * speed);
tone(420, 0.07);
}
for (const brick of game.bricks) {
if (!brick.alive) continue;
const closestX = Math.max(
brick.x,
Math.min(ball.x, brick.x + brick.width),
);
const closestY = Math.max(
brick.y,
Math.min(ball.y, brick.y + brick.height),
);
const distanceX = ball.x - closestX;
const distanceY = ball.y - closestY;
if (
distanceX * distanceX + distanceY * distanceY <
ball.radius * ball.radius
) {
const brickCenterX = brick.x + brick.width / 2;
const brickCenterY = brick.y + brick.height / 2;
const normalizedX = Math.abs(ball.x - brickCenterX) / brick.width;
const normalizedY = Math.abs(ball.y - brickCenterY) / brick.height;
if (normalizedX > normalizedY) ball.vx *= -1;
else ball.vy *= -1;
hitBrick(game, brick);
break;
}
}
if (ball.y - ball.radius > HEIGHT) loseLife(game);
};
const draw = (game: Game) => {
context.clearRect(0, 0, WIDTH, HEIGHT);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, WIDTH, HEIGHT);
for (const brick of game.bricks) {
if (!brick.alive) continue;
context.save();
const cropX = brick.image.naturalWidth * 0.07;
const cropY = brick.image.naturalHeight * 0.07;
context.drawImage(
brick.image,
cropX,
cropY,
brick.image.naturalWidth - cropX * 2,
brick.image.naturalHeight - cropY * 2,
brick.x,
brick.y,
brick.width,
brick.height,
);
context.restore();
}
for (const point of game.trail) {
context.beginPath();
context.arc(
point.x,
point.y,
game.ball.radius * point.life * 0.65,
0,
Math.PI * 2,
);
context.fillStyle = `rgba(0,158,209,${point.life * 0.16})`;
context.fill();
}
for (const particle of game.particles) {
context.beginPath();
context.arc(particle.x, particle.y, 3.2 * particle.life, 0, Math.PI * 2);
context.globalAlpha = Math.max(0, particle.life);
context.fillStyle = particle.color;
context.fill();
}
context.globalAlpha = 1;
context.save();
context.shadowColor = "#009ed1";
context.shadowBlur = 14;
context.beginPath();
context.arc(
game.ball.x,
game.ball.y,
game.ball.radius,
0,
Math.PI * 2,
);
context.fillStyle = "#009ed1";
context.fill();
context.restore();
const { paddle } = game;
context.save();
context.shadowColor = "rgba(0,0,0,.14)";
context.shadowBlur = 12;
roundRect(
context,
paddle.x,
paddle.y,
paddle.width,
paddle.height,
8,
);
context.fillStyle = "#f2f2f2";
context.fill();
context.shadowBlur = 0;
context.drawImage(
game.paddleImage,
paddle.x + 25,
paddle.y + 8,
paddle.width - 50,
paddle.height - 16,
);
context.restore();
};
const loop = (time: number) => {
const delta = Math.min(1.65, (time - previousTime) / 16.667);
previousTime = time;
const game = gameRef.current;
if (game) {
update(game, delta);
draw(game);
} else {
context.fillStyle = "#ffffff";
context.fillRect(0, 0, WIDTH, HEIGHT);
}
frame = requestAnimationFrame(loop);
};
frame = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frame);
}, [syncUi, tone]);
const movePaddle = (clientX: number) => {
const game = gameRef.current;
const stage = stageRef.current;
if (!game || !stage) return;
const bounds = stage.getBoundingClientRect();
const x = ((clientX - bounds.left) / bounds.width) * WIDTH;
game.paddle.x = Math.max(
14,
Math.min(WIDTH - game.paddle.width - 14, x - game.paddle.width / 2),
);
};
const overlay = {
loading: {
kicker: "Initializing field",
title: "Loading logos",
copy: "Preparing all 50 signatory nodes.",
action: "Please wait",
},
ready: {
kicker: "",
title: "",
copy: "",
action: "Start",
},
paused: {
kicker: "Field suspended",
title: "Paused",
copy: "The signal is holding. Resume whenever you’re ready.",
action: "Resume",
},
won: {
kicker: "Mission complete",
title: "Field cleared",
copy: "All 50 signatory logos are down. The weights are open.",
action: "Play again",
},
lost: {
kicker: "Signal lost",
title: "Try another run",
copy: `${destroyed} of 50 logos cleared. Reconnect and finish the field.`,
action: "Restart mission",
},
running: null,
}[status];
return (
<section className="game-console" id="game" aria-label="Logo Breakout game">
<div className="console-topbar">
<div className="console-id">Field online</div>
<div className="hud" aria-live="polite">
<div className="hud-item">
<span>Score</span>
<strong>{String(score).padStart(5, "0")}</strong>
</div>
<div className="hud-item">
<span>Lives</span>
<strong>{"●".repeat(lives) || "—"}</strong>
</div>
<div className="hud-item">
<span>Cleared</span>
<strong>{destroyed}/50</strong>
</div>
</div>
<div className="console-actions">
<button
className="icon-button"
type="button"
onClick={() => {
const next = !muted;
setMuted(next);
soundRef.current.muted = next;
}}
aria-label={muted ? "Turn sound on" : "Turn sound off"}
title={muted ? "Sound off" : "Sound on"}
>
{muted ? "×" : "♪"}
</button>
<button
className="restart-button"
type="button"
onClick={restart}
aria-label="Restart game"
>
Restart
</button>
</div>
</div>
<div
className="game-stage"
ref={stageRef}
onPointerMove={(event) => movePaddle(event.clientX)}
onPointerDown={(event) => {
movePaddle(event.clientX);
launch();
}}
>
<canvas
ref={canvasRef}
width={WIDTH}
height={HEIGHT}
aria-label="Breakout playfield containing 50 company logo bricks"
/>
{overlay && (
<div className="game-overlay">
{status === "ready" ? (
<button
className="launch-button start-button"
type="button"
onClick={(event) => {
event.stopPropagation();
launch();
}}
>
Start
</button>
) : (
<div className="overlay-card">
<p className="overlay-kicker">{overlay.kicker}</p>
<h2>{overlay.title}</h2>
<p>{overlay.copy}</p>
<button
className="launch-button"
type="button"
onClick={(event) => {
event.stopPropagation();
if (status === "paused") togglePause();
else launch();
}}
disabled={status === "loading"}
>
{overlay.action}
</button>
</div>
)}
</div>
)}
</div>
<div className="console-bottom">
<div className="control-list" aria-label="Game controls">
<span>
<kbd></kbd>
<kbd></kbd> Move
</span>
<span>
<kbd>Space</kbd> Launch
</span>
<span>
<kbd>P</kbd> Pause
</span>
</div>
<div className="field-counter">Anthropic / Control node</div>
</div>
<div className="sr-only">
The logo bricks are: {LOGOS.map(([name]) => name).join(", ")}.
</div>
</section>
);
}