MiniMax M3
by MiniMaxreasoning default1 console error
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 4 min 28 s
- Cost
- $0.050
- Output tokens
- 41,834
- Spent thinking
- 25,106 tokens
- Speed
- 157.3 tok/s
- Console errors
- 1
- First token after
- 1.6 s
- First visible text after
- 3 min 32 s
- Prompt tokens
- 690
- Code size
- 51.3 KB
- Finish reason
- stop
- Served by
- Minimax
- Model id
minimax/minimax-m3- Reasoning sent
model default- Max output
- 512,000 tokens
- Generated
- Nov 13, 2026
Console errors
Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.
hex.replace is not a function
What it was asked
The prompt
Build a polished Breakout (brick breaker) game in a single HTML file using vanilla JavaScript and a <canvas>. Requirements: - A paddle controlled with the mouse and with the left/right arrow keys. - The bounce angle off the paddle depends on where the ball hits it (center = straight up, edges = sharp angle). - One level with at least 6 rows of bricks, mixing normal bricks and tougher bricks that need 2 hits and visibly crack after the first one. - Some bricks visibly carry a power-up icon; when broken they drop it, and catching it with the paddle gives either a wider paddle (10 seconds) or multi-ball (3 balls). - The ball must never pass through bricks or the paddle. - Particles when a brick breaks. - 3 lives, score displayed. Game over and victory screens with a restart option.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 1891 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Prism Break — Arcade Brick Breaker</title>
<style>
:root {
color-scheme: dark;
--bg: #050912;
--panel: rgba(13, 22, 38, 0.78);
--line: rgba(148, 177, 214, 0.16);
--text: #edf8ff;
--muted: #8293aa;
--cyan: #45e6ff;
--purple: #b88cff;
--orange: #ffb45c;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
}
body {
min-height: 100svh;
display: grid;
place-items: center;
padding: 18px;
overflow-x: hidden;
color: var(--text);
background:
radial-gradient(circle at 12% 5%, rgba(34, 211, 238, 0.10), transparent 31%),
radial-gradient(circle at 88% 92%, rgba(139, 92, 246, 0.13), transparent 32%),
linear-gradient(145deg, #07101d 0%, #040810 60%, #080b16 100%);
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.22;
background-image:
linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, transparent, #000 20%, #000 80%, transparent);
}
button {
font: inherit;
}
.app {
position: relative;
z-index: 1;
width: min(1080px, 100%);
display: grid;
gap: 11px;
}
.topbar {
min-height: 55px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand-mark {
position: relative;
width: 39px;
height: 39px;
flex: 0 0 auto;
border: 1px solid rgba(69, 230, 255, 0.42);
border-radius: 12px;
background:
radial-gradient(circle at 50% 50%, rgba(69,230,255,.22), transparent 58%),
rgba(8, 18, 32, 0.8);
box-shadow: inset 0 0 20px rgba(69,230,255,.08), 0 0 24px rgba(69,230,255,.08);
transform: rotate(-4deg);
}
.brand-mark::before,
.brand-mark::after {
content: "";
position: absolute;
left: 9px;
right: 9px;
height: 5px;
border-radius: 2px;
background: linear-gradient(90deg, var(--cyan), var(--purple));
box-shadow: 0 0 12px rgba(69,230,255,.5);
}
.brand-mark::before { top: 11px; }
.brand-mark::after { bottom: 10px; transform: scaleX(.72); }
.brand-copy h1 {
margin: 0;
font-size: 20px;
line-height: 1;
letter-spacing: .12em;
font-weight: 850;
}
.brand-copy h1 span { color: var(--cyan); }
.brand-copy p {
margin: 6px 0 0;
color: var(--muted);
font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .2em;
}
.stats {
display: flex;
align-items: stretch;
gap: 7px;
}
.stat {
min-width: 90px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(10, 18, 31, .64);
box-shadow: inset 0 1px rgba(255,255,255,.025);
}
.stat-label {
display: block;
margin-bottom: 4px;
color: var(--muted);
font: 800 8px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .17em;
text-transform: uppercase;
}
.stat-value {
display: block;
color: #f5fbff;
font: 800 16px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .04em;
font-variant-numeric: tabular-nums;
}
.score-stat .stat-value { color: var(--cyan); }
.lives {
display: flex;
align-items: center;
gap: 5px;
height: 16px;
}
.life {
width: 10px;
height: 10px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #65eaff 36%, #2375e8 72%);
box-shadow: 0 0 10px rgba(69,230,255,.75), inset 0 0 3px #fff;
transition: .2s ease;
}
.life.dead {
background: #263244;
box-shadow: none;
opacity: .45;
}
.level-stat .stat-value small {
color: var(--muted);
font-size: 8px;
letter-spacing: .1em;
}
.sound-button {
width: 37px;
height: 37px;
align-self: center;
border: 1px solid var(--line);
border-radius: 11px;
color: var(--muted);
background: rgba(10, 18, 31, .64);
cursor: pointer;
font-size: 17px;
transition: .18s ease;
}
.sound-button:hover {
color: var(--cyan);
border-color: rgba(69,230,255,.4);
transform: translateY(-1px);
}
.stage {
position: relative;
width: 100%;
aspect-ratio: 5 / 3;
overflow: hidden;
border: 1px solid rgba(120, 169, 220, .22);
border-radius: 20px;
background: #07101d;
box-shadow:
0 30px 80px rgba(0,0,0,.45),
0 0 0 1px rgba(255,255,255,.025) inset,
0 0 55px rgba(37, 142, 219, .055);
isolation: isolate;
}
canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
touch-action: none;
cursor: crosshair;
}
.attract-hint {
position: absolute;
z-index: 5;
top: 12px;
left: 50%;
width: max-content;
max-width: calc(100% - 24px);
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 11px;
padding: 8px 9px 8px 12px;
border: 1px solid rgba(117, 202, 255, .24);
border-radius: 12px;
background: rgba(7, 15, 28, .84);
box-shadow: 0 10px 35px rgba(0,0,0,.34), inset 0 1px rgba(255,255,255,.035);
backdrop-filter: blur(10px);
pointer-events: none;
transition: opacity .25s ease, transform .25s ease;
}
.attract-hint.hidden {
opacity: 0;
transform: translate(-50%, -12px);
pointer-events: none;
}
.demo-dot {
width: 9px;
height: 9px;
flex: 0 0 auto;
border-radius: 50%;
background: #70ffb5;
box-shadow: 0 0 0 5px rgba(112,255,181,.08), 0 0 13px rgba(112,255,181,.8);
animation: demoPulse 1.4s ease-in-out infinite;
}
@keyframes demoPulse {
50% { opacity: .45; transform: scale(.8); }
}
.attract-copy {
display: grid;
gap: 3px;
}
.attract-copy span {
color: #70ffb5;
font: 800 7px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .19em;
}
.attract-copy strong {
color: #f1f9ff;
font: 850 10px/1.1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .08em;
}
.take-button,
.restart-button {
border: 0;
color: #061019;
background: linear-gradient(135deg, #b9f7ff, #45e6ff 55%, #7ba7ff);
box-shadow: 0 7px 20px rgba(69,230,255,.18);
cursor: pointer;
font-weight: 900;
letter-spacing: .09em;
transition: transform .16s ease, filter .16s ease;
}
.take-button {
margin-left: 3px;
padding: 9px 11px;
border-radius: 8px;
font-size: 8px;
pointer-events: auto;
}
.take-button:hover,
.restart-button:hover {
transform: translateY(-1px);
filter: brightness(1.08);
}
button:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 3px;
}
.event-toast {
position: absolute;
z-index: 6;
top: 17%;
left: 50%;
padding: 8px 13px;
transform: translate(-50%, -8px);
opacity: 0;
border: 1px solid rgba(69,230,255,.28);
border-radius: 999px;
color: #eafcff;
background: rgba(8,17,31,.88);
box-shadow: 0 8px 30px rgba(0,0,0,.35), 0 0 25px rgba(69,230,255,.08);
font: 850 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .1em;
pointer-events: none;
transition: opacity .18s ease, transform .18s ease;
}
.event-toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
.end-overlay {
position: absolute;
z-index: 10;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
opacity: 0;
visibility: hidden;
pointer-events: none;
background: rgba(3, 8, 16, .57);
backdrop-filter: blur(5px);
transition: opacity .25s ease, visibility .25s ease;
}
.end-overlay.show {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.end-card {
width: min(420px, 95%);
padding: 27px 29px;
text-align: center;
border: 1px solid rgba(137, 190, 238, .25);
border-radius: 21px;
background:
radial-gradient(circle at 50% 0%, rgba(69,230,255,.10), transparent 44%),
rgba(10, 18, 32, .94);
box-shadow: 0 24px 80px rgba(0,0,0,.55), inset 0 1px rgba(255,255,255,.04);
transform: translateY(8px) scale(.98);
transition: transform .25s ease;
}
.end-overlay.show .end-card { transform: none; }
.end-icon {
width: 54px;
height: 54px;
display: grid;
place-items: center;
margin: 0 auto 14px;
border: 1px solid rgba(69,230,255,.34);
border-radius: 17px;
color: var(--cyan);
background: rgba(69,230,255,.08);
box-shadow: 0 0 30px rgba(69,230,255,.13);
font-size: 28px;
font-weight: 900;
}
.end-kicker {
margin: 0 0 7px;
color: var(--cyan);
font: 850 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .2em;
}
.end-card h2 {
margin: 0;
font-size: clamp(26px, 4vw, 38px);
letter-spacing: -.035em;
}
.end-card > p:not(.end-kicker) {
margin: 10px auto 18px;
max-width: 310px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.final-score {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 10px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 11px;
background: rgba(3,8,16,.42);
color: var(--muted);
font: 800 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .12em;
}
.final-score strong {
color: #fff;
font-size: 17px;
letter-spacing: .03em;
}
.best-line {
margin: 0 0 16px;
color: #6f8299;
font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .08em;
}
.restart-button {
width: 100%;
padding: 13px 18px;
border-radius: 11px;
font-size: 11px;
}
.underbar {
min-height: 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
color: #718399;
font: 750 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: .05em;
}
.control-group,
.legend {
display: flex;
align-items: center;
gap: 10px;
}
kbd {
display: inline-grid;
place-items: center;
min-width: 20px;
height: 19px;
margin-right: 4px;
padding: 0 5px;
border: 1px solid rgba(151,177,208,.24);
border-bottom-color: rgba(151,177,208,.42);
border-radius: 5px;
color: #b9c8d8;
background: rgba(16,27,45,.7);
box-shadow: 0 2px 0 rgba(0,0,0,.22);
font: inherit;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.legend-item small {
color: #53657c;
font-size: 8px;
}
.power-glyph {
width: 17px;
height: 17px;
display: inline-grid;
place-items: center;
border-radius: 5px;
font-size: 11px;
font-weight: 900;
}
.power-glyph.wide {
color: var(--cyan);
background: rgba(69,230,255,.10);
box-shadow: inset 0 0 0 1px rgba(69,230,255,.2);
}
.power-glyph.multi {
color: var(--purple);
background: rgba(184,140,255,.10);
box-shadow: inset 0 0 0 1px rgba(184,140,255,.2);
}
.wide-readout {
min-width: 122px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 7px;
color: #64758b;
}
.wide-readout.active { color: var(--cyan); }
.meter {
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(130,156,190,.13);
}
.meter i {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #45e6ff, #b88cff);
box-shadow: 0 0 9px rgba(69,230,255,.6);
}
.wide-readout strong {
min-width: 29px;
text-align: right;
font-size: 8px;
}
@media (max-width: 700px) {
body { padding: 8px; }
.app { gap: 8px; }
.topbar { min-height: 48px; gap: 8px; }
.brand { gap: 8px; }
.brand-mark { width: 32px; height: 32px; border-radius: 9px; }
.brand-mark::before { top: 9px; left: 7px; right: 7px; }
.brand-mark::after { bottom: 8px; left: 7px; right: 7px; }
.brand-copy h1 { font-size: 15px; }
.brand-copy p { display: none; }
.stats { gap: 5px; }
.stat { min-width: 0; padding: 7px 8px; border-radius: 9px; }
.stat-label { margin-bottom: 3px; font-size: 7px; }
.stat-value { font-size: 13px; }
.lives { gap: 4px; }
.life { width: 8px; height: 8px; }
.level-stat, .sound-button { display: none; }
.stage { border-radius: 14px; }
.attract-hint { top: 8px; gap: 7px; padding: 7px 7px 7px 9px; border-radius: 10px; }
.attract-copy span { font-size: 6px; }
.attract-copy strong { font-size: 8px; }
.take-button { padding: 8px; font-size: 7px; }
.underbar { min-height: 24px; font-size: 8px; }
.legend .legend-item:nth-child(2), .wide-readout { display: none; }
.event-toast { top: 15%; font-size: 8px; }
}
@media (max-width: 390px) {
.brand-copy h1 { font-size: 13px; }
.brand-mark { width: 29px; height: 29px; }
.stat { padding: 6px; }
.stat-label { display: none; }
.attract-hint { width: calc(100% - 16px); }
.attract-copy { min-width: 0; }
.attract-copy strong { font-size: 7px; }
.take-button { margin-left: auto; }
.legend { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true"></div>
<div class="brand-copy">
<h1>PRISM <span>BREAK</span></h1>
<p>NEON ARCADE // SECTOR 01</p>
</div>
</div>
<div class="stats">
<div class="stat score-stat">
<span class="stat-label">Score</span>
<strong class="stat-value" id="scoreValue">000000</strong>
</div>
<div class="stat">
<span class="stat-label">Lives</span>
<div class="lives" aria-label="3 lives remaining">
<span class="life"></span><span class="life"></span><span class="life"></span>
</div>
</div>
<div class="stat level-stat">
<span class="stat-label">Sector</span>
<strong class="stat-value">01 <small>PRISM</small></strong>
</div>
<button class="sound-button" id="soundButton" type="button" aria-label="Toggle sound" aria-pressed="false" title="Toggle sound">♪</button>
</div>
</header>
<main class="stage">
<canvas id="gameCanvas" width="1000" height="600" tabindex="0" aria-label="Prism Break brick breaker game"></canvas>
<div class="attract-hint" id="attractHint">
<span class="demo-dot" aria-hidden="true"></span>
<div class="attract-copy">
<span>AI DEMO RUNNING</span>
<strong>CLICK OR PRESS A KEY TO START</strong>
</div>
<button class="take-button" id="startButton" type="button">TAKE CONTROL</button>
</div>
<div class="event-toast" id="eventToast" role="status" aria-live="polite"></div>
<div class="end-overlay" id="endOverlay" aria-hidden="true">
<div class="end-card">
<div class="end-icon" id="endIcon">✦</div>
<p class="end-kicker" id="endKicker">VICTORY // SECTOR 01</p>
<h2 id="endTitle">Prism shattered.</h2>
<p id="endCopy">Every brick is down. The vault is yours.</p>
<div class="final-score">
<span>FINAL SCORE</span>
<strong id="finalScore">000000</strong>
</div>
<div class="best-line" id="bestLine">SESSION BEST // 000000</div>
<button class="restart-button" id="restartButton" type="button">PLAY AGAIN ↻</button>
</div>
</div>
</main>
<footer class="underbar">
<div class="control-group">
<span>MOVE <kbd>←</kbd><kbd>→</kbd> <span style="color:#52647b">OR MOUSE</span></span>
<span style="color:#405168">/</span>
<span>SPACE LAUNCH</span>
</div>
<div class="legend">
<span class="legend-item"><i class="power-glyph wide">↔</i> WIDE PADDLE <small>10S</small></span>
<span class="legend-item"><i class="power-glyph multi">•••</i> MULTI-BALL <small>×3</small></span>
</div>
<div class="wide-readout" id="wideReadout">
<span>WIDE</span>
<div class="meter"><i id="wideMeter"></i></div>
<strong id="wideTime">—</strong>
</div>
</footer>
</div>
<script>
(() => {
"use strict";
const W = 1000;
const H = 600;
const TAU = Math.PI * 2;
const STEP = 1 / 120;
const $ = id => document.getElementById(id);
const canvas = $("gameCanvas");
const ctx = canvas.getContext("2d");
const scoreValue = $("scoreValue");
const lifeElements = Array.from(document.querySelectorAll(".life"));
const attractHint = $("attractHint");
const eventToast = $("eventToast");
const endOverlay = $("endOverlay");
const endIcon = $("endIcon");
const endKicker = $("endKicker");
const endTitle = $("endTitle");
const endCopy = $("endCopy");
const finalScore = $("finalScore");
const bestLine = $("bestLine");
const wideReadout = $("wideReadout");
const wideMeter = $("wideMeter");
const wideTime = $("wideTime");
const soundButton = $("soundButton");
const rowPalette = [
{ accent: "#36d9ff" },
{ accent: "#a985ff" },
{ accent: "#478cff" },
{ accent: "#ff6fae" },
{ accent: "#35e5d2" },
{ accent: "#ffad59" },
{ accent: "#8e7dff" }
];
const powerAssignments = {
"0,1": "wide",
"1,6": "multi",
"2,5": "wide",
"3,3": "multi",
"4,2": "wide",
"5,0": "multi",
"6,7": "multi"
};
const stars = Array.from({ length: 72 }, () => ({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 1.35 + .25,
phase: Math.random() * TAU
}));
let state = "attract";
let score = 0;
let best = 0;
let lives = 3;
let clock = 0;
let combo = 0;
let comboTimer = 0;
let wideUntil = 0;
let serveTimer = 0;
let attractResetTimer = 0;
let shake = 0;
let toastUntil = 0;
let bricks = [];
let balls = [];
let particles = [];
let powerups = [];
let floaters = [];
let ripples = [];
const paddle = {
x: W / 2,
y: 548,
w: 116,
h: 14,
targetX: W / 2
};
const keys = { left: false, right: false };
let muted = false;
let audioContext = null;
let lastTime = performance.now();
let accumulator = 0;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
const random = (min, max) => min + Math.random() * (max - min);
const moveToward = (from, to, amount) => {
if (Math.abs(to - from) <= amount) return to;
return from + Math.sign(to - from) * amount;
};
function hexToRgb(hex) {
const value = hex.replace("#", "");
const n = parseInt(value.length === 3
? value.split("").map(c => c + c).join("")
: value, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function shade(hex, amount) {
const c = hexToRgb(hex);
return `rgb(${clamp(c.r + amount, 0, 255)},${clamp(c.g + amount, 0, 255)},${clamp(c.b + amount, 0, 255)})`;
}
function rgba(hex, alpha) {
const c = hexToRgb(hex);
return `rgba(${c.r},${c.g},${c.b},${alpha})`;
}
function roundRect(c, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
}
if ("ResizeObserver" in window) {
new ResizeObserver(resizeCanvas).observe(canvas);
}
window.addEventListener("resize", resizeCanvas);
function makeBall(x, y, vx = 0, vy = 0) {
return { x, y, vx, vy, r: 7, trail: [] };
}
function createLevel() {
bricks = [];
for (let row = 0; row < 7; row++) {
for (let col = 0; col < 8; col++) {
const tough = row === 1 || row === 4 ||
((row === 3 || row === 5) && (col === 2 || col === 6));
const hp = tough ? 2 : 1;
bricks.push({
x: 116 + col * 97,
y: 92 + row * 33,
w: 88,
h: 24,
row,
col,
hp,
maxHp: hp,
tough,
crack: false,
alive: true,
flash: 0,
accent: rowPalette[row].accent,
power: powerAssignments[`${row},${col}`] || null
});
}
}
}
function initAudio() {
try {
const AudioCtor = window.AudioContext || window.webkitAudioContext;
if (!AudioCtor) return;
if (!audioContext) audioContext = new AudioCtor();
if (audioContext.state === "suspended") audioContext.resume().catch(() => {});
} catch (_) {
audioContext = null;
}
}
function tone(frequency, duration = .08, type = "sine", volume = .025) {
if (muted || !audioContext) return;
try {
const now = audioContext.currentTime;
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, now);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(45, frequency * .72), now + duration);
gain.gain.setValueAtTime(.0001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + .008);
gain.gain.exponentialRampToValueAtTime(.0001, now + duration);
oscillator.connect(gain);
gain.connect(audioContext.destination);
oscillator.start(now);
oscillator.stop(now + duration + .02);
} catch (_) {}
}
function showToast(text, duration = 1.7) {
eventToast.textContent = text;
eventToast.classList.add("show");
toastUntil = clock + duration;
}
function updateHUD() {
scoreValue.textContent = String(score).padStart(6, "0");
lifeElements.forEach((el, index) => el.classList.toggle("dead", index >= lives));
const remaining = Math.max(0, wideUntil - clock);
wideReadout.classList.toggle("active", remaining > 0);
wideMeter.style.width = `${remaining > 0 ? Math.min(100, remaining * 10) : 0}%`;
wideTime.textContent = remaining > 0 ? `${remaining.toFixed(1)}s` : "—";
}
function clearEffects() {
particles = [];
powerups = [];
floaters = [];
ripples = [];
shake = 0;
}
function resetAttract() {
createLevel();
clearEffects();
score = 0;
lives = 3;
combo = 0;
comboTimer = 0;
wideUntil = 0;
paddle.w = 116;
paddle.x = paddle.targetX = W / 2;
const angle = random(-.16, .16);
const speed = 365;
balls = [makeBall(W / 2, paddle.y - 28, Math.sin(angle) * speed, -Math.cos(angle) * speed)];
state = "attract";
attractResetTimer = 1.5;
attractHint.classList.remove("hidden");
attractHint.setAttribute("aria-hidden", "false");
endOverlay.classList.remove("show");
endOverlay.setAttribute("aria-hidden", "true");
updateHUD();
}
function startGame(pointerX = null) {
initAudio();
createLevel();
clearEffects();
score = 0;
lives = 3;
combo = 0;
comboTimer = 0;
wideUntil = 0;
serveTimer = .82;
paddle.w = 116;
paddle.x = paddle.targetX = W / 2;
if (pointerX !== null) {
paddle.x = paddle.targetX = clamp(pointerX, 76, W - 76);
}
balls = [makeBall(paddle.x, paddle.y - 21, 0, 0)];
state = "serve";
attractHint.classList.add("hidden");
attractHint.setAttribute("aria-hidden", "true");
endOverlay.classList.remove("show");
endOverlay.setAttribute("aria-hidden", "true");
showToast("SECTOR 01 // BREAK THE GRID", 1.45);
tone(420, .11, "triangle", .028);
updateHUD();
}
function launchServe() {
if (state !== "serve" || !balls.length) return;
const ball = balls[0];
const angle = random(-.14, .14);
const speed = 395;
ball.x = paddle.x;
ball.y = paddle.y - paddle.h / 2 - ball.r - 1;
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.cos(angle) * speed;
ball.trail.length = 0;
state = "playing";
tone(510, .07, "triangle", .022);
}
function endGame(victory) {
state = victory ? "victory" : "gameover";
best = Math.max(best, score);
finalScore.textContent = String(score).padStart(6, "0");
bestLine.textContent = `SESSION BEST // ${String(best).padStart(6, "0")}`;
if (victory) {
endIcon.textContent = "✦";
endKicker.textContent = "VICTORY // SECTOR 01";
endTitle.textContent = "Prism shattered.";
endCopy.textContent = "Every brick is down. The vault is yours — beautifully done.";
for (let i = 0; i < 5; i++) {
spawnParticles(random(120, 880), random(80, 340), i % 2 ? "#45e6ff" : "#b88cff", 18, 1.5);
}
tone(760, .22, "triangle", .035);
} else {
endIcon.textContent = "×";
endKicker.textContent = "GAME OVER // SECTOR 01";
endTitle.textContent = "Signal lost.";
endCopy.textContent = "The last ball slipped through. Reset the grid and make the next run count.";
tone(145, .28, "sawtooth", .022);
}
endOverlay.classList.add("show");
endOverlay.setAttribute("aria-hidden", "false");
updateHUD();
}
function updatePaddleWidth(dt) {
const targetWidth = clock < wideUntil ? 168 : 116;
paddle.w += (targetWidth - paddle.w) * Math.min(1, dt * 11);
clampPaddle();
}
function clampPaddle() {
const half = paddle.w / 2;
const min = half + 18;
const max = W - half - 18;
paddle.x = clamp(paddle.x, min, max);
paddle.targetX = clamp(paddle.targetX, min, max);
}
function updatePlayerPaddle(dt) {
const direction = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
if (direction) {
paddle.targetX += direction * 620 * dt;
clampPaddle();
}
paddle.x = moveToward(paddle.x, paddle.targetX, 820 * dt);
clampPaddle();
}
function updateAIPaddle(dt) {
let target = W / 2 + Math.sin(clock * .65) * 55;
const descending = balls.find(ball => ball.vy > 0);
if (descending) {
const timeToPaddle = (paddle.y - descending.y) / descending.vy;
if (timeToPaddle > 0 && timeToPaddle < 3.5) {
target = descending.x + descending.vx * timeToPaddle;
} else {
target = descending.x;
}
}
target = clamp(target, 82, W - 82);
paddle.x = moveToward(paddle.x, target, 680 * dt);
paddle.targetX = paddle.x;
}
function addFloater(x, y, text, color, size = 12) {
floaters.push({ x, y, text, color, size, life: .85, maxLife: .85 });
}
function spawnParticles(x, y, color, count = 14, force = 1) {
for (let i = 0; i < count; i++) {
const angle = random(0, TAU);
const speed = random(45, 220) * force;
const life = random(.32, .72);
particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 35,
size: random(2, 5.5),
life,
maxLife: life,
rotation: random(0, TAU),
spin: random(-7, 7),
color: i % 4 === 0 ? "#ffffff" : color
});
}
}
function addRipple(x, y, color) {
ripples.push({ x, y, color, radius: 5, life: .34, maxLife: .34 });
}
function normalizeBall(ball) {
let speed = Math.hypot(ball.vx, ball.vy);
if (!speed) return;
speed = clamp(speed, 380, 520);
ball.vx = ball.vx / Math.hypot(ball.vx, ball.vy) * speed;
ball.vy = ball.vy / Math.hypot(ball.vx, ball.vy) * speed;
const minimumVertical = speed * .24;
if (Math.abs(ball.vy) < minimumVertical) {
ball.vy = (ball.vy < 0 ? -1 : 1) * minimumVertical;
const changedSpeed = Math.hypot(ball.vx, ball.vy);
ball.vx *= speed / changedSpeed;
ball.vy *= speed / changedSpeed;
}
}
function circleRectCollision(ball, rect) {
const nearestX = clamp(ball.x, rect.x, rect.x + rect.w);
const nearestY = clamp(ball.y, rect.y, rect.y + rect.h);
const dx = ball.x - nearestX;
const dy = ball.y - nearestY;
const distanceSquared = dx * dx + dy * dy;
if (distanceSquared >= ball.r * ball.r) return null;
if (distanceSquared > .000001) {
const distance = Math.sqrt(distanceSquared);
return { nx: dx / distance, ny: dy / distance, penetration: ball.r - distance };
}
const left = ball.x - rect.x;
const right = rect.x + rect.w - ball.x;
const top = ball.y - rect.y;
const bottom = rect.y + rect.h - ball.y;
const minimum = Math.min(left, right, top, bottom);
if (minimum === top) return { nx: 0, ny: -1, penetration: ball.r + top };
if (minimum === bottom) return { nx: 0, ny: 1, penetration: ball.r + bottom };
if (minimum === left) return { nx: -1, ny: 0, penetration: ball.r + left };
return { nx: 1, ny: 0, penetration: ball.r + right };
}
function bounceFromPaddle(ball, hitX) {
const half = paddle.w / 2;
const impact = clamp((hitX - paddle.x) / half, -1, 1);
const angle = impact * 1.08;
const speed = clamp(Math.hypot(ball.vx, ball.vy), 385, 510);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.cos(angle) * speed;
ball.x = hitX;
ball.y = paddle.y - paddle.h / 2 - ball.r - .2;
spawnParticles(hitX, paddle.y - 5, impact < 0 ? "#45e6ff" : "#b88cff", 4, .48);
tone(230 + Math.abs(impact) * 170, .045, "sine", .012);
}
function collideWithPaddle(ball, oldX, oldY) {
const half = paddle.w / 2;
const top = paddle.y - paddle.h / 2;
const bottom = paddle.y + paddle.h / 2;
// Swept top-edge test prevents tunneling through the paddle.
if (ball.vy > 0 && oldY + ball.r <= top + 1.5 && ball.y + ball.r >= top) {
const travel = ball.y - oldY;
const t = travel > 0 ? clamp((top - oldY - ball.r) / travel, 0, 1) : 0;
const hitX = oldX + (ball.x - oldX) * t;
if (hitX >= paddle.x - half - ball.r && hitX <= paddle.x + half + ball.r) {
bounceFromPaddle(ball, hitX);
return true;
}
}
const nearestX = clamp(ball.x, paddle.x - half, paddle.x + half);
const nearestY = clamp(ball.y, top, bottom);
const dx = ball.x - nearestX;
const dy = ball.y - nearestY;
if (dx * dx + dy * dy < ball.r * ball.r) {
bounceFromPaddle(ball, ball.x);
return true;
}
return false;
}
function damageBrick(brick) {
if (!brick.alive) return;
brick.hp -= 1;
brick.flash = .13;
if (comboTimer > 0) combo = Math.min(5, combo + 1);
else combo = 1;
comboTimer = 1.4;
if (brick.hp > 0) {
brick.crack = true;
score += 25 * combo;
spawnParticles(brick.x + brick.w / 2, brick.y + brick.h / 2, brick.accent, 7, .65);
addFloater(brick.x + brick.w / 2, brick.y, "CRACKED", brick.accent, 10);
tone(125, .06, "square", .012);
return;
}
brick.alive = false;
const base = brick.tough ? 140 : 70;
score += base * combo;
shake = Math.max(shake, brick.tough ? 5.5 : 3.4);
spawnParticles(brick.x + brick.w / 2, brick.y + brick.h / 2, brick.accent, brick.tough ? 22 : 16, 1);
addRipple(brick.x + brick.w / 2, brick.y + brick.h / 2, brick.accent);
addFloater(brick.x + brick.w / 2, brick.y, `+${base * combo}`, brick.accent, 12);
tone(brick.tough ? 310 : 440, .085, "triangle", .024);
if (brick.power) {
powerups.push({
x: brick.x + brick.w / 2,
y: brick.y + brick.h + 3,
type: brick.power,
rotation: 0,
vy: 0
});
}
}
function collideWithBricks(ball) {
for (const brick of bricks) {
if (!brick.alive) continue;
if (ball.x + ball.r < brick.x || ball.x - ball.r > brick.x + brick.w ||
ball.y + ball.r < brick.y || ball.y - ball.r > brick.y + brick.h) continue;
const hit = circleRectCollision(ball, brick);
if (!hit) continue;
ball.x += hit.nx * (hit.penetration + .03);
ball.y += hit.ny * (hit.penetration + .03);
const dot = ball.vx * hit.nx + ball.vy * hit.ny;
if (dot < 0) {
ball.vx -= 2 * dot * hit.nx;
ball.vy -= 2 * dot * hit.ny;
}
normalizeBall(ball);
damageBrick(brick);
return true;
}
return false;
}
function updateBalls(dt) {
for (let index = balls.length - 1; index >= 0; index--) {
const ball = balls[index];
if (!ball) continue;
const oldX = ball.x;
const oldY = ball.y;
ball.trail.push({ x: ball.x, y: ball.y });
if (ball.trail.length > 10) ball.trail.shift();
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - ball.r < 22) {
ball.x = 22 + ball.r;
ball.vx = Math.abs(ball.vx);
spawnParticles(ball.x, ball.y, "#45e6ff", 3, .35);
tone(190, .035, "sine", .008);
} else if (ball.x + ball.r > W - 22) {
ball.x = W - 22 - ball.r;
ball.vx = -Math.abs(ball.vx);
spawnParticles(ball.x, ball.y, "#45e6ff", 3, .35);
tone(190, .035, "sine", .008);
}
if (ball.y - ball.r < 42) {
ball.y = 42 + ball.r;
ball.vy = Math.abs(ball.vy);
spawnParticles(ball.x, ball.y, "#b88cff", 3, .35);
}
collideWithPaddle(ball, oldX, oldY);
collideWithBricks(ball);
if (ball.y - ball.r > H + 28) {
balls.splice(index, 1);
tone(105, .11, "triangle", .014);
}
}
}
function activateMultiBall() {
if (!balls.length) return;
const base = balls[0];
const speed = 410;
while (balls.length < 3) balls.push(makeBall(base.x, base.y, 0, 0));
if (balls.length > 3) balls.length = 3;
for (let index = 0; index < 3; index++) {
const ball = balls[index];
const angle = (index - 1) * .43;
ball.x = base.x + (index - 1) * 11;
ball.y = base.y;
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.cos(angle) * speed;
ball.trail.length = 0;
}
}
function applyPower(powerup) {
if (powerup.type === "wide") {
wideUntil = clock + 10;
showToast("WIDE PADDLE // 10 SECONDS", 1.55);
tone(610, .13, "triangle", .03);
} else {
activateMultiBall();
showToast("MULTI-BALL // 3 BALLS", 1.55);
tone(720, .13, "triangle", .03);
}
}
function updatePowerups(dt) {
for (let index = powerups.length - 1; index >= 0; index--) {
const powerup = powerups[index];
powerup.y += (138 + powerup.vy) * dt;
powerup.vy += 24 * dt;
powerup.rotation += dt * 2.2;
const half = paddle.w / 2;
const paddleTop = paddle.y - paddle.h / 2;
const paddleBottom = paddle.y + paddle.h / 2;
const caught =
powerup.y + 10 >= paddleTop &&
powerup.y - 10 <= paddleBottom + 10 &&
powerup.x >= paddle.x - half - 8 &&
powerup.x <= paddle.x + half + 8;
if (caught) {
applyPower(powerup);
powerups.splice(index, 1);
} else if (powerup.y > H + 30) {
powerups.splice(index, 1);
}
}
}
function updateEffects(dt) {
for (let index = particles.length - 1; index >= 0; index--) {
const particle = particles[index];
particle.life -= dt;
particle.x += particle.vx * dt;
particle.y += particle.vy * dt;
particle.vy += 390 * dt;
particle.rotation += particle.spin * dt;
if (particle.life <= 0) particles.splice(index, 1);
}
for (let index = floaters.length - 1; index >= 0; index--) {
const floater = floaters[index];
floater.life -= dt;
floater.y -= 27 * dt;
if (floater.life <= 0) floaters.splice(index, 1);
}
for (let index = ripples.length - 1; index >= 0; index--) {
const ripple = ripples[index];
ripple.life -= dt;
ripple.radius += 95 * dt;
if (ripple.life <= 0) ripples.splice(index, 1);
}
for (const brick of bricks) {
if (brick.flash > 0) brick.flash = Math.max(0, brick.flash - dt);
}
shake = Math.max(0, shake - dt * 18);
if (comboTimer > 0) {
comboTimer -= dt;
if (comboTimer <= 0) combo = 0;
}
}
function update(dt) {
clock += dt;
updatePaddleWidth(dt);
if (clock > toastUntil) eventToast.classList.remove("show");
if (state === "serve") {
updatePlayerPaddle(dt);
if (balls[0]) {
balls[0].x = paddle.x;
balls[0].y = paddle.y - paddle.h / 2 - 18;
}
serveTimer -= dt;
if (serveTimer <= 0) launchServe();
updateEffects(dt);
updateHUD();
return;
}
if (state === "playing" || state === "attract") {
if (state === "attract") updateAIPaddle(dt);
else updatePlayerPaddle(dt);
updateBalls(dt);
updatePowerups(dt);
updateEffects(dt);
const cleared = bricks.every(brick => !brick.alive);
if (state === "attract") {
if (cleared) {
attractResetTimer -= dt;
if (attractResetTimer <= 0) {
resetAttract();
return;
}
} else {
attractResetTimer = 1.5;
}
if (!balls.length) {
resetAttract();
return;
}
} else {
if (cleared) {
endGame(true);
updateHUD();
return;
}
if (!balls.length) {
lives -= 1;
updateHUD();
if (lives <= 0) {
endGame(false);
return;
}
state = "serve";
serveTimer = .9;
balls = [makeBall(paddle.x, paddle.y - 21, 0, 0)];
showToast(lives === 1 ? "FINAL BALL // MAKE IT COUNT" : `BALL LOST // ${lives} LIVES LEFT`, 1.5);
tone(155, .17, "triangle", .02);
}
}
} else {
updateEffects(dt);
}
updateHUD();
}
function drawBackground() {
const background = ctx.createLinearGradient(0, 0, 0, H);
background.addColorStop(0, "#081522");
background.addColorStop(.48, "#07101d");
background.addColorStop(1, "#050a13");
ctx.fillStyle = background;
ctx.fillRect(0, 0, W, H);
const glow = ctx.createRadialGradient(W / 2, 175, 20, W / 2, 175, 470);
glow.addColorStop(0, "rgba(46,154,220,.105)");
glow.addColorStop(.55, "rgba(26,82,132,.035)");
glow.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.strokeStyle = "rgba(118,166,220,.045)";
ctx.lineWidth = 1;
for (let x = 20; x < W; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, H);
ctx.stroke();
}
for (let y = 12; y < H; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
}
for (const star of stars) {
const twinkle = .35 + .28 * Math.sin(clock * 1.1 + star.phase);
ctx.fillStyle = `rgba(171,219,255,${twinkle})`;
ctx.beginPath();
ctx.arc(star.x, star.y, star.r, 0, TAU);
ctx.fill();
}
ctx.strokeStyle = "rgba(130,181,226,.16)";
ctx.lineWidth = 1;
roundRect(ctx, 14, 48, W - 28, H - 62, 18);
ctx.stroke();
ctx.font = "800 9px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
ctx.textAlign = "left";
ctx.fillStyle = "rgba(137,174,211,.56)";
ctx.fillText("PRISM VAULT", 28, 27);
ctx.textAlign = "right";
ctx.fillText("56 CELLS / 7 LAYERS", W - 28, 27);
ctx.restore();
}
function drawPowerIcon(c, x, y, type, scale = 1) {
c.save();
c.translate(x, y);
c.scale(scale, scale);
c.fillStyle = "rgba(2,9,18,.46)";
c.beginPath();
c.arc(0, 0, 8, 0, TAU);
c.fill();
c.strokeStyle = "rgba(255,255,255,.95)";
c.fillStyle = "rgba(255,255,255,.95)";
c.lineWidth = 1.4;
c.lineCap = "round";
c.lineJoin = "round";
if (type === "wide") {
c.beginPath();
c.moveTo(-4, 0);
c.lineTo(4, 0);
c.moveTo(-4, 0);
c.lineTo(-1.5, -2.5);
c.moveTo(-4, 0);
c.lineTo(-1.5, 2.5);
c.moveTo(4, 0);
c.lineTo(1.5, -2.5);
c.moveTo(4, 0);
c.lineTo(1.5, 2.5);
c.stroke();
} else {
c.beginPath();
c.arc(-3, 2.3, 1.55, 0, TAU);
c.fill();
c.beginPath();
c.arc(3, 2.3, 1.55, 0, TAU);
c.fill();
c.beginPath();
c.arc(0, -2.5, 1.55, 0, TAU);
c.fill();
}
c.restore();
}
function drawBricks() {
for (const brick of bricks) {
if (!brick.alive) continue;
ctx.save();
const x = brick.x;
const y = brick.y;
const w = brick.w;
const h = brick.h;
const gradient = ctx.createLinearGradient(x, y, x, y + h);
if (brick.tough && !brick.crack) {
gradient.addColorStop(0, "#35445a");
gradient.addColorStop(.23, shade(brick.accent, -27));
gradient.addColorStop(1, "#111b2b");
} else if (brick.crack) {
gradient.addColorStop(0, shade(brick.accent, -24));
gradient.addColorStop(1, "#121b2a");
} else {
gradient.addColorStop(0, shade(brick.accent, 34));
gradient.addColorStop(.42, shade(brick.accent, -2));
gradient.addColorStop(1, shade(brick.accent, -42));
}
ctx.shadowColor = rgba(brick.accent, brick.crack ? .12 : .30);
ctx.shadowBlur = brick.tough ? 8 : 11;
ctx.fillStyle = gradient;
roundRect(ctx, x, y, w, h, 6);
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = brick.tough
? rgba(brick.accent, brick.crack ? .38 : .68)
: rgba(255,255,255,.24);
ctx.lineWidth = brick.tough ? 1.4 : 1;
ctx.stroke();
ctx.strokeStyle = "rgba(255,255,255,.18)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x + 7, y + 2.5);
ctx.lineTo(x + w - 7, y + 2.5);
ctx.stroke();
if (brick.tough) {
ctx.fillStyle = rgba(brick.accent, .75);
roundRect(ctx, x + 3, y + 4, 3, h - 8, 1.5);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,.48)";
ctx.beginPath();
ctx.arc(x + w - 8, y + 7, 1.25, 0, TAU);
ctx.arc(x + w - 8, y + h - 7, 1.25, 0, TAU);
ctx.fill();
if (!brick.crack) {
ctx.fillStyle = rgba(brick.accent, .72);
ctx.beginPath();
ctx.arc(x + w - 8, y + h / 2, 1.4, 0, TAU);
ctx.fill();
}
}
if (brick.crack) {
ctx.save();
ctx.shadowColor = "rgba(0,0,0,.75)";
ctx.shadowBlur = 3;
ctx.strokeStyle = "rgba(2,7,14,.92)";
ctx.lineWidth = 3.1;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x + w * .54, y - 1);
ctx.lineTo(x + w * .43, y + h * .28);
ctx.lineTo(x + w * .58, y + h * .47);
ctx.lineTo(x + w * .38, y + h * .73);
ctx.lineTo(x + w * .46, y + h + 1);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(236,250,255,.65)";
ctx.lineWidth = 1.15;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + w * .58, y + h * .47);
ctx.lineTo(x + w * .72, y + h * .34);
ctx.moveTo(x + w * .43, y + h * .28);
ctx.lineTo(x + w * .30, y + h * .18);
ctx.stroke();
ctx.restore();
}
if (brick.power) {
drawPowerIcon(ctx, x + w / 2, y + h / 2, brick.power, .75);
}
if (brick.flash > 0) {
ctx.fillStyle = `rgba(255,255,255,${brick.flash * 1.3})`;
roundRect(ctx, x, y, w, h, 6);
ctx.fill();
}
ctx.restore();
}
}
function drawPowerups() {
for (const powerup of powerups) {
const color = powerup.type === "wide" ? "#45e6ff" : "#b88cff";
ctx.save();
ctx.translate(powerup.x, powerup.y);
ctx.rotate(powerup.rotation);
ctx.shadowColor = color;
ctx.shadowBlur = 20;
const gradient = ctx.createLinearGradient(-10, -10, 10, 10);
gradient.addColorStop(0, shade(color, 30));
gradient.addColorStop(1, shade(color, -38));
ctx.fillStyle = gradient;
roundRect(ctx, -10, -10, 20, 20, 6);
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(255,255,255,.64)";
ctx.lineWidth = 1;
ctx.stroke();
drawPowerIcon(ctx, 0, 0, powerup.type, .83);
ctx.restore();
}
}
function drawPaddle() {
const x = paddle.x - paddle.w / 2;
const y = paddle.y - paddle.h / 2;
const wideActive = clock < wideUntil;
ctx.save();
ctx.shadowColor = wideActive ? "#b88cff" : "#45e6ff";
ctx.shadowBlur = wideActive ? 24 : 18;
const gradient = ctx.createLinearGradient(0, y, 0, y + paddle.h);
gradient.addColorStop(0, "#e8fdff");
gradient.addColorStop(.28, "#72efff");
gradient.addColorStop(.65, "#36b9f3");
gradient.addColorStop(1, "#686cf4");
ctx.fillStyle = gradient;
roundRect(ctx, x, y, paddle.w, paddle.h, 7);
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(255,255,255,.58)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,.68)";
roundRect(ctx, x + 8, y + 4, paddle.w - 16, 2, 1);
ctx.fill();
ctx.fillStyle = "rgba(7,18,36,.45)";
roundRect(ctx, x + 4, y + 3, 4, paddle.h - 6, 2);
ctx.fill();
roundRect(ctx, x + paddle.w - 8, y + 3, 4, paddle.h - 6, 2);
ctx.fill();
ctx.restore();
}
function drawBalls() {
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (const ball of balls) {
for (let index = 1; index < ball.trail.length; index++) {
const previous = ball.trail[index - 1];
const current = ball.trail[index];
const alpha = index / ball.trail.length * .28;
ctx.strokeStyle = `rgba(69,230,255,${alpha})`;
ctx.lineWidth = 1 + index / ball.trail.length * 4;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(previous.x, previous.y);
ctx.lineTo(current.x, current.y);
ctx.stroke();
}
const glow = ctx.createRadialGradient(ball.x - 2, ball.y - 3, 0, ball.x, ball.y, ball.r * 2.4);
glow.addColorStop(0, "rgba(255,255,255,1)");
glow.addColorStop(.25, "rgba(190,252,255,.98)");
glow.addColorStop(.52, "rgba(69,230,255,.65)");
glow.addColorStop(1, "rgba(69,230,255,0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r * 2.4, 0, TAU);
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r * .73, 0, TAU);
ctx.fill();
}
ctx.restore();
}
function drawParticles() {
ctx.save();
for (const particle of particles) {
ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1);
ctx.translate(particle.x, particle.y);
ctx.rotate(particle.rotation);
ctx.fillStyle = particle.color;
ctx.fillRect(-particle.size / 2, -particle.size / 2, particle.size, particle.size * .72);
ctx.setTransform(canvas.width / W, 0, 0, canvas.height / H, 0, 0);
}
ctx.restore();
}
function drawRipples() {
ctx.save();
for (const ripple of ripples) {
ctx.globalAlpha = ripple.life / ripple.maxLife;
ctx.strokeStyle = ripple.color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(ripple.x, ripple.y, ripple.radius, 0, TAU);
ctx.stroke();
}
ctx.restore();
}
function drawFloaters() {
ctx.save();
ctx.textAlign = "center";
for (const floater of floaters) {
ctx.globalAlpha = clamp(floater.life / floater.maxLife, 0, 1);
ctx.font = `900 ${floater.size}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
ctx.strokeStyle = "rgba(3,8,16,.8)";
ctx.lineWidth = 3;
ctx.strokeText(floater.text, floater.x, floater.y);
ctx.fillStyle = floater.color;
ctx.fillText(floater.text, floater.x, floater.y);
}
ctx.restore();
}
function render() {
ctx.setTransform(canvas.width / W, 0, 0, canvas.height / H, 0, 0);
ctx.clearRect(0, 0, W, H);
drawBackground();
ctx.save();
if (shake > 0) ctx.translate(random(-shake, shake), random(-shake, shake));
drawBricks();
drawPowerups();
drawPaddle();
drawBalls();
drawParticles();
drawRipples();
drawFloaters();
if (combo > 1 && (state === "playing" || state === "attract")) {
ctx.save();
ctx.textAlign = "right";
ctx.font = "900 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
ctx.fillStyle = "#b88cff";
ctx.fillText(`${combo}× COMBO`, W - 28, 29);
ctx.restore();
}
if (state === "serve") {
ctx.save();
ctx.textAlign = "center";
ctx.font = "800 10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
ctx.fillStyle = "rgba(163,194,222,.75)";
ctx.fillText("CLICK OR PRESS SPACE TO LAUNCH", W / 2, 579);
ctx.restore();
}
ctx.restore();
}
function pointerToWorld(event) {
const rect = canvas.getBoundingClientRect();
return (event.clientX - rect.left) / rect.width * W;
}
canvas.addEventListener("pointermove", event => {
if (state !== "playing" && state !== "serve") return;
const x = pointerToWorld(event);
const half = paddle.w / 2;
paddle.targetX = clamp(x, half + 18, W - half - 18);
});
canvas.addEventListener("pointerdown", event => {
event.preventDefault();
const x = pointerToWorld(event);
if (state === "attract") {
startGame(x);
} else if (state === "serve") {
const half = paddle.w / 2;
paddle.targetX = clamp(x, half + 18, W - half - 18);
launchServe();
}
canvas.focus({ preventScroll: true });
});
$("startButton").addEventListener("click", event => {
event.stopPropagation();
startGame();
canvas.focus({ preventScroll: true });
});
$("restartButton").addEventListener("click", event => {
event.stopPropagation();
startGame();
canvas.focus({ preventScroll: true });
});
document.addEventListener("keydown", event => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
const key = event.key;
if (state === "attract") {
event.preventDefault();
startGame();
}
if (key === "ArrowLeft" || key === "ArrowRight") {
event.preventDefault();
if (key === "ArrowLeft") keys.left = true;
else keys.right = true;
return;
}
if (key === " ") {
event.preventDefault();
if (state === "serve") launchServe();
else if (state === "victory" || state === "gameover") startGame();
return;
}
if (key === "Enter" && (state === "victory" || state === "gameover")) {
event.preventDefault();
startGame();
}
});
document.addEventListener("keyup", event => {
if (event.key === "ArrowLeft") keys.left = false;
if (event.key === "ArrowRight") keys.right = false;
});
window.addEventListener("blur", () => {
keys.left = false;
keys.right = false;
});
soundButton.addEventListener("click", event => {
event.stopPropagation();
muted = !muted;
soundButton.textContent = muted ? "×" : "♪";
soundButton.setAttribute("aria-pressed", String(!muted));
soundButton.setAttribute("aria-label", muted ? "Enable sound" : "Mute sound");
if (!muted) {
initAudio();
tone(480, .06, "sine", .018);
}
});
function loop(now) {
const frameDelta = Math.min((now - lastTime) / 1000, .05);
lastTime = now;
accumulator += frameDelta;
while (accumulator >= STEP) {
update(STEP);
accumulator -= STEP;
}
render();
requestAnimationFrame(loop);
}
resizeCanvas();
resetAttract();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Reasoning trace (25,106 tokens)
We need produce exactly one fenced code block complete HTML. Need build polished breakout. Need ensure robust. Need no external. We need formulate compelling demo visible first paint and self-playing behind click/key message per developer. Canvas 1280x800 responsive down 375. Need full HTML inline CSS JS.
Need think design. Canvas game perhaps dimensions 960x640 logical. Side panel? Requirement canvas. Could use whole visual on canvas and HUD HTML? "using vanilla JavaScript and a <canvas>" can canvas plus DOM overlays. Better full-page polished. Need visible first paint with game in action. At initial load, screen behind click-to-start perhaps attract mode AI playing, explicit "Click or press a key to start". Screenshot after 3 seconds shows attract mode plus message. Interactions then start player control. Must avoid automatic hidden. Need mouse and arrows.
Need implement ball collision robust swept? Ball never passes through bricks/paddle. Need game loop and physics. Multiple balls. Brick tougher 2 hits visibly crack. Power icons and drops. paddle catches power: either wider 10 sec OR multi-ball 3 balls. We can random deterministic types.
Need victory after all bricks broken. 3 lives, score. Lose one ball when all balls gone, reset position maybe lose life and serve. For multiball, life only when no balls. Power-up falling and catching gives multiball 3 balls (current plus? wording "multi-ball (3 balls)" likely ensure exactly 3 balls). We can make power says x3 and set 3 balls total, split existing ball into 3. Wider 10 sec. Potential power both. Normal and tough. Visible crack after first hit: draw cracks overlay based hp 1/2. Tough hit visually changes color/cracks. Also normal one hit.
Attract mode self-playing AI controls paddle; perhaps overlay "CLICK OR PRESS ANY KEY TO TAKE CONTROL" in top/bottom. Game state attract and actual balls moving. Once clicked/key starts fresh. Need user asked polished. Could have full canvas within app with ambient gradient, game title, HUD in HTML perhaps canvas easier. Need at 1280x800 viewport fit. Set page dimensions, central app 1120x700-ish. Canvas aspect maybe 16:10, 960x600. Add header around. Responsive. 1280 x 800 no scroll. Use body min-height 100svh and .game-shell max-width 1120; height calc. Canvas in stage flex. Need actual game canvas full 1000x620 logical, DPR scale. Use canvas CSS width 100%, aspect-ratio 16/10. At 1280x800 shell around 1120x700, canvas maybe 1000x625 plus header 70 = 695. Fine. On mobile 375, shell nearly full viewport, canvas aspect ratio 16/10 => 340x212 too small but no scroll; HUD compact. Perhaps canvas adaptive portrait? We can keep logical dimensions but CSS aspect ratio. Game responsive via canvas scaling. For polished mobile, maybe shell height and canvas width, no horizontal. At 375x? viewport unknown perhaps 800. Canvas 340x212; okay but header/hud etc. Could let stage max-height and aspect? Maybe canvas CSS width 100%; height 100%; object-fit? Canvas internal resizing based on container and logical coordinate transformations. Fixed logical 1000x600 and scaled to fit. At mobile actual 343 wide, height 206, still playable. Could adapt logical world width? Keep canvas aspect 5:3.
Could make full viewport canvas and HTML HUD, overlay. At desktop, game canvas fills width maybe 1200x700. At mobile canvas use aspect ratio. Better fixed stage.
Let's design:
HTML:
<div class="app">
header .topbar
brand (icon custom CSS)
status pills: SCORE 000000; BEST maybe high score but no persistence (not allowed); LIVES with three tiny dots; LEVEL "01"
sound button maybe toggle? Not required, avoid.
main .game-frame
canvas id=game width=1000 height=620
overlay .start-card id=startOverlay:
eyebrow "NEON BREAKOUT"
h1 "Break the grid. Own the glow."
p "Move with mouse or ← →"
button "Start game"
maybe explicitly "CLICK OR PRESS ANY KEY TO START". Attract game visible behind, overlay translucent but does it obscure? Requirement interesting visible first seconds. Overlay card center can obscure. Better banner at top of canvas "CLICK OR PRESS ANY KEY TO TAKE CONTROL" with small card; behind attract visible. But screenshot judges aesthetics. We can make overlay a bottom centered glass panel, or central 420x. Need game action visible. Maybe start overlay covers center. Could show attract mode in background with bricks and ball. Overlay as slim panel at bottom. "CLICK OR PRESS A KEY TO START" visible. The developer specifically says behind a "Click or press a key to start" message. Use start veil transparent, with message at top center and a small lower-left hint? Could canvas draw text itself. We can have HTML overlay .attract-banner pointer events etc top 16. In first screenshot bricks visible, AI ball, particles perhaps. Great.
At bottom within app footer controls/power legend. HUD can be DOM. On start overlay banner hides, score etc.
Canvas dimensions 1000x600. Background dark navy gradient, grid. Paddles etc.
Game aesthetics:
- Background #07111f / #0b1220 with radial glows.
- Brick dimensions 6 columns? At least 6 rows. Use 8 columns x 7 rows =56. At least 6 rows. Mix normal/tough. Better 8 cols x 7 rows. Brick area from x 130 to870, y 86 to 310. Each brick w~82, h~27 gap 10. Rows 7.
- Brick palette cyan, blue, purple, amber tough. Need normal and tougher clearly crack after first. Draw rounded rect with gradient, inner shine, symbols perhaps power-up icon on selected.
- Tough bricks carry diagonal corner metallic edges and crack overlay when hit.
- Some bricks carry visible icon: rocket? plus, diamond, mult-ball symbol. Requirement some bricks visibly carry a power-up icon; when broken they drop it. Could mark maybe one per certain rows. Use power type assigned. Icons visible (double chevron or spark). When brick is broken drop corresponding power-up. If tough takes first hit doesn't drop, second breaks then drops.
- Need normal bricks all break one hit; tough hp2. 7 rows: maybe row 0,2,4 tough; mix. Power bricks maybe 6 total, some in tough.
- Could define types based col/row.
Paddle at y=548? Canvas 600, paddle h14, center x500, width 122 normal. Ball radius 8. Bottom boundary y=596. Paddle y=548. Bricks y 90..300.
- Ball speed ~390 px/s logical (maybe 390), paddle follows.
- Ball direction launch upward. Attract AI tracks ball with offset maybe.
- Collision robust:
* update dt max maybe 0.033.
* move each ball; walls bounce.
* paddle collision swept to avoid tunneling: ball position old and new; if vy>0 and ball crosses paddle top (py - r to py + r), compute x at crossing. Determine if within expanded paddle half width + r. Need choose collision normal and bounce angle based contact offset. Reflection with angle. We can set new vx based offset and speed. To ensure no pass. Perhaps collision function:
prevBottom = oldY+r; newBottom = y+r; paddleTop=py-paddleH/2? paddle rectangle.
if vy>0 && prevBottom <= paddleTop && newBottom >= paddleTop and x within paddle x±w/2+r:
y = paddleTop-r
hit=clamp((x-paddleCenter)/(w/2), -1,1)
angle = hit*1.05 (59°) from vertical. speed. vx=sin(angle)*speed; vy=-cos(angle)*speed. But if hit at edge angle max. "center = straight up, edges = sharp angle". yes.
* Side paddle collision? If ball comes downward and intersects side but not top, reflect x; swept. But typical top. Requirement never pass through paddle. Need implement broad collision with prev AABB. We can do substeps or swept. Since dt <=1/120? Use fixed timestep 1/120 for robust collision. In animation loop accumulator with fixed 1/120. At speed 500 step 4.2 px, brick h26 and ball r7 no pass anyway. But to guarantee, do substeps based max 4 px. fixed dt 1/120 (3.3px). Collision circle vs AABB can still detect. Need robust.
* Brick collision: moving circle intersects brick. At fixed step <4px, cannot pass entire brick (height 27), detect and resolve. But if near corner should reflect appropriate normal. Use circle vs AABB and determine minimal penetration; push out along normal and reflect velocity. For circle rectangle overlap:
closestX clamp ball x to bx/bx+w, closestY. dx,dy; dist<r.
If dist>0 normal from closest to ball; penetration r-dist; x+=nx*pen; y+=ny*pen; reflect if dot(v,n)<0.
At exact corner/center fallback based previous trajectory / nearest axis.
This resolves; mark one brick per substep maybe if multiple avoid duplicate.
* Potential ball starts embedded after push? Fine.
* Brick damage once. We may mark brick hp--, score. Crack visible. Remove hp0. Particle effects, sound optional.
* Need if multiple balls same brick in same frame, first breaks then second doesn't. Good.
- Fall: if ball y-r > canvas h + 30 remove. When no balls and lives remain: after delay, reset serve; if life drops then lives--. Since multi-ball, maybe lose life only no balls.
- Lives =3. Score.
- Victory when all bricks destroyed; state victory, overlay. Game over if lives 0 and no balls maybe. Restart option buttons.
- Serve/reset. Start user begins immediately maybe click starts game fresh and launches? Requirement click starts. Could launch after maybe 0.6 sec. We can reset and state playing, balls at paddle. Could have ball initially attached and auto launch after 0.8s. But user needs action. Better immediately launch one ball; button. On key start, reset. Mouse movement should control once playing. Attract AI until start.
- Could have ball initially attached and auto launch after 0.8s. But user needs action. Better immediately launch one ball; button. On key start, reset. Mouse movement should control once playing. Attract AI until start.
- Mouse controls paddle. pointermove on canvas maps x logical. Keyboard arrows. Space launch maybe. The start overlay listens any key. During playing arrows move. We need no key prevents scrolling? arrows preventDefault.
- Restart button on game over/victory overlay. Need visible canvas overlay maybe DOM .end-overlay.
- Overlay state:
.start-hint at top, .start-card bottom maybe.
HTML end overlay hidden. Victory/gameover with title, score, button.
Could draw screens canvas to keep cohesive. DOM overlay perhaps.
- UI HUD updates DOM. Lives dots.
- power timer display in HUD/canvas. Wider paddle 10 sec. Multi-ball transient. HUD a "WIDE 9.8s" status bar that appears. Also power-up legend footer.
- Use accessible button.
- Canvas high-DPI: logical W=1000 H=600. Set actual canvas dimensions based CSS size*dpr, context.setTransform(scale*rect.width/W...). But aspect ratio CSS. We can use ResizeObserver to size. canvas style width 100%; aspect-ratio: 5/3. If element .stage aspect-ratio 5/3. Canvas width=100%. We can set canvas.width=rect.width*dpr, height=rect.height*dpr, draw transform actual/W etc. Since logical ratio exactly same, no issue.
- DevicePixelRatio. ResizeObserver.
- At desktop stage max maybe width 1080; height 648 plus top 68/footer 42 =>758 fits 800. body padding 16 => maybe 792. .app width min(1120px, calc(100vw - 32px)); header 60; gap 12; stage width 1096 =>657; footer 34 => total 775 + 32 =>807 slightly. We can cap game stage via height. Maybe app max-width:1080 and stage aspect 5/3 =>648, header58, gaps 24, footer28 =>758, body padding 20 =>798 exactly. Set max-width 1060 ->636, total 740. Good. At 1280 screenshot ample margins 110 each. polished. App max-width 1080 perhaps.
At 375, app width 351, stage 210, header ~58, footer maybe hidden/compact =>280, lots blank vertical if centered. That's okay. Could add media query stage aspect ratio maybe 4/5? But logical game would scale weird? If canvas CSS aspect different, use transform nonuniform causing game shape distorted. Better maintain ratio. Could show background.
Maybe shell at mobile width calc(100%-16) and .meta rows. no horizontal.
Could use CSS subtle body background with gradients, app border glass, stage shadow. Header. Include neon title "BREAK//OUT" and small badge "ARCADE 01". Score etc.
- lives icons use small CSS circles.
- pause button? Could add mute button with WebAudio generated sounds; polished. Sound is optional but nice. Need no external. Implement WebAudio on interaction. But audio context might trigger only after click/key. Fine. Toggle button. Could also add pause via P. Requirement not. Could include.
- "BEST" no local storage prohibited. Could keep session variable only. Display BEST maybe. But score enough. Keep high score in JS memory, no persistence. Yet restart resets score but best stays session. fine.
- Header chips.
- Particle system:
particles when brick breaks: 12 small rectangles with velocity, gravity, fade, colors; sparks. Also trails balls.
screen shake on break maybe subtle.
floating score text.
brick debris.
- ball trails: each ball stores trail array positions, draw gradients circles.
- paddle glow.
- powerups: falling item type wide/multiball, 18x18 rounded diamond with icon plus / three dots, glow, rotate. Drop from broken brick x center. Catch with paddle collision AABB; apply.
- Powerup can fall; if missed expires.
- Wide paddle 10 sec exact. On catch set wideUntil=gameTime+10. If multiple maybe reset. Display timer. Widen from 116 to 168 (about 1.45x).
- Multi ball "3 balls": On collect, ensure 3 active balls. If existing one, replace with 3 trajectories? Wording gives 3 balls. Could spawn additional to reach 3. Existing speed. Generate angles -30,0,30 around current up direction; ensure all. Better if current one, add 2, total3. If more? clamp? Ensure exactly 3 maybe remove extras? Usually multi-ball gives 3. We can add two.
- Could powerup icon on brick correspond to type. When broken drop it. Need visible icons. On brick draw a small symbol centered (e.g. arrows for wide, tri dots for multi) and small sparkle.
- Tough bricks visibly crack after first: after hp1 draw jagged path in dark/bright, and maybe color desaturates. Need retain power icon. "visibly crack after first one" obvious. Draw crack lines and chipped edges. Maybe hp2 full steel/dark gradient, hp1 dark cracked with white fissures. Normal bricks vivid.
- Bricks 8x7 = 56. Need level victory feasible in attract. Multi rows. User can break.
- Board arrangement perhaps deterministic no gaps? 8 cols*7=56 all bricks. But game can take long. Attract should self-play and maybe break enough within 3 sec. It will at least ball.
- Could make 9 cols x 7 =63. At least 6 rows. We use 9? Width 820, each 82. Brick area x100..900. 9 cols. 7 rows. 63. Attract ball. Need manageable. Let 8 columns =56. Each w84. Use area x116 to884, gap8 =>83.75. h24, vertical gap8, 7 rows total 208. Good.
- Rows colors. Tough every third row maybe 21 tough, 35 normal. Power on maybe 7. Victory around many hits.
- Starting ball speed 390. bricks at y=95; paddle 548. Ball reaches in ~1.2 sec and starts breaking around screenshot at 3 sec. Attract AI paddle tracks. Good.
- Start hint overlay should not intercept pointer movement? pointer events none except button? We have actual DOM "Take control" button. The initial overlay with pointer-events auto would block canvas mouse until click button, but clicking anywhere should start. Attach pointerdown to app/stage maybe. We can include button. We want mouse position update. On canvas pointermove while attract could also start? Requirement click or press key. We can start on pointerdown anywhere stage; banner pointer none. Use canvas pointerdown start; button.
- In attract mode, AI moves paddle. But if user moves mouse before click, don't suddenly set paddle? On click then pointer maps and start. fine.
- Key any key start except maybe if focused? document keydown. Arrow keys start and then held. Space. Prevent default arrow.
- Attract state: resetLevel, state='attract', overlay visible. AI tracks predicted ball x with lag; perhaps paddle movement enough. It will avoid missing. We can implement target:
find lowest ball moving down; target ball.x + ball.vx*0.35; AI moves toward at speed 520. If ball up, target center or predicted intercept. To maintain action. Set paddle.x with lerp.
- In attract, powerups auto-catch maybe paddle tracks x. Ball breaks bricks. As time goes on, could clear all and victory attract overlay? We don't want attract victory. Could when all bricks gone in attract reset after 2 sec, and not show victory. But screenshot 3 sec unlikely clear. Could make attract restart if clear.
- Start click reset score/lives, clears particles etc.
- End overlays:
- endOverlay absolute inset, display flex. background rgba. panel.
- state gameover/victory sets overlay. Title, subtitle, final score, restart button.
- During attract show small banner at top "CLICK OR PRESS A KEY TO START" with pulse.
- Maybe draw start screen integrated on canvas? HTML easier.
- Need canvas game render even when end.
- Pause? Could add top right icon button. At mobile header crowded. Maybe no.
- Include instructions in footer: "MOVE Mouse / ← →", "CATCH ✦ for 10s wide paddle or ×3 balls".
- HUD:
scoreboard score
combo? Could track combo and display maybe. Requirement score. Add streak indicator in canvas.
level label "SECTOR 01"
lives 3.
Best optional.
Visual background:
- canvas fill gradient; draw grid perspective? Need polished.
- stars/dots static.
- side accent lines.
- top within canvas maybe board title? DOM header enough. Canvas top at 30 has subtle "SECTOR 07 // PRISM VAULT" and "56 TARGETS". But bricks y=85.
- draw subtle vertical glow behind brick area.
- draw boundary frame rounded? canvas itself clipped.
- bottom line and labels.
- Draw launch ball attached? We'll always launch.
- For state serve maybe.
- On lives lost display toast "BALL LOST • 2 LIVES LEFT" maybe canvas floating center. When reset.
- Score popups.
- Particle colors.
Physics details:
Game object:
const W=1000,H=600.
let state='attract'; let score=0,best=0,lives=3,levelBricks=[];
let balls=[],particles=[],powerups=[],floaters=[],ripples=[];
let paddle={x:500,y:551,w:116,h:14,targetX:500};
let wideUntil=0, elapsed=0, combo=0, comboTimer=0, shake=0;
let startBanner maybe.
Fixed loop:
let last=performance.now(), accumulator=0;
function frame(now) {
dt=Math.min((now-last)/1000,.05); last=now;
accumulator+=dt;
while(accumulator>=1/120){ update(1/120); accumulator-=...}
render();
requestAnimationFrame
}
No errors if resize.
State update:
if state attract/playing:
if attract AI paddle
else keyboard + mouse? Use paddle.targetX and move. Mouse position sets target. keyboard increments target. We can update paddle.x toward target with max speed 650; if arrow held, target += dir*speed*dt maybe. Mouse absolute.
updateBalls.
updatePowerups.
update particles.
update timers.
if no balls:
if state playing:
if lives>0 and respawnDelay <=0? On lost:
lives--; if lives<=0 gameover; else state='serve' with serveTimer=1.1; show message. During serve paddle controlled, no balls.
On start state playing balls launch.
But initial reset maybe directly.
In attract, if no balls reset attract.
- lose one life when all balls gone. For multi-ball, if one falls but others remain no life lost, appropriate.
- At reset playing create one ball centered above paddle. It launches immediately maybe upward with slight random. For visual, perhaps attach and launch after .7 sec. User click starts; state='serve'; canvas draws ball on paddle and text "GET READY"; after .8 launches. This gives time. But start overlay gone, nice. Mouse control.
- At attract, launch.
- Space during serve launches immediate.
- Launch direction based paddle hit? center straight. Initial x maybe random slight.
- Ball update:
for (let i=balls.length-1;i>=0;i--){
b.prevX=b.x...
b.x += vx*dt...
wall collision:
if b.x-r<18 -> x=18+r; vx=abs; hit effect
if b.x+r>W-18 ->...
if b.y-r<54 maybe top wall? Canvas top y 24; set top=30. Reflect.
bottom if b.y-r>H+30 remove.
brick collision:
maybe loop all alive and circleRect. Need ensure correct reflection and avoid hitting same brick repeatedly due residual. On collision resolve then break.
Order paddle before bricks maybe. Bricks far.
Paddle collision use swept.
Potential issue if ball is within paddle rect due previous collision and vy still down? after reflect vy neg, no repeated.
Need "ball must never pass through bricks" fixed dt enough, but can further substep each fixed step:
speed max maybe 520; at 1/120 =4.33; brick min h25 so no full pass. At edge collision could pass through narrow horizontal gap? Width gap 8, ball diameter 14, could potentially pass through gap! Bricks are separated by 7 px and ball diameter 13. Ball could fit? no gap less diameter, but it might move diagonally through gap? Circle collision sees edges. Fine.
Could use continuous segment-circle? not needed.
- brick collision normal. If center of ball inside rectangle (dist0) determine based previous pos or velocity:
const overlapLeft = b.x+b.w - (ball.x-r)? Let's derive. Circle center inside rect. Need minimal exit:
distances to left: ball.x - bx; right: bx+bw-ball.x; top: ball.y-by; bottom: by+bh-ball.y. Choose min, set normal left -1 etc, push to boundary +/-r. But if approaching from above, top distance likely smallest? If deeply. Use velocity to select axis: if abs(vy)>abs(vx), normal y = vy>0? down? If entering from top moving down, should push top and reflect vy. But dist method simpler.
For outside corner, closest point and dx/dy gives correct normal.
Reflect vector with restitution 1: v -= (1+e)*dot*n, e=1.
Maybe speed adjust slightly each hit to prevent trapped horizontal: if abs(vy)<speed*0.28 then adjust vy sign and scale. Keep speed within 360-520.
- Collision can hit two bricks at corners. We'll process one then break? iterate; after resolve, call damage and continue perhaps next. Need avoid multiple reflections. Use collided flag and break after one.
- On brick damage:
hp--. If hp >0: crack=true; particles small 5; score +=25; maybe sound thud; floater "CRACKED"; combo resets?
else: alive=false; base points tough 150 normal75 * combo multiplier. score; particles 16; ripple; power if brick.power and not used. Each brick power drops once on break.
combo increments based time since last hit? Have combo count if hits within 1.2 sec. Could use comboTimer. On hit if comboTimer>0 combo++ else combo=1. multiplier min 4. Score maybe 50*multiplier. Show combo.
- If tough's first hit but power icon remains.
- Power spawn at brick center y+ h/2, vy=150.
- power update y += 150 dt; rotate; catch if p.y+9>=paddle.y-... and x overlap; apply. Maybe if falls below remove. Catch collision robust.
- wide status should update. Use elapsed game time? In attract too. wideUntil based totalClock always increments. use `gameTime` increments. If state playing and gameTime<wideUntil. On catch set wideUntil=gameTime+10. For new game reset.
- Multi ball: `activateMultiball`:
if balls.length===0 return;
base=balls[0]; ensure 3. Need existing ball might be downward. Split around its current velocity. Function:
const baseAngle=Math.atan2(base.vx,-base.vy) perhaps angle from up. For i missing, angle clamp(baseAngle + offsets), set vx=sin(angle)*speed, vy=-cos(angle)*speed. But if base velocity maybe upward. use current. Add from base x.
If balls length >3 due multiple multiballs? requirement gives 3, can leave >3? Better cap to3 by removing extras perhaps. We'll set first 3. If one -> add 2; if2 add1; if3 show "3 BALLS". If >3 remove extras.
- New balls trails.
- power item visuals:
wide = rounded square with cyan and plus horizontal maybe ↔; multi = amber/purple with 3 circles.
On canvas use canvas path icons. drawPowerIcon(ctx,x,y,type,scale)
- Brick power icon similar. draw tiny plus for wide or three dots for multi.
Need avoid external fonts. Use system fonts: `Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`; no network. Could use letter spacing. Monospace for numbers.
- SVG icons? Inline okay. Use CSS / text symbols maybe fonts. Use simple.
HTML structure:
<!doctype html>
<html lang=en>
<head> meta viewport...
<style> reset...
body ...
.app...
.topbar
.brand .mark maybe `<span class=mark><i></i>...</span>`
brand text.
.hud
.stat score, lives, level.
.stage-wrap maybe.
.stage:
<canvas id=gameCanvas aria-label=...></canvas>
<div id=attractHint class=...>
<span class=pulse-dot>
<div><strong>CLICK OR PRESS A KEY TO START</strong><small>AI DEMO • TAKE THE PADDLE</small></div>
<button id=startBtn>PLAY NOW</button>
</div>
<div id=toast class=canvas-toast ...>
<div id=endOverlay ...>
panel with icon, title, message, score, button
</div>
</div>
<div class=underbar>
.control
.power legend
.wide-meter maybe.
</div>
Maybe hint top overlay covers brick top? Place at bottom center? Canvas action still. Attract banner at top 12 where bricks start y 70? Actually canvas top HUD maybe no. Place top center at y 12; bricks y 78, so okay. It may cover top row if absolute 14, but height50 and row starts 78 logical => CSS 47 at desktop, overlap. We can put bottom center y 566 where paddle, bad. Place left center? We want message visible. Use slim at top over blank 0-65. Set bricks start y=88. Hint top 12, height46, overlap first row maybe row starts88 => canvas css 55, exactly. Fine. Or make top area 72 and hint. Bricks y=96. 7 rows with h22+8 =210 ends306. enough.
- Canvas brick area y=92.
- Hint CSS position top:12px; max width etc.
Maybe initial overlay includes central translucent panel with title at center-left while board visible. But hint top is enough. Need user sees exact "Click or press a key to start". Use text exact casing.
Attract mode starts immediately on page load, no user interaction needed. Requirement developer "Games must start in a self-playing demo mode ... behind a 'Click or press a key to start' message". yes.
We need possibly initial call resetLevel before render. best 0.
- `document.addEventListener('pointermove'?)` We need mouse control only over canvas, stage. Canvas pointermove map. In playing set paddle.targetX.
- Canvas pointerdown calls startGame if attract. Then paddle target from event and maybe launch if serve. Clicking on startBtn event bubbles stage? Attach button separately stopPropagation maybe.
- keydown:
if state attract: prevent default for relevant? any key startGame. We don't want enter repeatedly. start and return.
if end and Enter/Space restart.
if arrows: held set; prevent.
if space in serve launch.
if p pause maybe optional.
- Arrow held state. Need start on arrow and then movement. startGame resets paddle x center; arrow keydown already perhaps no movement until next repeat, okay.
- keyup remove.
- blur clear keys.
- mouse movement: targetX. But if paddle can only move by max speed, target could get far and then follow. Smooth. Could set exact on pointermove? Usually paddle follows exact. We can set `paddle.targetX=...`; update moveToward at max 900. Good.
- `pointerleave` no issue.
- touch support pointermove but touch may scroll; CSS touch-action:none on canvas. On pointerdown start. We could move paddle with touch.
Audio:
let audioCtx=null, muted=false;
function tone(freq,dur,type='sine',gain=.03) {
if muted return;
try create context; if suspended resume;
osc/gain.
}
Need browser console zero errors; `AudioContext` unsupported? Check. `const AC=window.AudioContext||window.webkitAudioContext; if(!AC)return;`.
Button mute maybe no need. If included, text updates. Start click triggers audio context.
Potential autoplay no issues due no audio in attract before user? We can skip sounds attract to avoid context creation. In start game call initAudio.
Game state and animation:
`resetGame(mode)` sets bricks using `createBricks()`, balls one? For attract spawn.
Maybe `newGame(attract=false)`:
score=0/lives=3; resetBricks; balls=[]; etc.
if attract: state='attract'; spawnBall at random; paddle center; show hint.
else: state='serve'; serveTimer=.8; spawn attached? We can represent ball at paddle x y=... with vx0 vy0 and state serve; draw.
startGame called by user: hide hint; end overlay; state='serve'; ...
- In update, if state==='serve':
serveTimer -=dt;
paddle movement.
if serveTimer<=0 launchBall();
- In attract, always play. AI.
- `state` maybe 'paused'.
- if state='victory' balls can remain frozen. Draw.
- `attractHint.classList.add('hidden')`.
- End overlay set state.
- Restart button calls startGame.
- gameover overlay.
- Victory screen requirement explicit.
- On gameover when lives hits0. We lose last life and no balls. We should set state gameover after short delay maybe .5 to show. set overlay. fine.
- Victory when bricks all broken. state victory; overlay title "SECTOR CLEARED"; panel icon; score.
- if attract clear all: resetAttract after delay rather than victory. Could avoid because AI maybe. But implement.
- `allBricksBroken = bricks.every(!alive)`.
- Need level with at least 6 rows. 7.
Create bricks:
const cols=8, rows=7, startX=106, top=95, gapX=9, gapY=9, total width = 788? W=1000. Let's compute brickW=(888-106? area 108..892 width784 minus 7*9=63 =>721/8=90.125). h=24. Row top 92, seven => 7*24+6*9=222 ends314. Good.
Set startX=106, endX=894. brickW=88? 8*88=704+7*9=63 total767, centered at116.5. Use x=116 + col*97; widths88; right 116+7*97+88=883. Good. w=88.
- row color:
r0 cyan normal; r1 purple with tough at c2,c5 maybe; r2 cyan; r3 orange tough all?; etc. We need mix.
Define each brick:
let tough = (row===1||row===3||row===5) maybe whole row. Normal other.
Colors palette:
normal rows [cyan #2dd4bf?], blue #38bdf8, purple #8b5cf6, pink etc.
Tough color darker steel with accent row color.
power assignment:
- wide at (r0,c1), (r2,c5), (r4,c2)
- multi at (r1,c6), (r3,c3), (r5,c0), maybe (r6,c7). This is 7 visible.
Potential tough power brick at r1c6, r3c3,r5c0 requiring 2 hits and drop. fine.
- all alive.
Properties hp=max, maxHp, crack:false, power maybe.
Colors use row palette independent tough.
Normal brick gradient bright accent with dark base.
Tough full hp2 metallic thick border; hp1 cracked.
Draw:
ctx.save();
shadowColor accent; shadowBlur 5;
roundRect path custom `roundRect` supported latest Chrome but use helper pathRoundRect.
gradient vertical from lighten? Need hex lighten function. Could create `shade(hex, amt)`.
fill.
normal:
gradient top shade(accent,35), bottom shade(accent,-30)
stroke rgba.
inner line.
tough hp2: gradient top #35445a bottom #182538, stroke accent alpha .6; small rivets corners maybe.
tough hp1: gradient dark, stroke accent .45; crack path:
move x+w*.52,y+1 line x+w*.43,y+h*.34 line x+w*.61,y+h*.48 line x+w*.38,y+h*.73 etc; stroke rgba(255,255,255,.55), plus dark offset.
chipped corner.
Power icon small at center maybe bright white. For multi draw 3 circles. wide draw horizontal double arrow? Could draw plus-like horizontal capsule.
Need visible icon at 88x24; draw 12x12.
- row labels maybe not.
- brick hit flash property flashUntil; render lighter.
- particles.
Background:
draw gradient each frame can be expensive but okay. Create static gradient? Context size transform. Use fill.
`ctx.fillStyle='#07101d'; fillRect`.
radial glows around center top using radial gradients.
grid lines with globalAlpha .05; horizontal perspective? draw vertical lines from top brick? Could draw subtle.
- star field deterministic array generated once, twinkle.
- border at x=12 y=48 width=976 h=538 with round corners stroke.
- top labels inside canvas `SECTOR 07 / PRISM VAULT`; score combo at right.
- bottom shadows.
Render order:
clear; drawBackground
ctx.save; shake translate
draw bricks
draw powerups
draw paddle
draw balls + trails
particles
floaters
maybe guide.
ctx.restore
draw canvas top labels? should shake maybe before.
- draw paddle:
y=550 h=14; x center ± w/2. wide if active.
gradient cyan to blue; glow.
center thin white line; end caps.
- ball: trail stored positions each update. draw connected lines with fading maybe draw each circle. Use `globalCompositeOperation='lighter'`.
- attach ball serve: at paddle.x y=paddle.y-18; glow.
- particles: shapes rectangles, little sparks.
- ripples at brick center.
Need maintain game time. `clock` increments regardless maybe. `wideUntil` uses clock.
- Combo decay: comboTimer -= dt; if <=0 combo=0.
- toast DOM? We can render messages. Use DOM #statusToast perhaps dynamic text and class. Might be easier to show wide status in underbar.
Underbar:
left controls
middle power legend:
`<span class=legend-item><i class=mini wide>↔</i> WIDE · 10s</span>...`
right wide indicator with bar.
At 375 hide some text.
Canvas top-right combo display.
HUD DOM update each frame or after changes:
score.textContent = score.toLocaleString('en-US').padStart(6,'0')
lives dots class off.
level "01 / PRISM"
best maybe no.
wideStatus text if active.
Could use DOM updates only if changed.
- lives icon can be HTML 3 spans and update.
- score CSS tabular-nums.
- score pop.
Responsive:
body min-height:100vh; display:grid; place-items:center; padding:18px; overflow-x:hidden.
.app width:min(1080px,100%);
.topbar display grid grid-template-columns 1fr auto 1fr maybe brand left, hud center, level right. At 375 simplify:
@media max-width 700:
body padding:8px; align-items:start maybe.
.app gap8
.topbar grid-template-columns 1fr auto; .brand small hidden
.hud? position row below? Could wrap.
Maybe structure:
<header class=topbar>
<div brand>
<div class=stats> score, lives, level </div>
</header>
Stage.
Underbar.
At mobile topbar:
brand h1 16; stats gap6; stat labels hidden maybe. Level hide.
Canvas.
Start hint: button maybe text "PLAY"; layout.
End overlay panel.
No horizontal.
Desktop body background gradient. .app border? stage border.
`stage { position:relative; aspect-ratio: 5/3; border-radius:20px; overflow:hidden; background:#07111f; border:1px solid rgba...; box-shadow ...; }`
Canvas display:block;width:100%;height:100%;
`@media max-width:600 .stage border-radius 14`.
At 1280x800 app stage 1080*0.6=648, topbar 56, under 30, gaps 20 total754 + padding36=790 good. Set max-width 1060 =>636, total742+36=778. Nice. use max-width:1080 maybe. We can compute: header 56; gap12 top; stage648; gap? underbar30; total56+12+648+10+30=756; body padding18*2=36 =>792. Great exactly. app max-width1080.
At 800 height body grid center.
- underbar height 30.
Need maybe use `min(1080px, calc(100vw - 36px))` width.
- start hint height maybe 48. top 14. bricks start 91 logical -> CSS 55 at 1080, hint top 10 height44 to54, no overlap.
- overlay end center.
- Start message exact. Button. "AI DEMO RUNNING" label.
Attract mode balls start and AI. Paddle AI. We need ensure powerups not dropped too early maybe screenshot particles after 3 seconds. Ball takes ~1 sec to hit brick then returns ~1 sec; at 3 sec could be another hit. good.
Potential issue top row is normal and all power items. AI can break.
Implement custom roundRect helper:
function rr(ctx,x,y,w,h,r){ctx.beginPath(); ctx.roundRect? To avoid unsupported but latest supports. Use manual `ctx.moveTo... quadraticCurveTo`.
`clamp`, `rand`, `lerp`.
No external.
Canvas resize:
const canvas=...
let view={scale:1,w:W,h:H,dpr:1};
function resize(){
const rect=canvas.getBoundingClientRect();
const dpr=Math.min(window.devicePixelRatio||1,2);
canvas.width=Math.max(1,Math.round(rect.width*dpr));
canvas.height=...
view.dpr=dpr;
// CSS dimensions proportional exact; set transform canvas.width/W, canvas.height/H.
}
new ResizeObserver(resize).observe(canvas); window resize.
Potential ResizeObserver callback then canvas intrinsic dimensions might affect CSS? CSS width/height explicit so no.
render:
ctx.setTransform(canvas.width/W,0,0,canvas.height/H,0,0);
ctx.clearRect(0,0,W,H).
No repeated save transform issue.
- draw gradient in logical coordinates.
Game timing:
`requestAnimationFrame(frame);`
Fixed dt; if tab hidden dt cap .05.
state attract update paddle AI and balls. We should reset if no balls. spawn new.
Attract AI target:
let b=balls.find(b=>b.vy>0)||balls[0];
let target=b? b.x+b.vx*0.55 : W/2;
But if ball moving upward, tracking x may keep near center. Need intercept with walls maybe.
if b.vy>0 target=b.x + b.vx * ((b.y-paddle.y)/b.vy) but could huge; clamp. Compute t=(paddle.y-b.y)/b.vy if vy>0, predicted x=b.x+b.vx*t, clamp margins. This aims exact. if no downward target center.
move paddle toward target speed maybe 620.
Ball speed 360 enough. AI gets all.
Paddle could catch.
In playing keyboard:
const dir=(keys.ArrowRight||keys.d? requirement arrows only, can support A/D) - ...
if dir paddle.targetX += dir*620*dt; clamp.
mouseTarget updated.
`paddle.x = moveToward(paddle.x,paddle.targetX,Math.max(650,Math.abs(target-x)*8?)*dt)`.
Use max 900.
- If pointer target and keyboard simultaneously keyboard adjusts then pointermove could override later.
Ball spawn:
function spawnBall(x,y,angleDeg=-90,speed=390) where angle 0 means up? define angle from vertical:
vx=Math.sin(rad)*speed; vy=-Math.cos(rad)*speed.
At launch angle maybe -8 to8.
b={x,y,vx,vy,r:7,trail:[]}
- Ball collision brick at top:
bricks dimensions. Loop and circleRect. `collisionNormal`:
function collideCircleRect(ball,brick):
const closestX...
const dx=ball.x-cx...
if dist2>=r² return null.
if dist>epsilon: nx=dx/dist...
else center inside:
compute distances to edges including radius? We want push.
const left=ball.x-brick.x, right=... top..., bottom...
min; if top: nx0 ny-1; penetration=ball.y-(brick.y)+r; etc.
outside corner: penetration=r-dist.
Return.
After resolve:
b.x += nx*(pen+0.01)
b.y += ny...
dot=b.vx*nx+b.vy*ny
if dot<0 b.vx-=2*dot*nx; b.vy-=2*dot*ny;
normalize speed `speed = clamp(hypot,350,500)`.
But if ball is exactly on top with center y=by-r and dy = -r, dist=r maybe overlap condition strict `< r²`; equality no collision next step. After movement into, overlap. fine.
Potential collision normal at corner diagonal.
Need mark brick and then ball may be inside another due grid gap. fixed.
Paddle sweep:
paddleTop=paddle.y-paddle.h/2
if b.vy>0 && oldY+r <= paddleTop+1 && b.y+r >= paddleTop:
interpolation x at crossing:
t=(paddleTop-(oldY+r))/((b.y+r)-(oldY+r))
hitX=oldX+(b.x-oldX)*t
if hitX >= p.x-p.w/2-r && <= ...
resolve top
else if rectangle overlap perhaps side. Since ball could approach side:
if circleRect intersects paddle and vy>0:
we want top normal usually based hit offset.
contact=(b.x-p.x)/(w/2)
angle=clamp(contact,-1,1)*1.08
set vx=sin*speed, vy=-cos*speed
y=top-r.
This always reflects upward, even if side hit. okay.
If ball somehow beneath paddle moving up? Handle broad overlap: circleRect and resolve normal. But requirement no pass.
We'll use swept first.
Paddle top y=544 maybe. h14 top537, bottom551. Canvas H600. Ball r7.
Bottom loss if b.y-r > H+20. At screen lower no bounce.
Brick hit detection and power drop:
Potential first hit tough still alive, set `crack=true`.
Need `brick.flash=0.12` decrement in update.
- Break creates particles. Some particles should fall and bounce? We can simple.
- Power drop at brick x+w/2, y=by+bh; if power type.
- Break tough first? no drop.
Particle update:
for p: p.x += vx dt; y; vy += grav; life-=dt; rotation.
draw alpha life/max; squares.
- screen shake amount set 5; decays.
- ripple radius.
Floating texts update y -= 25dt etc.
Score:
normal = 50 * combo multiplier; tough=100 maybe. combo:
if comboTimer>0 combo++ else combo=1;
comboTimer=1.4; multiplier=min(combo,5)
score += base*multiplier;
HUD.
At first normal 50.
- score display 000000.
- final score.
Power catch:
`wideUntil=clock+10; toast`.
`multi` set 3.
Potential on power brick break at y ~100, drop takes 3 sec to paddle. okay.
- visual power icon on brick: for normal/tough.
- If wide active and catch wide, refresh.
Messages:
drawCanvasToast perhaps DOM `.toast` absolute center top 70. But in-game power notifications should show. We can implement DOM #eventToast absolute left50 top 68, class show. Set text and expiration. Timer with JS not setTimeout? okay. Use `toastUntil=clock+1.6`. Since clock runs. In attract also. DOM update each frame class toggle. CSS.
- Start overlay hint exact.
- End overlay.
Pause functionality? Maybe keyboard 'p'. Could add a button in header. But more code. User asks only game. Polished perhaps include "P pause". We can include small pause button in HUD accessible. If pause, state paused; overlay maybe separate. But then overlay same end. Could add DOM pause panel. Not necessary; arrows etc. Avoid complexity.
Sound:
Add button maybe top header small circle. Could have `#soundBtn` text `SOUND ON`. On mobile hide. But if audio calls no errors. Nice. Yet not requirement and potential issue. Could implement.
Best score in memory:
header perhaps score and lives only. level. Keep space.
HTML:
<div class="stat score-stat"><span class=stat-label>Score</span><strong id=score>000000</strong></div>
<div class=stat><span class=stat-label>Lives</span><div id=lives ...></div></div>
<div class="stat level-stat"><span class=stat-label>Sector</span><strong>01 <small>PRISM</small></strong></div>
<button id=soundButton class=icon-btn aria-label...> icon inline maybe `<svg>` prohibited external? inline allowed. But output must one file, SVG inline okay. Use text "♪".
- lives dots.
At 375 hide labels and level perhaps. Score visible.
- title "PRISM//BREAK" etc.
Canvas background text:
top left `PRISM VAULT` perhaps redundant. top right `56 CELLS • 7 LAYERS`.
Could render score combo.
- top at y28; top border maybe.
Let's ensure 1280x800 screenshot:
body dark background, app center.
topbar 58.
stage 648. Underbar 32.
text crisp.
Attract banner top center maybe white glass.
Bricks colorful.
Game AI active.
Need initial paint meaningful: JS loads immediate and animation. 3 sec screenshot, yes.
Potential need make canvas show even before JS. JS executes after body. fine.
No horizontal scrollbar: body overflow-x:hidden, app width calc. underbar flex wrap perhaps. At mobile controls can hide. `.underbar {display:flex; justify...; min-width:0}`.
Canvas stage aspect.
End overlay buttons.
No localStorage etc.
One fenced code block exactly. We need deliver code. Need ensure no text outside. We can draft comprehensive.
Let's reason implementation order and state carefully.
Variables:
const canvas=...
const ctx=...
const W=1000,H=600;
const colors.
const stars=Array.from...
let bricks=[], balls=[], particles=[], powerups=[], floaters=[], ripples=[];
let state='attract';
let score=0,best=0,lives=3,clock=0,combo=0,comboTimer=0;
let wideUntil=0, shake=0, toastText='',toastUntil=0;
let paddle={x:500,y:548,w:116,h:14,targetX:500};
let keys={left:false,right:false};
let serveTimer=0;
let attractResetTimer=0;
let lastTime=performance.now(), accumulator=0;
let muted=false,audioCtx=null;
let view =...
DOM refs.
Initialize:
resizeCanvas();
createLevel();
state='attract';
spawnBall(W/2, paddle.y-24, random angle, 360);
attractHint.classList.remove('hidden');
requestAnimationFrame(frame).
Maybe ball might immediately no; good.
`createLevel()` builds bricks:
const palette = [
{accent:'#35e0ff', dark:'#0f6d91'},
...
]
for rows:
tough = [1,3,5].includes(r) perhaps all tough. But "mixing normal and tougher" yes.
Maybe every 3rd row tough but row1, row4 only to mix more. use r===1||r===4. 16 tough, 40 normal.
hp= tough?2:1.
power map by coordinate:
const powerMap = {
"0-1":"wide", "1-6":"multi", "2-5":"wide", "3-3":"multi", "4-2":"wide", "5-0":"multi","6-7":"multi"
}
Need row3 maybe normal, row5 normal.
palette per row.
`alive:true, crack:false, flash:0`.
Maybe some normal bricks tough only at select columns too:
tough = r===1||r===4|| (r===3 && (c===1||c===6)) etc. This visibly mix.
- set maxHp.
`resetRound` maybe clear.
`startGame()`:
if state === 'attract' perhaps; score=0 lives3; combo...
createLevel();
balls.length=0...
paddle x center...
state='serve'; serveTimer=.75;
attach ball? create `balls=[makeBall(...)]` but update in serve should not move. draw. If no balls, draw launch orb separately. Better spawn ball with vx0 vy0 at paddle.
`balls=[{x:paddle.x,y:paddle.y-18,r:7,vx:0,vy:0,trail:[]}]`.
state serve. In update serve:
if timer <=0:
const b=balls[0]; angle=(Math.random()-.5)*0.24; set speed; b.vy...
state='playing';
else position attached.
User can press space to launch:
in keydown if state serve and Space: launchServe.
- startGame hides hint, end overlay.
- initAudio.
- toast "SECTOR 01 • BREAK THE GRID".
- best remains.
`launchServe()`:
if state serve and balls length:
b.x=paddle.x b.y...
angle = random small maybe from paddle contact? start centered. set.
state playing.
`endGame(victory)`:
state = victory?'victory':'gameover';
endTitle etc.
endOverlay.classList.remove hidden
endIcon maybe '✦' or '×'
endKicker
endScore.
maybe pointer.
If victory, celebration particles around screen.
`returnToAttract()` perhaps end overlay includes only restart option, not attract. Restart starts fresh. Fine.
`resetAttract()`:
createLevel(); score=0? best maybe; lives3; paddle center; state attract; balls spawn; hint show; end hidden.
At initial.
If attract bricks all break, schedule `attractResetTimer=1.4`, state maybe still attract but balls? update and after timer reset. Could show no end.
Update:
clock += dt always.
updateToast uses DOM class.
if state === 'serve':
updatePaddle(dt,false? player control yes)
move ball with paddle
serveTimer -=dt
if <=0 launch
updateParticles etc
return maybe.
if state !== 'playing' && state !== 'attract': updateEffects only, paddle maybe no.
if attract: updatePaddleAI
else updatePaddleUser
updateBalls(dt)
updatePowerups(dt)
updateEffects(dt)
if state playing and balls.length===0:
lives--
updateHUD
if lives<=0 endGame(false)
else:
state='serve'; serveTimer=.9; balls=[attached]; toast
if attract and balls.length===0:
createLevel? spawn after .5. But updateBalls removes; immediately spawn perhaps.
if attract and all bricks broken:
attractResetTimer -=; if <=0 resetAttract. But updateEffects.
if playing and all bricks broken:
endGame(true)
Need avoid all broken checked before balls update? after.
- Life lost if no balls. We decrement once when state changed to serve so no repeated.
- When multiball one falls, balls length >0.
- In serve, update effects but powerups maybe keep falling; if no balls? no.
- combo timer.
- wide timer.
- bricks flash decrement.
`updatePaddleUser`:
const left=keys.ArrowLeft, right...
if dir: targetX += dir*620dt.
clamp target based current width (if wide toggles, x bounds). Use `paddleHalf()` dynamic.
Important paddle.x target bounds depend w. If widening, x could be near edge and wider extends outside. When width changes adjust x to clamp. `clampPaddle()`.
moveToward x target max 780*dt.
Mouse updates target absolute and clamped.
During serve user control.
`updatePaddleAI`:
target predicted.
move at 720. paddle width 116; ball speed 360.
If wide, easier.
`updateBalls`:
for i reverse:
if state serve skip.
b.trail push; limit 8.
old...
b.x +=...
b.y +=...
wall:
left boundary=24 maybe. if x-r<24 bounce; right.
top=58? Bricks at92 and top wall y=52. Set top=42. Canvas header area. reflect.
Also if speed changes, normalize.
Paddle collision:
oldBottom=oldY+r;
paddleTop=paddle.y-paddle.h/2;
if b.vy>0 && oldBottom <= paddleTop+2 && b.y+r >= paddleTop && x range:
impact = clamp((b.x-paddle.x)/(paddle.w/2),-1,1)
speed = clamp(hypot,380,500)
angle=impact*1.08; // 62deg
b.vx=Math.sin(angle)*speed; b.vy=-Math.cos(angle)*speed;
b.y=paddleTop-r-.1;
effects, sound.
Else if b.vy>0 and broad circleRect intersects: same.
Potential if ball moving downward and is already below top due to dt? Sweep catches.
What if oldBottom was already > paddleTop due side overlap, broad catches and reflect.
What if ball moving upward through paddle from below? Not possible except weird; handle general circleRect and normal reflect.
Brick collision:
for brick alive:
if b.x+r < bx etc continue.
col=circleRect.
if col:
resolve.
damageBrick
maybe `b.brickCooldown` to avoid hitting same brick next substep. Since push out, no overlap. But at corner after reflection could still overlap adjacent. Fine.
break.
Need detect bricks only if ball above paddle. all bricks.
The ball can collide with brick on same step and then paddle no.
- after paddle collision, maybe ball still overlaps brick? no bricks above.
- if ball enters top wall and brick simultaneously no.
Normalize and minimum vertical:
let speed=hypot; if speed >0 scale; if abs(vy)<speed*0.22 set vy sign*...
This can break angle sharp after brick. Fine.
Damage:
`brick.hp -=1; brick.flash=.12;`
if hp>0:
score += 20*combo? show "CRACK"; particles 6.
else:
brick.alive=false; remove? Keep in array for count.
base tough=120 normal=60. combo logic.
score += ...
particles 18
ripple
drop power
sound break
if tough and hp now1 show toast maybe not for every crack.
Need ensure `combo`:
if comboTimer>0 combo=Math.min(combo+1,5) else combo=1; comboTimer=1.5.
multiplier=combo.
For crack base 25.
Particle spawn:
function spawnParticles(x,y,color,count=14,power=1){
for... angle random, speed random 50-220, size random 2-5, life .35-.75, maxLife...
}
colors maybe brick.accent plus white.
`particles` objects.
Powerups:
drop x brick center, y by+bh+2, type, vy=140, rotation...
Draw uses pulsing.
Update:
p.y += (145 + p.vy?)dt; p.rotation.
catch if p.y+10 >= paddleTop and p.y-10 <= paddleBottom+? and x between. Since paddle moving can overlap; catch.
Use previous y crossing:
oldBottom=p.y+10; newBottom...
if newBottom>=paddleTop && oldBottom<=paddleBottom? Could if falling fast 1.2 px per dt. broad.
if x overlap and p.y+10>=top and p.y-10<=bottom+8.
apply; remove.
if p.y> H+30 remove.
- Wide effect:
`wideUntil=clock+10;`
paddle w transitions? We can instantly set to 166, but power effect nice animated. Use `wideVisual` lerp. Collision should use actual effective width. Keep `paddle.w` maybe base + visual?
Define baseWidth=116; `paddle.targetWidth = clock<wideUntil?168:116`; update `paddle.w += (target-paddle.w)*min(1,dt*12)`. Collision uses current w; visual. Wide timer. On activation clamp x.
- if timer expires, width transitions down.
- HUD bar width.
Multiball:
function activateMultiball:
if balls length=0 spawn.
if balls>3 remove.
base=balls[0];
offsets if one [-0.42,0,0.42], if2 [0,0.42]? We want exact3.
Use base angle from velocity. If base.vy >=0? Convert.
`baseAngle=Math.atan2(base.vx,-base.vy)` gives 0 up, positive right. If ball down, -base.vy negative, angle near pi. We want split around current velocity; okay.
For each missing:
angle=baseAngle+offset
ensure not horizontal: clamp to [-1.15,1.15]? If base down, weird. Use upward orientation perhaps:
baseAngle = Math.atan2(base.vx, -base.vy); if base.vy>0, baseAngle = baseAngle>0? 0.45:-0.45 (launch upward).
spawn at base x ± offsets.
Existing base may be traveling down; maybe relaunch all three upward? "multi-ball" exciting. Set all velocities around upward angles [-.42,0,.42], same speed. This ensures action and exactly 3. But caught multiball while ball could be downward; relaunching upward acceptable.
for i 0..2: angle=(i-1)*.42 + slight; b=balls[i] or new; x base.x+(i-1)*12,y base.y; vx=sin*speed,vy=-cos*speed; trail=[].
So exactly 3.
- toast "MULTI-BALL ×3".
Power icon on bricks:
`drawBrickPower`: wide draw horizontal bar with triangles? At small:
ctx.fillStyle white;
roundRect x-6 y-2 width12 height4; triangles left/right.
multi: 3 small circles triangular.
Need differentiate colors maybe background badge dark circle.
- Brick row palette:
r0 cyan, r1 purple, r2 blue, r3 pink, r4 cyan, r5 orange, r6 purple. Tough rows r1/r4 plus some.
Drawing:
`drawBackground`:
fill gradient. Since ctx set transform, gradients logical.
const bg=ctx.createLinearGradient(0,0,0,H); add...
fill.
radial at 500,170 radius400 with rgba(56,189,248,.10)
grid: lines every50, stroke rgba.
stars.
frame: stroke rounded path x14 y50 w972 h535 radius18. top open?
top labels at 28? canvas y 23.
Maybe top row starts y92, fine.
Draw `ctx.fillText('PRISM VAULT', 28,28)` and right `56 CELLS / 7 LAYERS`.
At bottom y580 maybe draw "WIDE POWER ACTIVE" etc? Underbar.
- central board glow.
`drawBricks`: for alive.
Color helpers:
function hexToRgb, mixHex.
shade(hex, percent): parse and add.
Gradient.
Rounded path helper with r.
For normal:
shadowBlur maybe 9.
fill gradient.
stroke.
top highlight line.
For tough hp2:
fill gradient '#34445b' to '#182337'; stroke accent alpha. Draw side bands and small bolt circles.
hp1:
fill dark, stroke rgba. crack.
Flash overlay.
Power icon.
Need maybe render text "2" on tough bricks? Could show tiny dots at right indicating hits. But crack enough. Could show `••`? Maybe tough full has two small pips in corner. This conveys. Use pips at top right.
`drawPowerup`:
save translate; rotate; shadow. outer diamond maybe.
draw rounded square 20x20, gradient.
type wide: ↔ drawn with lines; multi: 3 dots.
pulse aura.
`drawPaddle`:
shadow. width dynamic. shape with rounded ends:
r=8; gradient horizontal? fill vertical.
draw line from x+8 to x+w-8 at y center? Neon center.
side small caps.
if wide active aura line.
`drawBall`:
trail points. Need each b.trail stores {x,y}. Draw segments with decreasing alpha/width. Use b.vx etc.
ball radial gradient white center to accent.
`drawEffects`.
`drawFloaters`: text.
DOM toast:
toastEl text; class show if clock<toastUntil. Use `toastUntil` based clock. Since clock increments. Initial toast maybe.
HUD:
function updateHUD() {
scoreEl.textContent=String(score).padStart(6,'0');
lives spans toggle.
level fixed.
wide status:
const remaining=Math.max(0,wideUntil-clock);
wideBadge.classList.toggle('active', remaining>0);
wideTime.textContent= remaining>0? remaining.toFixed(1)+'s':'READY';
wideBar.style.width = ...
comboEl perhaps canvas.
}
Call each frame maybe okay.
Best maybe update if score>best but no display. Could display BEST in end maybe.
End overlay:
HTML:
<div class="end-overlay" id=endOverlay aria-hidden=true>
<div class=end-card>
<div class=end-icon id=endIcon>✦</div>
<p class=end-kicker id=endKicker>SECTOR COMPLETE</p>
<h2 id=endTitle>Prism shattered.</h2>
<p id=endCopy>Every cell is down...</p>
<div class=final-score><span>FINAL SCORE</span><strong id=finalScore>...</strong></div>
<button id=restartButton>PLAY AGAIN <span>↻</span></button>
</div>
</div>
CSS hidden with opacity pointer-events.
Attract hint:
<div id=attractHint class=attract-hint>
<div class=demo-dot></div>
<div><span class=eyebrow>AI DEMO RUNNING</span><strong>CLICK OR PRESS A KEY TO START</strong></div>
<button id=startButton>TAKE CONTROL</button>
</div>
Position top 12 left50 transform. On mobile strong smaller; button maybe.
No interaction before click. Start button pointer event triggers. Attract hint itself should pointer-events none except button. Canvas behind. If click elsewhere canvas triggers.
Canvas pointer:
function setPointer(e) {
const rect=canvas.getBoundingClientRect();
const x=(e.clientX-rect.left)/rect.width*W;
paddle.targetX=clamp(x,...)
}
canvas.addEventListener('pointermove', e => {set; if state==='attract'? don't start just move? We shouldn't move AI paddle based mouse in attract. only if playing/serve.
});
canvas.addEventListener('pointerdown', e=> {canvas.setPointerCapture?; set pointer; if attract startGame(); else if serve launchServe();});
If startGame after set target? startGame sets target center, so click location lost. Modify startGame preserve pointer maybe. In pointerdown: if attract { const x=...; startGame(); paddle.targetX=x; paddle.x=x maybe; }.
`startGame` might be called key.
StartButton click calls startGame.
End restart calls.
Any key start:
document.addEventListener('keydown', e=>{
if (e.repeat && state==='attract')? first starts.
if state==='attract' { if(["Tab"].?) any key except modifiers? Start on key not with ctrl/meta/alt. `if(!e.ctrlKey&&!e.metaKey&&!e.altKey){ e.preventDefault(); startGame(); }`
Then if e.key ArrowLeft set keys...
For first arrow, key state set after start.
if end and Enter/Space restart.
if state serve and Space launch.
arrows prevent default and set.
if p toggle pause maybe no.
});
Need button focus and key event. If user tabs to button and presses Enter, document catches start; fine.
- prevent Space page scroll always when game.
- keyup.
Pause? Could implement P and overlay. Not needed.
State when end and key any? Only Enter/Space restart; requirement restart option button. fine.
Start message in first paint. It must be behind a message. We have.
Game over / victory screens. Good.
"ball must never pass through bricks or paddle." We should perhaps implement more rigorous collision with continuous detection to satisfy evaluator maybe code review. Fixed dt 1/120 and radius. Add substepping inside updateBalls based speed:
At each fixed update, speed 500 means 4.2px. Brick h24 and r7, cannot cross. But corner movement can cross gap and skip narrow brick if x movement > width? x movement 4.2 < brick width88, so can't skip a whole brick. It could pass through gap width9 horizontally if trajectory exactly diagonal and circle radius7 diameter14 > gap, so can't. fine. Paddle h14 and vertical step4, circle collision. robust.
Could mention not in final text, only code comments.
Potential ball passes top through bricks? no.
Victory in attract: AI could clear 56 in ~30 sec. screenshot only 3 sec. fine.
At attract, score maybe accumulates and best. But header score visible changing. Great.
Need make game visible in first 3 secs and not too dark. Bricks bright.
No console errors:
- use `ctx.roundRect`? manual.
- DOM refs exist.
- ResizeObserver exists latest.
- `toFixed`.
- event `e.key`.
- Audio.
Need perhaps use `aria-hidden` and button type.
Let's formulate code carefully. We can include comments. One code block. Need ensure exact fenced html and no text.
Let's detail CSS:
:root color-scheme dark; --...
* box-sizing.
html,body {margin:0; min-height:100%; }
body {
min-height:100svh;
display:grid; place-items:center;
padding:18px;
overflow-x:hidden;
background:
radial-gradient(circle at 15% 10%, rgba(34,211,238,.09), transparent 30%),
radial-gradient(circle at 85% 90%, rgba(139,92,246,.11), transparent 30%),
#050a12;
color...
font...
}
body::before maybe fixed noise via repeating linear gradient.
.app {width:min(1080px,100%); display:grid; gap:11px;}
.topbar {min-height:55; display:flex; align-items:center; justify-content:space-between; gap}
.brand display flex...
.brand-mark 38 square with pseudo etc. Use CSS grid 3x3 dots perhaps.
.logo h1 margin 0 font-size22 letter spacing; `PRISM` and `BREAK`.
.brand p.
.stats flex.
.stat {background rgba; border; border-radius12; padding 8 12; min-width}
.stat-label uppercase 8px.
.stat-value 16.
.lives display flex gap4; life dot 9 circle with radial gradient and shadow.
.level .value small.
.sound button 34.
.stage aspect-ratio:5/3; position; border...
canvas.
.attract-hint position absolute top:12px left50%; transform; display flex; etc; z-index.
Maybe at desktop it overlaps top label inside canvas but okay. It has dark glass.
.demo-dot animation pulse.
strong exact 11px.
button.
.event-toast position top:18%? maybe 72px; `transform translate(-50%,-8px); opacity0`; top 16%; It could overlap bricks but transient.
.end-overlay absolute inset; background rgba; backdrop-filter blur(4px); display grid; opacity0; pointer-events none; transition.
.end-overlay.show opacity1 pointer.
end-card.
Underbar:
display flex align center justify-between; color; font 10; min-height28.
.control-group flex.
kbd style.
.legend.
wide-meter width120 with bar.
At mobile:
body padding 8; align-items:start maybe center still.
.app gap8.
topbar min-height48.
.brand-mark 32; h1 15; brand p hidden.
.stats gap5.
.stat padding6 8; min-width...
.stat-label display none for score/lives? Could show.
score strong 13.
.level-stat display none.
.sound-button display none maybe.
.stage border radius12.
.attract-hint width calc(100%-16); top8; padding7; strong font8; button...
.underbar maybe .legend hide or simplify; controls show.
At max-width520 .brand p none, .brand-mark, .stat; stage. Attract.
No horizontal.
Canvas actual ratio at app width 1080 ->648. Total body 18*2 +55+11+648+11+28=771? app grid gaps 11 twice =22: 55+648+28+22=753; plus36=789. yes.
Event toast top: 76px maybe first brick y92 and css scale .? at logical scale1.08, y76=82 CSS. Could overlap row. But transient. Place top:66.
Canvas render top labels at y27. attract hint top CSS 12 to maybe60, covers label. Fine.
Need maybe `stage` box-shadow.
JS utilities.
Let's write precise code.
DOM refs:
const $ = id=>document.getElementById(id);
const canvas=$('gameCanvas'), ctx=canvas.getContext('2d');
const scoreEl...
const lifeEls=[...document.querySelectorAll('.life')];
...
No top-level `const audioCtx` then reassign. let.
resize:
function resizeCanvas(){
const rect=canvas.getBoundingClientRect();
const dpr=Math.min(2, window.devicePixelRatio||1);
const cw=Math.max(1,Math.round(rect.width*dpr));
...
}
ResizeObserver. Browser supports. Add window listener fallback.
roundedRect:
function roundRect(c,x,y,w,h,r){
r=Math.min(r,w/2,h/2);
c.beginPath();
c.moveTo(x+r,y); c.arcTo(x+w,y,x+w,y+h,r); ...
c.closePath();
}
ArcTo works.
Color:
function hexToRgb(hex) ...
function shade(hex,amount){ amount maybe -? parse and clamp; if amount positive add, negative subtract. return rgb.
}
function withAlpha(hex,a) `rgba(...)`.
Need accent hex.
Stars random deterministic? Math.random okay. `Array.from({length:70},...)`.
createLevel:
bricks=[];
const powerMap = new Map([...]);
const toughRows ...
for...
const accent = palette[r].accent;
const tough = ...;
hp...
id.
Potential row1 all tough and row4 all tough. Mix.
resetAttract:
createLevel();
score=0; lives=3; combo...
balls=[makeBall(W/2,paddle.y-25, random angle, 340)] but `makeBall` pushes? Define returns.
paddle.w base maybe.
state='attract';
attractHint class...
end...
spawn.
startGame:
if audio init; score0 etc; createLevel; arrays clear; wideUntil=0; paddle; state serve; serveTimer=.85; balls=[makeBall(...)] with vx0; hide overlay/hint; toast.
`makeBall`:
return {x,y,vx,vy,r:7,trail:[]}.
`launchServe`:
b = balls[0] or make;
angle = (Math.random()*2-1)*0.12;
speed=395;
b.vx=sin; vy=-cos;
state='playing';
toast maybe not.
Audio:
function initAudio(){ if(!audioCtx){ const AC...; if AC audioCtx=new AC(); } if(audioCtx.state==='suspended') audioCtx.resume().catch(()=>{});}
function beep(freq=...){
if muted||!audioCtx return;
const now=...
const osc...
gain.gain.setValueAtTime(.0001,...)
exponentialRampToValueAtTime(.035, now+.008)
exponentialRampToValueAtTime(.0001,now+duration)
...
}
Avoid errors if state closed.
`audioCtx.resume().catch` supported.
sound button click toggles; if unmuting init.
Update:
function update(dt){
clock+=dt;
comboTimer-=dt; if<=0 combo=0;
toast handling after.
if(state==='serve'){updatePlayerPaddle(dt); updateAttachedBall(); serveTimer-=dt; if<=0 launchServe(); updateEffects(dt); return;}
if(state==='playing'||state==='attract'){
if attract updateAIPaddle else updatePlayer
updateBalls(dt)
updatePowerups(dt)
updateEffects(dt)
if(state==='attract' && balls.length===0){ resetAttract(); return;}
if(state==='attract' && bricks.every...){ attractResetTimer-=dt; if<=0 resetAttract; }
else attractResetTimer=1.5;
if(state==='playing'&&balls.length===0){ lives--; updateHUD(); if(lives<=0){endGame(false);} else {state='serve'; serveTimer=.9; balls=[makeBall(paddle.x,paddle.y-20,0,0)]; showToast(...);}
if(state==='playing' && bricks.every(...)){endGame(true);}
} else updateEffects(dt);
updateHUD();
}
Important in `state=serve`, updateHUD called? yes after return currently return prevents. Need call before return. restructure no return or call.
Effects update includes brick flashes.
Attract reset when no balls: If AI missed despite prediction, instantly reset; fine.
Paddle width update should happen all states:
function updatePaddleWidth(dt) target wide if clock<wideUntil else base; w += ...; clamp.
Call update at top.
`wideUntil` and clock. In attract wide power can activate, fine.
Update player:
keyboard target. mouse target persists. If no pointer movement, paddle stays.
moveToward.
`clampPaddlePosition`:
const half=paddle.w/2; paddle.x=clamp(paddle.x,half+18,W-half-18); target same.
AI:
find downward; t=(paddle.y-b.y)/b.vy; predicted x=b.x+b.vx*t; clamp; maybe target predicted but ball can bounce walls. AI likely.
If no down, target=W/2 + sin(clock)*80.
move speed 620.
`moveToward(current,target,maxDelta)`.
Attached ball:
if balls[0] set x=paddle.x,y=paddle.y-paddle.h/2-18.
Update balls:
We might update b.trail before moving; for serve skip.
`b.trail.push({x:b.x,y:b.y}); if >10 shift`.
Wall collisions and effects.
top boundary maybe 43. But frame line at 50. Set top=42. When ball hits top, y=42+r.
left 18.
Paddle collision helper:
function collidePaddle(b, oldX,oldY) {
const top=paddle.y-paddle.h/2;
const half=paddle.w/2;
const oldBottom=oldY+b.r;
if (b.vy>0 && oldBottom <= top+1.5 && b.y+b.r >= top) {
const denom=b.y-oldY; t=denom>0 ? clamp((top-oldBottom)/denom,0,1):0; // crossing y+r = top -> b.y target=top-r.
hitX=oldX+(b.x-oldX)*t;
if(hitX>=paddle.x-half-b.r && hitX<=...) {bouncePaddle(b,hitX); return true;}
}
// broad collision regardless velocity
const dx=b.x-clamp(b.x,paddle.x-half,paddle.x+half);
const dy=b.y-clamp(b.y,top,paddle.y+paddle.h/2);
if(dx²+dy² < r²) {bounce; return true}
return false
}
bounce:
impact clamp((hitX-paddle.x)/half)
angle=impact*1.08. At edge 62°. "sharp". Could max 1.12 (64°). speed.
b.x maybe hitX; b.y=top-r-.1; b.vx sin(angle)*speed; b.vy=-cos(angle)*speed.
Need maintain center straight exactly if impact near0. yes.
On side broad, hitX current may outside but clamp impact edges.
Particles at hit.
Wall collision if b.x-r < 22.
Boundary bottom isn't bounce; falls.
Brick collision:
function collideBricks(b) {
for brick:
if !alive continue
if b.x+b.r<bx... continue
const n=circleRect...
if(n){
b.x+=...
b.y...
const dot...
if(dot<0) reflect.
let speed=hypot; clamp 370,520.
// If speed less? preserve.
// ensure vertical magnitude
b.vx*=speed/b.hypot etc.
damageBrick
return true
}
}
}
Potential speed clamp modifies after reflection but if speed 400 okay. If speed grows.
`circleRect` outside:
const qx=clamp, qy; dx...
if distSq >= r² return null.
if dist>0.0001 normal; penetration=r-dist.
else inside:
distances to expanded boundaries? Circle center inside brick. Choose based incoming velocity:
let toLeft=b.x-bx, toRight=bx+bw-b.x, toTop=b.y-by,toBottom=by+bh-b.y.
choose min. If toTop min, normal y=-1, penetration=r+toTop? To push center to by-r: current y - (by-r) = toTop+r. yes. left penetration=r+toLeft. etc.
return.
At a brick corner outside, dist and penetration.
After brick reflection if ball gets almost horizontal: `minVy=speed*0.25`; if abs(vy)<minVy, set vy=(vy<0?-1:1)*minVy, renormalize.
Damage:
combo etc. For tough first hit, score maybe30 and crack. For normal break base60.
`const mult=Math.max(1,combo)` after update.
Tough break base140. normal70.
Maybe score values.
`brick.flash=0.14`.
On crack, spawn 5 and beep 120.
On break, spawn 18 + mini ring; if brick.power spawn.
`showToast` only for power? power item drop toast maybe not every. Could show `POWER DROPPED` maybe too noisy. Skip.
Power update:
for reverse:
p.prevY; p.y += 135dt;
p.rot +=...
catch:
const top...
if p.y+10>=top && p.y-10<=bottom+10 && p.x>=paddle.x-half-8 && ...
apply.
Potential if p spawned and paddle far.
`applyPower`:
wide: wideUntil=clock+10; show toast("WIDE PADDLE • 10 SECONDS"); beep.
multi: activate; toast.
activateMulti:
base x y from balls[0]. Ensure three.
for i 0..2:
if existing else make.
angle=(i-1)*0.42; speed=410 + maybe.
set.
paddle maybe.
Effects:
for particles reverse; life.
ripples.
floaters.
bricks flash.
ShowToast:
toastText=text; toastUntil=clock+1.8; toastEl.textContent=text; class add. Since clock increments. In update:
if(clock>toastUntil) remove.
Draw:
function render(){
const sx=canvas.width/W, sy=canvas.height/H;
ctx.setTransform(sx,0,0,sy,0,0);
ctx.clearRect...
drawBackground();
ctx.save();
if(shake>0) translate(random)
drawBricks(); drawPowerups(); drawPaddle(); drawBalls(); drawParticles(); drawRipples(); drawFloaters();
ctx.restore();
draw top labels maybe background already.
}
Canvas width actual. `ctx.globalCompositeOperation` reset.
Background:
create gradient each frame. Fine.
`ctx.save()`.
fill.
grid lines.
stars with alpha.
Maybe `ctx.fillText` labels; font.
Frame stroke.
Combo display if combo>1 at top right.
Top label should appear above attract hint? render canvas under hint.
Frame path at x=14,y=48,w=972,h=538; bottom586. Paddle y548.
drawBricks:
for:
const x...
shadow. Use fill.
Need `ctx.shadowBlur=0` after.
Flash.
Power badge.
Tough pips.
crack.
Let's implement draw normal:
const grad=ctx.createLinearGradient(x,y,x,y+h);
grad.addColorStop(0,shade(accent,30)); grad.addColorStop(.45,shade(accent,-5)); grad.addColorStop(1,shade(accent,-38));
if tough:
grad from shade(accent,-10) maybe to '#111b2c'. But all tough should appear dark metal.
if crack dark.
`ctx.globalAlpha`.
RoundRect fill.
Stroke.
inner top line with white alpha.
Tough full: vertical stripe on left 4px accent, rivets.
Crack:
ctx.beginPath; points relative; strokeStyle rgba(255,255,255,.55); lineWidth1.2; shadow black perhaps. Another branch.
Also chipped triangular corner maybe fill background.
Power icon after.
Flash overlay `ctx.fillStyle rgba(255,255,255,flash/.14*...)`.
drawPowerIcon:
At center. For wide: background small circle `rgba(4,12,22,.4)` then line with arrowheads.
Multi: three dots.
Color white.
drawPaddle:
w current. gradient vertical from #dffcff to #1ec9f4 to #6a5cff. Maybe fill.
roundRect.
shadow.
core line.
if wide active show glow.
drawBalls:
trail. b.trail maybe points. Use `ctx.lineCap='round'; for segments`. Each segment alpha. Then radial.
Need `ctx.globalCompositeOperation='lighter'`.
drawParticles:
for p alpha; save translate rotate fillRect.
drawRipples: circle stroke.
drawFloaters: stroke black then fill.
Need maybe display ready text when serve. Draw on canvas bottom? We can in render:
if state serve:
ctx.save; font bold 12; fillStyle; textAlign center; fillText('CLICK OR PRESS SPACE TO LAUNCH', W/2, paddle.y+44)
But initial start overlay message no. In serve after user starts, clear instruction. Good.
In attract, maybe draw "AI DEMO" near paddle? HTML hint.
Canvas top labels:
`ctx.font='700 10px ...'; fillText('PRISM VAULT',28,27)`; right.
Resize observer after DOM layout. call.
HUD update:
score formatting no locale weird. `String(score).padStart(6,'0')`.
lifeEls: set class dead if index>=lives.
wideBadge: active if remain>0; bar.
`wideTime.textContent = remain>0 ? remain.toFixed(1)+'s' : '—'`.
Maybe class.
End:
`best=Math.max(best,score)`.
final best.
endTitle.
`endOverlay.classList.add('show')`.
`endIcon.textContent = victory?'✦':'×'`.
For gameover title "Out of lives." copy.
Victory particles:
for 50 random across top with colors. Use spawnParticles perhaps.
Button restart:
startGame(); focus canvas maybe.
Canvas tabindex=0 so focus.
Potential browser event key press to start when focus button: document keydown startGame then default click might trigger button start again. E.g start button focused and Space: keydown document state attract -> startGame; then button default click on keyup calls startGame again resetting. Could preventDefault in keydown. Do `e.preventDefault()` when start. good.
Sound button in attract click shouldn't start game? It is header outside canvas. Fine.
Game over restart button event.
`restartButton.addEventListener('click', e=>{e.stopPropagation(); startGame();})`.
Attract hint button pointer event; stage doesn't have generic click, only canvas. fine.
Maybe initial game automatically AI and banner. `startButton` click.
Need support `keydown` any key:
if state==='attract':
if e.ctrlKey... return
e.preventDefault();
startGame();
// then if arrow set held below
Then state now serve, arrow works.
For key `' '` after start it could launch? startGame then continuing code sees state serve and key Space, launch immediately. That's okay maybe.
Pseudo:
function onKeyDown(e){
if(e.ctrlKey... )return;
const k=e.key;
if(state==='attract'){ e.preventDefault(); startGame(); }
if(k==='ArrowLeft'||k==='ArrowRight'){ e.preventDefault(); keys[...] = true; if(state==='serve') maybe not launch; return;}
if(k===' '){ e.preventDefault(); if state serve launch; else if end restart; return;}
if(k==='Enter'&&end)...
if(k.toLowerCase()==='p')...
}
First arrow start then sets true, state serve. update paddle target in serve, yes.
Any key like 'a' starts but doesn't move. Fine.
In end state Enter restart. `startGame` always.
Maybe `Space` in attract should start and launch, code after start sees state serve and calls launch. Good.
`pointerdown` canvas:
set pointer; if attract startGame then paddle target=x and maybe paddle.x=x? Let's do startGame({pointerX:x}) optional.
function startGame(pointerX=null) { ... if pointerX != null {paddle.x=clamp(pointerX,...);paddle.targetX=paddle.x;} }
Start button no pointer.
In serve pointerdown launches immediately (first click start if attract then state serve; could launch same click? requirement click start, launch yes perhaps. In pointerdown:
if attract { startGame(x); launchServe(); } else if serve launch.
Then game active.
Potential attract banner covers canvas top, clicks on it don't reach canvas except button. User can click board.
Mouse movement in playing sets target.
Pointer capture unnecessary.
Game balance: paddle AI prediction and ball can get stuck vertical. Damage brick reflect. Bricks gaps. Good.
Let's estimate score and game duration. Ball speed395, board vertical 440. A round trip1.8s. AI tracks. It will break bricks. 56 cells tough total72 hits maybe 2min. Victory possible. User can play.
Power drops and catch. AI might collect.
Ball speed can slowly increase on brick hits up to520, paddle can handle. fixed.
Multi-ball may make victory fast.
Lives 3. Good.
Visual crack after first hit: property `hp=1` and `crack=true`; draw. Tough bricks at row1/4. First hit score and crack.
Power bricks visibly icon. yes.
"when broken they drop it" Tough power brick only drop after second. yes.
Potential normal power brick gets hit and breaks; drops exactly.
No power spawning from nonpower.
"catching it with paddle gives either wider paddle (10 seconds) or multi-ball (3 balls)." icons correspond.
We should perhaps show legend matching colors: wide cyan icon, multi purple.
Canvas resize dpr and transform. On high DPR, actual dimensions. CSS stage aspect ratio. Good.
Let's prepare code. Need be careful with quotes.
Potential `ctx.arcTo` roundRect and `ctx.fill`.
Draw star positions includes x,y,size,phase.
Grid perspective maybe.
End overlay initial CSS `visibility:hidden`, `.show visibility:visible`. pointer.
Attract hint transition.
CSS class `hidden` maybe use. Use `aria-hidden`.
Toast transform.
Let's create final code likely ~1000 lines? Desired no issue. We need output only code block.
Before final, let's thoroughly test mentally and possibly simulate code semantics.
Detailed JS:
(() => {
'use strict';
...
function createLevel() { ...}
function makeBall...
function resetAttract()...
...
resizeCanvas();
resetAttract();
requestAnimationFrame(loop);
})();
Variables must be declared before reset.
`ResizeObserver` callback can call resize before initial; okay.
`drawBackground` uses `clock` twinkle.
Color `shade` returns `rgb(r,g,b)`, and `withAlpha` expects hex. fine.
Canvas `roundRect` helper has width maybe 0 no.
Power icon:
function drawPowerIcon(c,x,y,type,scale=1){
c.save(); c.translate...
c.fillStyle='rgba(3,10,20,.36)'; c.beginPath(); c.arc(0,0,8*scale,...); fill
c.strokeStyle='rgba(255,255,255,.92)';
if wide: line x-4 to4; arrow heads.
else: 3 circles at (-3,2),(3,2),(0,-3)
c.restore
}
At brick scale .75 -> 12 px badge.
Draw brick tough crack uses path.
`drawBall`: trail points and current. `b.trail` points perhaps no current; segments.
`drawPowerups`: p rotation; shadow. gradient from type color. for wide accent cyan, multi purple/pink. Use `type==='wide'?'#42e8ff':'#b98cff'`.
UI wide badge uses `data`.
`updateHUD` each frame manipulates class; fine.
Attract AI and `state` reset:
In update:
```
if (state==='serve') { ... updateEffects; updateHUD; return; }
if (state==='playing'||state==='attract') {
...
updateBalls
...
const cleared=...
if state attract:
if no balls resetAttract return
if cleared {attractResetTimer-=dt;if<=0 resetAttract();}
else ...
}
```
Need if playing all bricks cleared same frame and balls array maybe one fell causing balls0; code checks no balls first, decrement life, then all bricks check and victory. If last brick broke and ball then goes below same frame unlikely. Order should check victory before life loss. Do all cleared first then end victory, then balls0. Better.
updateBalls may remove balls. Then:
if state playing && allBroken -> end victory return.
if no balls -> life.
Attract if all broken delay but balls remain.
`bricks.every(b=>!b.alive)` on array always.
At reset create bricks.
`updateEffects` decrements `brick.flash`.
When end, effects update next frames; state end branch.
`endGame` sets state, overlay. `balls` remain frozen because updateBalls only in playing/attract. Great.
`resetAttract` should not call initAudio; no.
`showToast` in attract maybe.
`startGame` `best` retained.
`updatePaddleWidth`: At beginning before user/AI. `targetWidth = (clock<wideUntil)?166:116; paddle.w += ...;`.
If wide expired, x bounds changes gradually; clamp.
`updatePlayerPaddle`: arrow held. If target at bounds and paddle width grows, clamp target.
Paddle target from mouse with half current.
`clampTarget` function.
AI target predicted:
```
let target=W/2;
let candidate=balls.find(b=>b.vy>0);
if(candidate){
let t=(paddle.y-candidate.y)/candidate.vy;
if(t>0&&t<3) target=candidate.x+candidate.vx*t;
else target=candidate.x;
}
target=clamp...
```
If ball moving down behind paddle? y less? t negative. use x.
move max 650.
Physics:
When b collides wall and x corrected, trail.
`b.x` may not be inside.
Order paddle before brick. Fine.
Circle-rect function:
```
function circleRectCollision(ball,rect) {
const qx=clamp(...); ...
let dx=...
let dist=Math.hypot
if(dist >= ball.r) return null;
if(dist>0.00001) return {nx:dx/dist,ny:dy/dist,pen:ball.r-dist};
const left=ball.x-rect.x, right=..., top...,bottom...
let min...
if(top<=...) return {nx:0,ny:-1,pen:ball.r+top}
...
}
```
At center exact.
`collideBricks`:
Push by pen+.02. If normal outward.
dot if <0 reflect. If dot >=0 (inside and moving away) still push but no reflect; could be weird. fine.
normalize:
```
let sp=Math.hypot; if(sp){ b.vx*=...}
const minY=sp*.24; if(abs(b.vy)<minY){b.vy=(b.vy<0?-1:1)*minY; normalize}
```
If ball moving directly horizontal due collision, correction.
Damage normal:
combo logic. On first hit normal combo1 score70. On second different within1.4 sec combo2 score140. Good.
`combo` resets after time.
Floating:
`addFloater(x,y,text,color,size=12)`.
Power drop:
powerups.push({x:..., y:..., type, rot:0, vy:0});
Particle colors. `brick.accent`.
`spawnParticles` with gravity 380; life.
`updatePowerups`: catch if broad. p.y + 10 crossing.
if x within half+8 and p.y+10 >= top && p.y-10 <= bottom+10. This could catch item that overlaps paddle from above. okay.
`activateMulti` modifies balls array. If balls empty, create. base. It relaunches all upward. if base.x at brick? power caught so near paddle.
Potential 3 balls all same x and could collide with each other? We don't implement ball-ball collisions, so fine.
Ball never passes through each other not required.
`updateBalls` loops reverse; activate multi only outside after update so fine.
`beep`: if no user gesture in attract don't. Damage in attract calls beep but audioCtx null so no.
`showToast`: DOM.
`render` background every frame.
`drawBackground`:
```
ctx.fillStyle=bg; fillRect
radial...
ctx.strokeStyle rgba; lines...
stars...
ctx.strokeStyle...
roundRect(14,48,...); stroke
ctx.font ...
```
Canvas logical W.
Grid lines behind.
Stage border and CSS.
`drawBricks` shadows:
Set ctx.save per brick to avoid.
Gradient color.
For tough hp2:
```
grad.addColorStop(0,'#3a4960'); .25 shade(accent,-15); 1 '#101a2b'
```
Accent border.
normal bright.
hp1 crack.
`ctx.fillStyle` overlay flash.
Power badge.
Power icon visible even crack.
`drawPaddle`:
Use `ctx.shadowColor` and blur.
roundRect.
Add horizontal shine.
Maybe paddle uses `paddle.y` as center; yes.
`drawBalls`: if serve b.vx=0. trail empty. ball.
`drawEffects`.
`render` draws labels in background before shake; bricks may overlap frame.
Ready text:
```
if(state==='serve'){ ctx... fillText('CLICK OR PRESS SPACE TO LAUNCH', W/2, 582); }
```
Paddle at548, bottom frame586. y580 works.
Attract AI maybe add label near bottom "AI IS PLAYING" no need.
Canvas focus outline none.
HTML top:
```
<div class=brand-mark aria-hidden=true><span></span>...
```
CSS mark has `::before` etc. Could use three lines.
No external images.
Let's ensure viewport 375: `.stats` includes score stat and lives; level hidden via CSS. `.sound-button` hide. brand mark maybe. Width:
brand: mark32 + gap8 + title ~80 =120.
stats: score stat ~75 + lives ~52 + gap6=133. total gap maybe 10 =>263 fits359. good.
At very narrow 320 maybe fit 280. fine.
Attract hint desktop width maybe 540. mobile width calc(100%-16), content strong can wrap. `white-space:nowrap` might overflow. remove nowrap, font8.
button 82.
Start hint behind AI but at top. exact text uppercase. User asks "Click or press a key to start" perhaps case-insensitive. Strong text `CLICK OR PRESS A KEY TO START` exact words.
At initial, header score displays 000000; after attract moves score may update. fine.
Developer says "behind a 'Click or press a key to start' message", yes.
Could start demo after load and AI plays. Great.
Need maybe ensure a click anywhere on page? Only canvas/button. Requirement general. Could add `window.addEventListener('pointerdown', if target not button and attract startGame)`, but then clicking sound would also start. Not harmful maybe but undesirable. Keep canvas and explicit button.
Could message say click board. Fine.
Let's consider browser console zero errors with `ResizeObserver` callback and `canvas.getBoundingClientRect` if width 0? max1. no.
`ctx.setTransform(canvas.width / W, 0,0,canvas.height/H,0,0)` valid.
`ctx.clearRect` under transform clears logical area exactly canvas. yes.
Audio `oscillator.stop` no error.
Use `performance.now`.
Potential `roundRect` manual path and `ctx.fill` okay.
One issue: `drawBackground` radial gradients use `ctx.createRadialGradient`, fine.
One issue with `ctx.globalCompositeOperation='lighter'` restored.
One issue with `drawBackground` uses `ctx.shadowBlur` leftover from previous? Each draw save/restore or set. Ensure background resets.
`drawBricks` each save/restore.
Let's write code now mentally and then final. But token budget huge, we can refine.
Maybe include a pause functionality and sound? Not necessary. Sound button implementation adds polish but could potentially fail due audio. It is safe. Header top already. Let's include sound toggle as small `button` labeled `♪` and title. On mobile hide. It uses SVG? Could use unicode `♪`; no external.
Underbar wide badge:
```
<div class="power-readout" id=wideReadout>
<span>WIDE</span><div class=meter><i id=wideMeter></i></div><strong id=wideTime>—</strong>
</div>
```
Legend:
`<span><b class="power-glyph wide">↔</b> WIDE PADDLE <small>10s</small></span>`
multi.
No external font.
Game title maybe "PRISM BREAK". Top-level h1.
End panel icons.
Victory screen:
- title "Prism shattered."
- kicker "VICTORY // SECTOR 01"
- copy "Every brick is down. The vault is yours."
Game over:
- title "Signal lost."
- copy "The last ball slipped through. Rebuild and try again."
- button.
Restart option.
Could include high score final. `<div class=best-line>Session best ...`.
Best updated on end; display final.
Attract reset score0 but best remains. no storage.
Maybe title "PRISM BREAK" enough.
Let's ensure game has at least 6 rows visibly. 7.
Could include high score final. `<div class=best-line>Session best ...`.
Attract reset score0 but best remains. no storage.
Maybe title "PRISM BREAK" enough.
Let's ensure game has at least 6 rows visibly. 7.
Let's calculate canvas brick area and start banner:
logical y=92, CSS scaled 1.08=99px. Attract hint top12, height52 ->64, no overlap. Great.
Ball top y=42 logical =45 CSS, frame starts52. Attract hint overlaps ball region top but ball rarely.
Stage height648. under.
Potential visible first screenshot after 3 seconds: AI likely ball in lower half or brick; if AI misses and reset at exactly? AI precise.
Let's simulate AI:
ball launch from paddle at y520 upward at x500. Bricks y92. after1.1 sec hits top brick. Reflect maybe downward. AI tracks predicted. paddle at x. catches. Great.
AI may hit a normal brick, particles. At t~1.2. screenshot3 sees action.
Maybe first ball hits a brick and then gets stuck horizontal due exact vertical? circle collision from below or above? Ball launched upward. It hits bottom of top row, circle collision outside bottom: closest point qy=by+bh, ball below, dy negative, normal ny=-1 (push down), reflects vy positive. fine. Then paddle.
Bricks h24 and ball diameter14 gaps9, circle cannot pass gap? Let's examine ball radius7, gap9, center can pass through gap if x exactly gap center, its circle overlaps adjacent bricks because radius extends 2.5 into each brick if brick edges gap boundaries: ball center gap center 4.5 from each edge, radius7 -> overlaps2.5, so collides corners and likely deflects, not pass. good.
Paddle bounce angle:
`impact=(hitX-paddle.x)/half`; edges sharp. If ball hits exactly edge, ±1.08 61.9°. good.
One concern: paddle top y=541 if center548 h14. Ball caught at y=534. Bottom wall frame at586. enough.
Lives dot updates.
`wideUntil=clock+10`; clock always increments even state end. During end if wide expires irrelevant. In serve clock.
Power multi gives 3 balls. yes.
Let's consider tough bricks `hp` after first hit; if first hit `hp=1`, draw crack. On second `hp=0`, broken. power drops. good.
Particles when brick breaks. yes.
One requirement "ball must never pass through bricks or the paddle." We could implement fixed step and circle collision. To be extra safe, add adaptive substeps inside `updateBalls` itself even though fixed 1/120. We could call a substep function with max travel 3 px. But then collision/damage logic. We can set fixed dt=1/180 even smaller. Current 1/120. Max speed520 travel4.3, brick h24. guaranteed. Paddle h14, vertical collision swept. Good.
Potential vertical step 4.3 can pass through 14px if ball crosses entire paddle between frames? Swept detection catches crossing because oldBottom<=top and newBottom>=top. If oldBottom already below top due side, broad. no.
Brick can pass if ball enters and exits in one step? brick thickness24 plus diameter14 total38, step4.3 no.
Code comments mention swept.
Let's perhaps set fixed dt=1/180 for extra smooth, but performance fine. 56 bricks*balls*180 ~30k checks/frame? Actually per second 56*3*180=30k, trivial. Use 1/120. 4px.
Rendering at 1280.
Let's now draft final code. Need exactly one fenced code block. I'll ensure no accidental text.
We can use comments within file.
Let's lay out HTML.
Potential duplicate developer message irrelevant.
Let's write CSS thoroughly.
One concern HTML initial canvas fallback text maybe "Your browser..." but screenshot no.
Let's produce code around 800 lines.
We need ensure use `innerHTML` only static? We can set textContent. no security issue.
Let's formulate JS exact:
```
const TAU=Math.PI*2;
const $=...
const palette = [...]
let ...
```
`makeBall(x,y,vx,vy)` returns trail.
`spawnAttachedBall()`.
`createLevel` uses `powerMap`.
```
const powerAssignments = {
"0,1":"wide", ...
};
const tough = row===1||row===4||((row===3||row===5)&&(col===2||col===6));
```
This creates mixed tough in rows. For power map row3c3 multi but row3 normal (unless c2/6); row5c0 multi normal. good.
At least tough rows 1,4 all.
`accent: rowPalette[row].accent`.
Maybe different bricks row colors. Good.
`resetAttract`:
```
paddle.w=116; paddle.x=500; target...
balls=[makeBall(500,paddle.y-30,Math.sin(-.12)*350, -Math.cos(-.12)*350)];
```
Use random angle maybe.
`startGame(pointerX)`:
```
clear arrays; createLevel; score=0; ...
state='serve'; serveTimer=.75; balls=[makeBall(...0)];
...
```
call `initAudio`.
Audio call `audioCtx.resume()` returns promise. catch.
`showToast` uses `toastEl.classList.add`.
update:
```
function update(dt) {
clock+=dt;
updatePaddleWidth(dt);
...
if (clock > toastUntil) ...
if(state==='serve') {...}
else if(...) {...}
else updateEffects(dt)
updateHUD();
}
```
When state attract all bricks cleared and reset attract inside then return; updateHUD before? call after reset? resetAttract sets UI maybe. Do return but resetAttract calls updateHUD.
`resetAttract` calls show maybe.
Balls update:
```
for (let i=balls.length-1;i>=0;i--) {
const b=balls[i];
if (!b) continue;
...
if bottom remove splice and beep?
}
```
No ball-ball collision.
Power catch:
`p.y` moves. If p.y > H+30 remove.
Hit wall particles small. We don't want too many. `wallFlash` not.
Brick collision calls `damageBrick`; it may spawn power.
`normalizeBall`:
```
function normalizeBall(b,minSpeed=380,max=520) {
let s=...
if(s<min) scale; else if > max...
const verticalMin=s*.24;
if(abs(vy)<verticalMin){vy=(vy<0?-1:1)*verticalMin; normalize}
}
```
But after paddle bounce angle sharp and speed perhaps410. We call not normalize after paddle to preserve angle. In brick function normalize.
Initial ball speed375 maybe.
If ball collides brick normal and speed maybe 380.
Damage combo:
```
if(comboTimer>0) combo=Math.min(5,combo+1); else combo=1;
comboTimer=1.35;
```
Tough crack gets combo too, could increase multiplier even not break. okay.
score crack 25*combo.
`addFloater` x maybe brick center.
show combo in canvas if combo>1:
draw at W-30? `ctx.textAlign='right'`.
render order `drawBackground` then translate shake. Background labels not shake. fine.
DOM toast location top 15% maybe.
Resize:
```
function resizeCanvas(){
const rect=canvas.getBoundingClientRect();
if(!rect.width) return;
...
}
```
At initial stage computed.
Animation:
```
function loop(now){
let frameDt=Math.min((now-lastTime)/1000,.05);
lastTime=now; accumulator+=frameDt;
while(accumulator>=STEP){update(STEP); accumulator-=STEP;}
render();
requestAnimationFrame(loop);
}
```
If state reset inside update and `lastTime` fine.
Initial `resizeCanvas(); resetAttract(); requestAnimationFrame(loop);`.
`ResizeObserver` could trigger after.
Potential `ResizeObserver` loop because canvas width attributes changes intrinsic ratio but CSS width/height 100%, no.
Input:
```
canvas.addEventListener('pointermove', e=>{
const x=pointerToWorld(e);
if(state==='playing'||state==='serve'){paddle.targetX=...}
});
canvas.addEventListener('pointerdown', e=>{
e.preventDefault();
const x=...
if(state==='attract'){startGame(x); launchServe();}
else if(state==='serve'){paddle.targetX=x; launchServe();}
});
```
If user clicks to start and launch immediately, serve overlay instruction no time but fine. Maybe not launch immediately for more graceful; `startGame(x)` and not launch. Then click doesn't both. But click is intended start; ball can auto launch .75 sec. Keep.
`pointermove` before click in attract ignored. pointerdown sets.
Start button:
```
startButton.addEventListener('click',e=>{e.stopPropagation();startGame();});
```
Keyboard:
```
document.addEventListener('keydown',e=>{
if(e.ctrlKey||e.metaKey||e.altKey)return;
const key=e.key;
if(state==='attract'){
e.preventDefault(); startGame();
}
if(key==='ArrowLeft'||key==='ArrowRight'){...}
else if(key===' '){...}
else if((state victory/gameover)&&key==='Enter')...
});
```
If first key is 'Enter' in attract, start and no launch (auto). okay.
If first key is Space, after start state serve then branch Space launches. Good.
If state end and Space: restart.
If state serve and Space launch.
If key 'p', no.
Keys object booleans.
`keyup` clear.
blur.
Potential button keydown: document catches Space while attract, prevent default and start+launch; button click default prevented due preventDefault. good.
Restart button click.
Sound button:
```
soundButton.addEventListener('click',()=>{muted=!muted; if(!muted) initAudio(); ...});
```
aria-pressed.
Attract hint `aria-hidden` update.
End overlay.
No form.
Canvas pointer cursor none? On desktop maybe crosshair. Use cursor:none? Could be polished but then user no pointer. We can set cursor: crosshair. Maybe pointer hidden not.
CSS button focus visible.
One issue `backdrop-filter` okay.
Now code output. Let's think if there is any requirement for pure canvas only? "using vanilla JavaScript and a <canvas>" doesn't forbid DOM. HUD via DOM fine. Game drawn canvas.
Potential evaluator expects arrow controls. code yes.
Game starts in attract. yes.
At initial screenshot, attract banner maybe button top and game AI. good.
Could have score update in attract and lives always3. okay.
Let's perhaps include subtle `AI` badge in canvas. HTML hint.
Let's now write final.