Run details
- Model
- google/gemini-4-carbon
- Route
- OpenRouter API, one user message
- Served by
- Google AI Studio
- Effort
- medium, OpenRouter's default for this model
- Limits
- 60 min · 32,768 output tokens (the model's limit)
- Date
- 10 Nov 2026
- Time
- 2 min 25 s
- Size
- 46 kB
- Tokens
- 109 in, 19,175 out (1,597 of them thinking)
- List price
- $0.19 at today's price ($2.00 in / $10.00 out per M)
- Paid
- $0.19
Exact prompt
Make a Breakout game I can play right now with the keyboard or the mouse. Give it several levels, a score and some juice. It must be one complete, self-contained HTML file: inline all CSS and JavaScript, and make no network requests of any kind (no CDNs, web fonts or remote images). Reply with the file in a single ```html code block.
Source
46 kB 1,622 lines Raw
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NEON BREAKOUT // OVERDRIVE</title>
<style>
:root {
--bg: #070812;
--panel: #101328;
--cyan: #00f3ff;
--pink: #ff007f;
--yellow: #ffe600;
--green: #00ff88;
--text: #e8f0ff;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
body {
background: radial-gradient(circle at center, #121633 0%, var(--bg) 100%);
color: var(--text);
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
#game-wrapper {
position: relative;
width: min(96vw, calc(90vh * (4 / 3)));
aspect-ratio: 4 / 3;
box-shadow:
0 0 40px rgba(0, 243, 255, 0.18),
0 20px 60px rgba(0, 0, 0, 0.8),
inset 0 0 2px rgba(255, 255, 255, 0.25);
border: 2px solid rgba(0, 243, 255, 0.35);
border-radius: 12px;
overflow: hidden;
background: #060710;
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: none;
}
canvas.show-cursor {
cursor: default;
}
#footer-bar {
margin-top: 10px;
display: flex;
gap: 18px;
align-items: center;
justify-content: center;
font-size: 12px;
color: rgba(232, 240, 255, 0.65);
letter-spacing: 0.06em;
flex-wrap: wrap;
padding: 0 12px;
}
.key {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.09);
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--cyan);
font-weight: 700;
font-size: 11px;
}
button.hud-btn {
background: rgba(0, 243, 255, 0.1);
color: var(--cyan);
border: 1px solid rgba(0, 243, 255, 0.4);
border-radius: 5px;
padding: 4px 10px;
font: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
button.hud-btn:hover {
background: rgba(0, 243, 255, 0.25);
}
button.hud-btn:active {
transform: scale(0.95);
}
</style>
</head>
<body>
<div id="game-wrapper">
<canvas id="game" width="800" height="600" class="show-cursor"></canvas>
</div>
<div id="footer-bar">
<span><span class="key">MOUSE</span> or <span class="key">←</span> <span class="key">→</span> / <span class="key">A</span> <span class="key">D</span> Move</span>
<span><span class="key">CLICK</span> or <span class="key">SPACE</span> Launch</span>
<span><span class="key">P</span> Pause</span>
<button class="hud-btn" id="mute-btn" type="button">SOUND: ON (M)</button>
</div>
<script>
(() => {
'use strict';
// =========================================================
// WEB AUDIO PROCEDURAL SYNTHESIZER (Zero external assets)
// =========================================================
class SoundFX {
constructor() {
this.ctx = null;
this.muted = false;
this.noiseBuffer = null;
}
init() {
if (this.ctx) {
if (this.ctx.state === 'suspended') this.ctx.resume();
return;
}
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
this.ctx = new AudioCtx();
// Pre-generate white noise buffer for explosions & crisp percussion
const bufferSize = this.ctx.sampleRate * 0.5;
this.noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
}
toggleMute() {
this.muted = !this.muted;
return this.muted;
}
tone(freq, endFreq, duration, type = 'sine', vol = 0.18, delay = 0) {
if (this.muted || !this.ctx) return;
const now = this.ctx.currentTime + delay;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, now);
if (endFreq !== freq) {
osc.frequency.exponentialRampToValueAtTime(Math.max(20, endFreq), now + duration);
}
gain.gain.setValueAtTime(0.0001, now);
gain.gain.linearRampToValueAtTime(vol, now + 0.008);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.01);
}
noise(duration, filterFreq = 1200, vol = 0.22) {
if (this.muted || !this.ctx || !this.noiseBuffer) return;
const now = this.ctx.currentTime;
const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(filterFreq, now);
filter.frequency.exponentialRampToValueAtTime(120, now + duration);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(vol, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
src.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
src.start(now);
src.stop(now + duration);
}
launch() {
this.tone(220, 660, 0.16, 'triangle', 0.2);
}
paddle() {
this.tone(260, 420, 0.09, 'triangle', 0.22);
this.tone(130, 95, 0.08, 'sine', 0.18);
}
wall() {
this.tone(190, 150, 0.055, 'sine', 0.14);
}
metal() {
this.tone(880, 620, 0.09, 'square', 0.11);
this.tone(1320, 980, 0.07, 'triangle', 0.1);
}
brick(combo, destroyed) {
// Musical pentatonic-ish pitch climb as combo increases
const scale = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25, 587.33, 659.25, 783.99, 880.00, 1046.5, 1174.7, 1318.5];
const idx = Math.min(combo, scale.length - 1);
const base = scale[idx];
if (destroyed) {
this.tone(base, base * 1.45, 0.13, 'triangle', 0.2);
this.tone(base * 0.5, base * 0.75, 0.11, 'sine', 0.15);
this.noise(0.07, 2600, 0.08);
} else {
this.tone(base * 0.75, base * 0.65, 0.08, 'sawtooth', 0.12);
}
}
explode() {
this.noise(0.38, 1600, 0.34);
this.tone(140, 35, 0.35, 'sawtooth', 0.26);
}
powerup() {
this.tone(440, 554, 0.09, 'triangle', 0.18, 0);
this.tone(554, 659, 0.09, 'triangle', 0.18, 0.07);
this.tone(659, 880, 0.16, 'triangle', 0.20, 0.14);
}
loseLife() {
this.tone(340, 110, 0.38, 'sawtooth', 0.22, 0);
this.tone(240, 70, 0.42, 'square', 0.14, 0.08);
this.noise(0.28, 800, 0.2);
}
levelClear() {
const notes = [523.25, 659.25, 783.99, 1046.5];
notes.forEach((n, i) => {
this.tone(n, n * 1.02, 0.18, 'triangle', 0.2, i * 0.09);
});
}
}
const sfx = new SoundFX();
// =========================================================
// CANVAS & HIGH-DPI SETUP
// =========================================================
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const muteBtn = document.getElementById('mute-btn');
const LOGICAL_W = 800;
const LOGICAL_H = 600;
const TOP_BAR_H = 52;
function setupDPI() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = LOGICAL_W * dpr;
canvas.height = LOGICAL_H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
setupDPI();
window.addEventListener('resize', setupDPI);
// =========================================================
// LEVEL DEFINITIONS
// Legend:
// . = empty
// C, P, Y, G, B, O = 1-HP neon bricks (Cyan, Pink, Yellow, Green, Blue, Orange)
// 2, 3 = 2-HP and 3-HP armored bricks
// X = Explosive reactor brick (detonates neighbors!)
// # = Indestructible gold bumper brick
// =========================================================
const LEVELS = [
{
name: 'SECTOR 01 // NEON DAWN',
speed: 430,
map: [
'............',
'.CCCCCCCCCC.',
'.PPPPPPPPPP.',
'.YYYYXXYYYY.',
'.GGGGGGGGGG.',
'.BBBBBBBBBB.'
]
},
{
name: 'SECTOR 02 // TWIN REACTORS',
speed: 460,
map: [
'.2222..2222.',
'.2CC2..2PP2.',
'.2CX2..2XP2.',
'.2CC2..2PP2.',
'.YYYYYYYYYY.',
'..GGGGGGGG..',
'...BBBBBB...'
]
},
{
name: 'SECTOR 03 // PRISM MATRIX',
speed: 485,
map: [
'C.P.Y.G.B.O.',
'.2.X.2.2.X.2',
'P.Y.G.B.O.C.',
'.#.C.P.Y.G.#',
'Y.G.B.O.C.P.',
'.2.2.X.X.2.2',
'G.B.O.C.P.Y.'
]
},
{
name: 'SECTOR 04 // CITADEL CORE',
speed: 510,
map: [
'..33333333..',
'.3CCCCCCCC3.',
'.3CXX22XXC3.',
'.3CPPPPPPC3.',
'.##..YY..##.',
'..GGGGGGGG..',
'.BBBBBBBBBB.'
]
},
{
name: 'SECTOR 05 // OVERDRIVE SINGULARITY',
speed: 540,
map: [
'3.3.3XX3.3.3',
'.P2P2PP2P2P.',
'2CXC.##.CXC2',
'.YGYGXXGYGY.',
'2B2B.##.B2B2',
'.OOOOOOOOOO.',
'C.C.CCCC.C.C'
]
}
];
const BRICK_PALETTE = {
C: { main: '#00f3ff', glow: '#00f3ff', score: 50 },
P: { main: '#ff2a85', glow: '#ff2a85', score: 60 },
Y: { main: '#ffe600', glow: '#ffe600', score: 70 },
G: { main: '#00ff88', glow: '#00ff88', score: 60 },
B: { main: '#6e56ff', glow: '#8b78ff', score: 50 },
O: { main: '#ff7b00', glow: '#ff7b00', score: 70 },
2: { main: '#b8c7ff', glow: '#7096ff', score: 120 },
3: { main: '#ff52e5', glow: '#ff52e5', score: 180 },
X: { main: '#ff3b30', glow: '#ff6930', score: 150 },
'#': { main: '#ffd166', glow: '#ffaa00', score: 0 }
};
const POWERUP_TYPES = [
{ type: 'wide', label: 'EXPAND', color: '#00f3ff' },
{ type: 'multi', label: 'MULTIBALL',color: '#00ff88' },
{ type: 'fire', label: 'PLASMA', color: '#ff5500' },
{ type: 'slow', label: 'CHRONO', color: '#b967ff' },
{ type: 'life', label: '+1 LIFE', color: '#ff2a85' }
];
// =========================================================
// GAME STATE
// =========================================================
let state = 'TITLE'; // TITLE, PLAYING, PAUSED, LEVEL_CLEAR, GAME_OVER
let score = 0;
let displayScore = 0;
let highScore = 0;
try {
highScore = parseInt(localStorage.getItem('neon_breakout_hi') || '0', 10) || 0;
} catch (_) {}
let lives = 3;
let levelIndex = 0;
let loopCycle = 0; // Increments after beating all 5 levels (NG+)
let combo = 0;
let maxCombo = 0;
let shakeTime = 0;
let shakeMag = 0;
let gridPulse = 0;
let levelBannerTimer = 0;
// Entities
const paddle = {
x: LOGICAL_W / 2,
y: LOGICAL_H - 38,
w: 112,
baseW: 112,
targetW: 112,
h: 15,
speed: 680,
vx: 0,
recoil: 0,
flash: 0,
wideTimer: 0,
fireTimer: 0
};
let balls = [];
let bricks = [];
let particles = [];
let shockwaves = [];
let popups = [];
let powerups = [];
let bgStars = [];
for (let i = 0; i < 55; i++) {
bgStars.push({
x: Math.random() * LOGICAL_W,
y: TOP_BAR_H + Math.random() * (LOGICAL_H - TOP_BAR_H),
r: Math.random() * 1.6 + 0.4,
alpha: Math.random() * 0.5 + 0.15,
twinkle: Math.random() * Math.PI * 2
});
}
// =========================================================
// INPUT HANDLING (Keyboard + Mouse + Touch)
// =========================================================
const keys = { left: false, right: false };
function updateCursorVisibility() {
if (state === 'PLAYING') {
canvas.classList.remove('show-cursor');
} else {
canvas.classList.add('show-cursor');
}
}
window.addEventListener('keydown', (e) => {
if (['ArrowLeft', 'ArrowRight', 'Space', ' '].includes(e.key)) {
e.preventDefault();
}
sfx.init();
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') keys.left = true;
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') keys.right = true;
if (e.key === 'm' || e.key === 'M') {
const muted = sfx.toggleMute();
muteBtn.textContent = `SOUND: ${muted ? 'OFF' : 'ON'} (M)`;
}
if (e.key === 'p' || e.key === 'P' || e.key === 'Escape') {
if (state === 'PLAYING') {
state = 'PAUSED';
updateCursorVisibility();
} else if (state === 'PAUSED') {
state = 'PLAYING';
updateCursorVisibility();
}
}
if (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowUp') {
handlePrimaryAction();
}
});
window.addEventListener('keyup', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') keys.left = false;
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') keys.right = false;
});
function setPaddleFromClientX(clientX) {
const rect = canvas.getBoundingClientRect();
const relX = ((clientX - rect.left) / rect.width) * LOGICAL_W;
const half = paddle.w / 2;
const prevX = paddle.x;
paddle.x = Math.max(half + 8, Math.min(LOGICAL_W - half - 8, relX));
paddle.vx = (paddle.x - prevX) * 35;
}
canvas.addEventListener('mousemove', (e) => {
if (state === 'PLAYING') {
setPaddleFromClientX(e.clientX);
}
});
canvas.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
sfx.init();
handlePrimaryAction();
});
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
sfx.init();
if (e.touches.length > 0) {
setPaddleFromClientX(e.touches[0].clientX);
}
handlePrimaryAction();
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (state === 'PLAYING' && e.touches.length > 0) {
setPaddleFromClientX(e.touches[0].clientX);
}
}, { passive: false });
muteBtn.addEventListener('click', () => {
sfx.init();
const muted = sfx.toggleMute();
muteBtn.textContent = `SOUND: ${muted ? 'OFF' : 'ON'} (M)`;
muteBtn.blur();
});
function handlePrimaryAction() {
if (state === 'TITLE' || state === 'GAME_OVER') {
startNewGame();
} else if (state === 'PAUSED') {
state = 'PLAYING';
updateCursorVisibility();
} else if (state === 'LEVEL_CLEAR') {
nextLevel();
} else if (state === 'PLAYING') {
launchStuckBalls();
}
}
// =========================================================
// GAME FLOW & SPAWNING
// =========================================================
function startNewGame() {
score = 0;
displayScore = 0;
lives = 3;
levelIndex = 0;
loopCycle = 0;
combo = 0;
maxCombo = 0;
loadLevel(levelIndex);
state = 'PLAYING';
updateCursorVisibility();
}
function nextLevel() {
levelIndex++;
if (levelIndex >= LEVELS.length) {
levelIndex = 0;
loopCycle++;
}
loadLevel(levelIndex);
state = 'PLAYING';
updateCursorVisibility();
}
function currentBaseBallSpeed() {
return LEVELS[levelIndex].speed + loopCycle * 45;
}
function loadLevel(idx) {
bricks = [];
particles = [];
shockwaves = [];
powerups = [];
popups = [];
combo = 0;
paddle.targetW = paddle.baseW;
paddle.w = paddle.baseW;
paddle.wideTimer = 0;
paddle.fireTimer = 0;
paddle.x = LOGICAL_W / 2;
const def = LEVELS[idx];
const cols = 12;
const padX = 36;
const gap = 6;
const totalW = LOGICAL_W - padX * 2;
const brickW = (totalW - gap * (cols - 1)) / cols;
const brickH = 24;
const startY = TOP_BAR_H + 46;
def.map.forEach((rowStr, r) => {
for (let c = 0; c < cols; c++) {
const ch = rowStr[c] || '.';
if (ch === '.') continue;
const pal = BRICK_PALETTE[ch] || BRICK_PALETTE.C;
let hp = 1;
let type = 'normal';
if (ch === '2') { hp = 2; type = 'tough'; }
else if (ch === '3') { hp = 3; type = 'tough'; }
else if (ch === 'X') { hp = 1; type = 'explosive'; }
else if (ch === '#') { hp = 999; type = 'indestructible'; }
bricks.push({
x: padX + c * (brickW + gap),
y: startY + r * (brickH + gap),
w: brickW,
h: brickH,
hp,
maxHp: hp,
type,
code: ch,
color: pal.main,
glow: pal.glow,
score: pal.score,
flash: 0,
scale: 0,
spawnDelay: (r * 0.035) + (c * 0.015)
});
}
});
levelBannerTimer = 2.1;
resetBallOnPaddle();
}
function resetBallOnPaddle() {
balls = [{
x: paddle.x,
y: paddle.y - paddle.h / 2 - 8,
vx: 0,
vy: 0,
r: 7.5,
speed: currentBaseBallSpeed(),
stuck: true,
offsetX: 0,
trail: []
}];
}
function launchStuckBalls() {
let launched = false;
for (const b of balls) {
if (b.stuck) {
b.stuck = false;
const spread = (Math.random() - 0.5) * 0.45;
b.vx = Math.sin(spread) * b.speed;
b.vy = -Math.cos(spread) * b.speed;
launched = true;
}
}
if (launched) sfx.launch();
}
// =========================================================
// JUICE HELPERS (Particles, Shake, Popups, Shockwaves)
// =========================================================
function addShake(mag, duration = 0.18) {
shakeMag = Math.max(shakeMag, mag);
shakeTime = Math.max(shakeTime, duration);
}
function spawnParticles(x, y, color, count = 14, speedMult = 1) {
for (let i = 0; i < count; i++) {
const ang = Math.random() * Math.PI * 2;
const spd = (60 + Math.random() * 230) * speedMult;
particles.push({
x,
y,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
size: 2.2 + Math.random() * 3.8,
color,
life: 0.35 + Math.random() * 0.4,
maxLife: 0.75,
gravity: 240
});
}
}
function spawnPopup(x, y, text, color = '#ffffff', scale = 1) {
popups.push({
x,
y,
vy: -55,
text,
color,
life: 0.85,
maxLife: 0.85,
scale
});
}
function spawnShockwave(x, y, color, maxRadius = 110) {
shockwaves.push({
x,
y,
r: 8,
maxR: maxRadius,
color,
life: 0.35,
maxLife: 0.35
});
}
function maybeDropPowerup(brick) {
if (brick.type === 'indestructible') return;
const chance = brick.type === 'explosive' ? 0.35 : 0.16;
if (Math.random() > chance) return;
// Slightly weight multiball/expand/fire over extra life
const roll = Math.random();
let def;
if (roll < 0.28) def = POWERUP_TYPES[0]; // wide
else if (roll < 0.56) def = POWERUP_TYPES[1]; // multi
else if (roll < 0.78) def = POWERUP_TYPES[2]; // fire
else if (roll < 0.92) def = POWERUP_TYPES[3]; // slow
else def = POWERUP_TYPES[4]; // life
powerups.push({
x: brick.x + brick.w / 2,
y: brick.y + brick.h / 2,
vy: 145,
w: 76,
h: 20,
type: def.type,
label: def.label,
color: def.color,
phase: Math.random() * Math.PI * 2
});
}
function applyPowerup(p) {
sfx.powerup();
paddle.flash = 0.35;
spawnParticles(p.x, p.y, p.color, 18, 0.9);
spawnPopup(paddle.x, paddle.y - 26, p.label, p.color, 1.15);
addScore(100);
if (p.type === 'wide') {
paddle.targetW = 168;
paddle.wideTimer = 14;
} else if (p.type === 'fire') {
paddle.fireTimer = 9;
} else if (p.type === 'slow') {
for (const b of balls) {
b.speed = Math.max(330, b.speed * 0.78);
const mag = Math.hypot(b.vx, b.vy) || 1;
b.vx = (b.vx / mag) * b.speed;
b.vy = (b.vy / mag) * b.speed;
}
} else if (p.type === 'life') {
lives = Math.min(6, lives + 1);
} else if (p.type === 'multi') {
// Launch any stuck ball first
launchStuckBalls();
const sourceBalls = balls.slice(0, 3); // Cap exponential growth cleanly
for (const src of sourceBalls) {
if (balls.length >= 9) break;
const baseAngle = Math.atan2(src.vy, src.vx);
[-0.32, 0.32].forEach((offset) => {
if (balls.length >= 9) return;
const ang = baseAngle + offset;
balls.push({
x: src.x,
y: src.y,
vx: Math.cos(ang) * src.speed,
vy: Math.sin(ang) * src.speed,
r: src.r,
speed: src.speed,
stuck: false,
offsetX: 0,
trail: []
});
});
}
}
}
function addScore(pts) {
score += pts;
if (score > highScore) {
highScore = score;
try {
localStorage.setItem('neon_breakout_hi', String(highScore));
} catch (_) {}
}
}
// =========================================================
// BRICK DAMAGE & CHAIN EXPLOSIONS
// =========================================================
function damageBrick(brick, amount = 1, fromExplosion = false) {
if (brick.dead) return;
if (brick.type === 'indestructible') {
brick.flash = 0.2;
if (!fromExplosion) {
sfx.metal();
spawnParticles(brick.x + brick.w / 2, brick.y + brick.h / 2, '#ffd166', 6, 0.6);
}
return;
}
brick.hp -= amount;
brick.flash = 0.18;
const cx = brick.x + brick.w / 2;
const cy = brick.y + brick.h / 2;
if (brick.hp <= 0) {
brick.dead = true;
combo++;
if (combo > maxCombo) maxCombo = combo;
const mult = 1 + Math.floor((combo - 1) / 3) * 0.5;
const gained = Math.round(brick.score * mult);
addScore(gained);
gridPulse = Math.min(1, gridPulse + 0.35);
const label = combo >= 4 ? `${gained} x${mult.toFixed(1)}` : `+${gained}`;
spawnPopup(cx, cy, label, brick.color, combo >= 6 ? 1.15 : 0.95);
spawnParticles(cx, cy, brick.color, 18, 1.05);
maybeDropPowerup(brick);
if (brick.type === 'explosive') {
sfx.explode();
addShake(11, 0.3);
spawnShockwave(cx, cy, '#ff5522', 125);
spawnParticles(cx, cy, '#ffaa00', 28, 1.5);
// Detonate nearby bricks within blast radius
for (const other of bricks) {
if (other.dead || other === brick || other.type === 'indestructible') continue;
const ox = other.x + other.w / 2;
const oy = other.y + other.h / 2;
if (Math.hypot(ox - cx, oy - cy) < 112) {
damageBrick(other, 3, true);
}
}
} else {
if (!fromExplosion) sfx.brick(combo - 1, true);
addShake(Math.min(6, 2.2 + combo * 0.25), 0.12);
spawnShockwave(cx, cy, brick.color, 46);
}
} else {
if (!fromExplosion) sfx.brick(combo, false);
addScore(15);
addShake(2, 0.08);
spawnParticles(cx, cy, '#ffffff', 7, 0.65);
}
}
// =========================================================
// PHYSICS & UPDATE LOOP
// =========================================================
function enforceMinVerticalAngle(ball) {
const minVy = ball.speed * 0.24;
if (Math.abs(ball.vy) < minVy) {
const sign = ball.vy >= 0 ? 1 : -1;
ball.vy = sign * minVy;
const vxSign = ball.vx >= 0 ? 1 : -1;
ball.vx = vxSign * Math.sqrt(Math.max(0, ball.speed * ball.speed - ball.vy * ball.vy));
}
}
function update(dt) {
// Smooth rolling score counter
if (displayScore < score) {
const diff = score - displayScore;
displayScore += Math.ceil(diff * Math.min(1, dt * 14));
} else {
displayScore = score;
}
// Background stars twinkle
for (const s of bgStars) {
s.twinkle += dt * 2.5;
}
if (gridPulse > 0) gridPulse = Math.max(0, gridPulse - dt * 2.2);
if (shakeTime > 0) {
shakeTime -= dt;
if (shakeTime <= 0) shakeMag = 0;
}
// Always update cosmetic particles/shockwaves/popups
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.vy += p.gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) particles.splice(i, 1);
}
for (let i = shockwaves.length - 1; i >= 0; i--) {
const sw = shockwaves[i];
sw.life -= dt;
const t = 1 - sw.life / sw.maxLife;
sw.r = 8 + (sw.maxR - 8) * (1 - Math.pow(1 - t, 3));
if (sw.life <= 0) shockwaves.splice(i, 1);
}
for (let i = popups.length - 1; i >= 0; i--) {
const pp = popups[i];
pp.y += pp.vy * dt;
pp.life -= dt;
if (pp.life <= 0) popups.splice(i, 1);
}
if (state !== 'PLAYING') return;
if (levelBannerTimer > 0) levelBannerTimer = Math.max(0, levelBannerTimer - dt);
// Brick entry animations & flash decay
for (const b of bricks) {
if (b.spawnDelay > 0) {
b.spawnDelay -= dt;
} else if (b.scale < 1) {
b.scale = Math.min(1, b.scale + dt * 6.5);
}
if (b.flash > 0) b.flash = Math.max(0, b.flash - dt);
}
// Powerup timers on paddle
if (paddle.wideTimer > 0) {
paddle.wideTimer -= dt;
if (paddle.wideTimer <= 0) paddle.targetW = paddle.baseW;
}
if (paddle.fireTimer > 0) {
paddle.fireTimer = Math.max(0, paddle.fireTimer - dt);
}
// Smooth paddle width transition & recoil spring
paddle.w += (paddle.targetW - paddle.w) * Math.min(1, dt * 10);
paddle.recoil *= Math.pow(0.001, dt);
if (paddle.flash > 0) paddle.flash = Math.max(0, paddle.flash - dt);
// Keyboard paddle movement
const dir = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
if (dir !== 0) {
paddle.vx = dir * paddle.speed;
paddle.x += paddle.vx * dt;
} else {
paddle.vx *= Math.pow(0.01, dt);
}
const halfP = paddle.w / 2;
paddle.x = Math.max(halfP + 8, Math.min(LOGICAL_W - halfP - 8, paddle.x));
// Falling powerups
for (let i = powerups.length - 1; i >= 0; i--) {
const p = powerups[i];
p.phase += dt * 6;
p.y += p.vy * dt;
// Check paddle catch
if (
p.x + p.w / 2 >= paddle.x - halfP &&
p.x - p.w / 2 <= paddle.x + halfP &&
p.y + p.h / 2 >= paddle.y - paddle.h / 2 &&
p.y - p.h / 2 <= paddle.y + paddle.h / 2 + 6
) {
applyPowerup(p);
powerups.splice(i, 1);
continue;
}
if (p.y - p.h > LOGICAL_H) {
powerups.splice(i, 1);
}
}
// Update balls with physics sub-stepping (prevents tunneling at high speed)
const isPlasma = paddle.fireTimer > 0;
for (let bi = balls.length - 1; bi >= 0; bi--) {
const ball = balls[bi];
if (ball.stuck) {
ball.x = paddle.x + ball.offsetX;
ball.y = paddle.y - paddle.h / 2 - ball.r - 1;
ball.trail.length = 0;
continue;
}
// Record trail
ball.trail.push({ x: ball.x, y: ball.y });
if (ball.trail.length > 11) ball.trail.shift();
if (isPlasma && Math.random() < 0.55) {
particles.push({
x: ball.x + (Math.random() - 0.5) * 6,
y: ball.y + (Math.random() - 0.5) * 6,
vx: (Math.random() - 0.5) * 40,
vy: (Math.random() - 0.5) * 40,
size: 2.5 + Math.random() * 2.5,
color: Math.random() < 0.5 ? '#ff6a00' : '#ffe600',
life: 0.22,
maxLife: 0.22,
gravity: 0
});
}
const subSteps = 3;
const subDt = dt / subSteps;
for (let s = 0; s < subSteps; s++) {
ball.x += ball.vx * subDt;
ball.y += ball.vy * subDt;
// Left & Right walls
if (ball.x - ball.r < 6) {
ball.x = 6 + ball.r;
ball.vx = Math.abs(ball.vx);
sfx.wall();
spawnParticles(ball.x, ball.y, '#00f3ff', 5, 0.5);
} else if (ball.x + ball.r > LOGICAL_W - 6) {
ball.x = LOGICAL_W - 6 - ball.r;
ball.vx = -Math.abs(ball.vx);
sfx.wall();
spawnParticles(ball.x, ball.y, '#00f3ff', 5, 0.5);
}
// Top ceiling
if (ball.y - ball.r < TOP_BAR_H + 4) {
ball.y = TOP_BAR_H + 4 + ball.r;
ball.vy = Math.abs(ball.vy);
sfx.wall();
spawnParticles(ball.x, ball.y, '#00f3ff', 5, 0.5);
}
// Paddle collision (only when moving downward)
const pTop = paddle.y - paddle.h / 2;
const pBot = paddle.y + paddle.h / 2;
const pLeft = paddle.x - halfP;
const pRight = paddle.x + halfP;
if (
ball.vy > 0 &&
ball.x + ball.r >= pLeft &&
ball.x - ball.r <= pRight &&
ball.y + ball.r >= pTop &&
ball.y - ball.r <= pBot
) {
ball.y = pTop - ball.r;
// Calculate bounce angle (-68 deg to +68 deg) + slight paddle velocity influence
const hitNorm = Math.max(-1, Math.min(1, (ball.x - paddle.x) / halfP));
const maxAngle = 1.18; // ~67.5 degrees
const angle = hitNorm * maxAngle;
// Gradually ramp ball speed slightly for excitement
ball.speed = Math.min(760, ball.speed + 4);
ball.vx = Math.sin(angle) * ball.speed + paddle.vx * 0.08;
ball.vy = -Math.cos(angle) * ball.speed;
// Renormalize to ball.speed
const mag = Math.hypot(ball.vx, ball.vy) || 1;
ball.vx = (ball.vx / mag) * ball.speed;
ball.vy = (ball.vy / mag) * ball.speed;
enforceMinVerticalAngle(ball);
paddle.recoil = 6.5;
paddle.flash = 0.16;
if (combo >= 5) {
spawnPopup(paddle.x, paddle.y - 22, `${combo} HIT COMBO!`, '#ffe600', 1.05);
}
combo = 0; // Reset combo on paddle touch
sfx.paddle();
spawnParticles(ball.x, pTop, '#00f3ff', 9, 0.65);
}
// Brick collisions
for (let k = 0; k < bricks.length; k++) {
const br = bricks[k];
if (br.dead) continue;
// Closest point on AABB to circle center
const closestX = Math.max(br.x, Math.min(ball.x, br.x + br.w));
const closestY = Math.max(br.y, Math.min(ball.y, br.y + br.h));
const dx = ball.x - closestX;
const dy = ball.y - closestY;
const distSq = dx * dx + dy * dy;
if (distSq <= ball.r * ball.r) {
// Plasma ball slices right through breakable bricks without bouncing!
const sliceThrough = isPlasma && br.type !== 'indestructible' && br.hp <= 2;
if (!sliceThrough) {
// Determine collision normal & separate ball cleanly
const overlapLeft = (ball.x + ball.r) - br.x;
const overlapRight = (br.x + br.w) - (ball.x - ball.r);
const overlapTop = (ball.y + ball.r) - br.y;
const overlapBottom = (br.y + br.h) - (ball.y - ball.r);
const minOverlapX = Math.min(overlapLeft, overlapRight);
const minOverlapY = Math.min(overlapTop, overlapBottom);
if (minOverlapX < minOverlapY) {
if (overlapLeft < overlapRight) ball.x = br.x - ball.r - 0.5;
else ball.x = br.x + br.w + ball.r + 0.5;
ball.vx = -ball.vx;
} else {
if (overlapTop < overlapBottom) ball.y = br.y - ball.r - 0.5;
else ball.y = br.y + br.h + ball.r + 0.5;
ball.vy = -ball.vy;
}
enforceMinVerticalAngle(ball);
}
damageBrick(br, isPlasma ? 3 : 1, false);
break; // Handle one brick per sub-step for crisp reflections
}
}
}
// Check bottom out-of-bounds
if (ball.y - ball.r > LOGICAL_H + 12) {
balls.splice(bi, 1);
}
}
// Remove dead bricks
for (let i = bricks.length - 1; i >= 0; i--) {
if (bricks[i].dead) bricks.splice(i, 1);
}
// Check if all balls lost
if (balls.length === 0) {
lives--;
combo = 0;
paddle.fireTimer = 0;
paddle.wideTimer = 0;
paddle.targetW = paddle.baseW;
sfx.loseLife();
addShake(14, 0.38);
spawnParticles(paddle.x, LOGICAL_H - 12, '#ff2a85', 30, 1.3);
if (lives <= 0) {
state = 'GAME_OVER';
updateCursorVisibility();
} else {
resetBallOnPaddle();
}
return;
}
// Check level clear (all breakable bricks gone)
const remainingBreakable = bricks.some(b => b.type !== 'indestructible');
if (!remainingBreakable) {
sfx.levelClear();
addScore(500 + lives * 150);
state = 'LEVEL_CLEAR';
updateCursorVisibility();
}
}
// =========================================================
// RENDERING
// =========================================================
function roundRect(x, y, w, h, r) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
function drawBackground() {
ctx.fillStyle = '#060711';
ctx.fillRect(0, 0, LOGICAL_W, LOGICAL_H);
// Subtle synthwave perspective/cyber grid
const gridAlpha = 0.045 + gridPulse * 0.09;
ctx.strokeStyle = `rgba(0, 243, 255, ${gridAlpha.toFixed(3)})`;
ctx.lineWidth = 1;
const step = 40;
ctx.beginPath();
for (let x = step; x < LOGICAL_W; x += step) {
ctx.moveTo(x, TOP_BAR_H);
ctx.lineTo(x, LOGICAL_H);
}
for (let y = TOP_BAR_H + step; y < LOGICAL_H; y += step) {
ctx.moveTo(0, y);
ctx.lineTo(LOGICAL_W, y);
}
ctx.stroke();
// Twinkling stars
for (const s of bgStars) {
const a = s.alpha * (0.65 + 0.35 * Math.sin(s.twinkle));
ctx.fillStyle = `rgba(210, 235, 255, ${a.toFixed(3)})`;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
// Arena border glow
ctx.strokeStyle = 'rgba(0, 243, 255, 0.35)';
ctx.lineWidth = 2;
ctx.strokeRect(5, TOP_BAR_H + 3, LOGICAL_W - 10, LOGICAL_H - TOP_BAR_H - 2);
}
function drawBricks() {
for (const b of bricks) {
if (b.scale <= 0.01) continue;
ctx.save();
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
if (b.scale < 1) {
ctx.translate(cx, cy);
ctx.scale(b.scale, b.scale);
ctx.translate(-cx, -cy);
}
ctx.shadowColor = b.glow;
ctx.shadowBlur = b.flash > 0 ? 18 : 9;
const fill = b.flash > 0 ? '#ffffff' : b.color;
ctx.fillStyle = fill;
roundRect(b.x, b.y, b.w, b.h, 5);
ctx.fill();
ctx.shadowBlur = 0;
// Top inner highlight bevel
ctx.fillStyle = 'rgba(255, 255, 255, 0.28)';
roundRect(b.x + 2, b.y + 2, b.w - 4, 5, 3);
ctx.fill();
// Special brick markings
if (b.type === 'indestructible') {
ctx.strokeStyle = 'rgba(80, 50, 0, 0.65)';
ctx.lineWidth = 2;
roundRect(b.x + 4, b.y + 4, b.w - 8, b.h - 8, 3);
ctx.stroke();
} else if (b.type === 'explosive') {
const pulse = 0.65 + 0.35 * Math.sin(performance.now() * 0.012);
ctx.fillStyle = `rgba(255, 240, 150, ${pulse.toFixed(2)})`;
ctx.beginPath();
ctx.moveTo(cx, b.y + 5);
ctx.lineTo(cx + 7, cy);
ctx.lineTo(cx, b.y + b.h - 5);
ctx.lineTo(cx - 7, cy);
ctx.closePath();
ctx.fill();
} else if (b.maxHp > 1) {
// Show armor pips / cracks
ctx.fillStyle = 'rgba(8, 12, 28, 0.65)';
const pipW = 6;
const gap = 4;
const total = b.hp * pipW + (b.hp - 1) * gap;
let sx = cx - total / 2;
for (let i = 0; i < b.hp; i++) {
ctx.fillRect(sx + i * (pipW + gap), cy - 2, pipW, 5);
}
}
ctx.restore();
}
}
function drawPaddle() {
const px = paddle.x - paddle.w / 2;
const py = paddle.y - paddle.h / 2 + paddle.recoil;
const isPlasma = paddle.fireTimer > 0;
ctx.save();
const glowCol = isPlasma ? '#ff5e00' : '#00f3ff';
ctx.shadowColor = glowCol;
ctx.shadowBlur = paddle.flash > 0 ? 24 : 14;
ctx.fillStyle = paddle.flash > 0 ? '#ffffff' : (isPlasma ? '#ff7722' : '#00f3ff');
roundRect(px, py, paddle.w, paddle.h, 8);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#091024';
roundRect(px + 12, py + 4, paddle.w - 24, paddle.h - 8, 4);
ctx.fill();
// Glowing core bar inside paddle (shows active powerup duration!)
let barRatio = 1;
let barColor = '#00f3ff';
if (isPlasma) {
barRatio = paddle.fireTimer / 9;
barColor = '#ffaa00';
} else if (paddle.wideTimer > 0) {
barRatio = paddle.wideTimer / 14;
barColor = '#00ff88';
}
ctx.fillStyle = barColor;
const innerMax = paddle.w - 28;
roundRect(px + 14, py + 5.5, Math.max(0, innerMax * barRatio), paddle.h - 11, 2);
ctx.fill();
ctx.restore();
}
function drawBalls() {
const isPlasma = paddle.fireTimer > 0;
const mainCol = isPlasma ? '#ff6a00' : '#ffffff';
const trailCol = isPlasma ? '255, 110, 0' : '0, 243, 255';
for (const b of balls) {
// Motion trail
for (let i = 0; i < b.trail.length; i++) {
const pt = b.trail[i];
const t = (i + 1) / b.trail.length;
ctx.fillStyle = `rgba(${trailCol}, ${(t * 0.42).toFixed(3)})`;
ctx.beginPath();
ctx.arc(pt.x, pt.y, b.r * t * 0.92, 0, Math.PI * 2);
ctx.fill();
}
// Launch guide arrow when stuck on paddle
if (b.stuck && state === 'PLAYING') {
const bob = Math.sin(performance.now() * 0.008) * 4;
ctx.strokeStyle = 'rgba(0, 243, 255, 0.55)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(b.x, b.y - 12);
ctx.lineTo(b.x, b.y - 44 + bob);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.save();
ctx.shadowColor = isPlasma ? '#ff5500' : '#00f3ff';
ctx.shadowBlur = 15;
ctx.fillStyle = mainCol;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
function drawPowerups() {
for (const p of powerups) {
ctx.save();
const pulse = 1 + Math.sin(p.phase) * 0.06;
ctx.translate(p.x, p.y);
ctx.scale(pulse, pulse);
ctx.shadowColor = p.color;
ctx.shadowBlur = 12;
ctx.fillStyle = '#0b1024';
ctx.strokeStyle = p.color;
ctx.lineWidth = 2;
roundRect(-p.w / 2, -p.h / 2, p.w, p.h, 10);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = p.color;
ctx.font = 'bold 10px ui-monospace, Menlo, Consolas, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(p.label, 0, 0.5);
ctx.restore();
}
}
function drawEffects() {
// Shockwaves
for (const sw of shockwaves) {
const alpha = Math.max(0, sw.life / sw.maxLife);
ctx.save();
ctx.strokeStyle = sw.color;
ctx.globalAlpha = alpha * 0.85;
ctx.lineWidth = 3 * alpha + 1;
ctx.beginPath();
ctx.arc(sw.x, sw.y, sw.r, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// Particles
for (const p of particles) {
const alpha = Math.max(0, p.life / p.maxLife);
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
const sz = p.size * (0.4 + 0.6 * alpha);
ctx.fillRect(p.x - sz / 2, p.y - sz / 2, sz, sz);
ctx.restore();
}
// Floating score/combo popups
for (const pp of popups) {
const alpha = Math.min(1, (pp.life / pp.maxLife) * 1.4);
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = pp.color;
ctx.font = `bold ${Math.round(13 * pp.scale)}px ui-monospace, Menlo, Consolas, monospace`;
ctx.textAlign = 'center';
ctx.shadowColor = '#000';
ctx.shadowBlur = 6;
ctx.fillText(pp.text, pp.x, pp.y);
ctx.restore();
}
}
function drawHUD() {
ctx.fillStyle = 'rgba(9, 12, 28, 0.92)';
ctx.fillRect(0, 0, LOGICAL_W, TOP_BAR_H);
ctx.strokeStyle = 'rgba(0, 243, 255, 0.35)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, TOP_BAR_H);
ctx.lineTo(LOGICAL_W, TOP_BAR_H);
ctx.stroke();
ctx.textBaseline = 'middle';
const midY = TOP_BAR_H / 2;
// Score
ctx.textAlign = 'left';
ctx.font = 'bold 11px ui-monospace, Menlo, Consolas, monospace';
ctx.fillStyle = 'rgba(232, 240, 255, 0.55)';
ctx.fillText('SCORE', 18, midY - 9);
ctx.font = 'bold 19px ui-monospace, Menlo, Consolas, monospace';
ctx.fillStyle = '#00f3ff';
ctx.fillText(String(displayScore).padStart(7, '0'), 18, midY + 9);
// High Score
ctx.font = 'bold 11px ui-monospace, Menlo, Consolas, monospace';
ctx.fillStyle = 'rgba(232, 240, 255, 0.55)';
ctx.fillText('BEST', 155, midY - 9);
ctx.font = 'bold 16px ui-monospace, Menlo, Consolas, monospace';
ctx.fillStyle = '#ffe600';
ctx.fillText(String(highScore).padStart(7, '0'), 155, midY + 9);
// Level & Combo in center
ctx.textAlign = 'center';
const lvlNum = loopCycle * LEVELS.length + levelIndex + 1;
ctx.font = 'bold 14px ui-monospace, Menlo, Consolas, monospace';
ctx.fillStyle = '#e8f0ff';
ctx.fillText(`LEVEL ${String(lvlNum).padStart(2, '0')}`, LOGICAL_W / 2, combo > 1 ? midY - 8 : midY);
if (combo > 1) {
const mult = 1 + Math.floor((combo - 1) / 3) * 0.5;
ctx.font = 'bold 12px ui-monospace, Menlo, Consolas, monospace';
ctx.fillStyle = combo >= 6 ? '#ffe600' : '#00ff88';
ctx.fillText(`COMBO ${combo}x (${mult.toFixed(1)}x PTS)`, LOGICAL_W / 2, midY + 10);
}
// Lives (drawn as glowing energy capsules on right)
ctx.textAlign = 'right';
ctx.font = 'bold 11px ui-monospace, Menlo, Consolas, monospace';
ctx.fillStyle = 'rgba(232, 240, 255, 0.55)';
ctx.fillText('LIVES', LOGICAL_W - 18, midY - 10);
for (let i = 0; i < lives; i++) {
const lx = LOGICAL_W - 22 - i * 24;
ctx.save();
ctx.shadowColor = '#ff2a85';
ctx.shadowBlur = 8;
ctx.fillStyle = '#ff2a85';
roundRect(lx - 14, midY + 2, 16, 8, 4);
ctx.fill();
ctx.restore();
}
// Level intro banner
if (state === 'PLAYING' && levelBannerTimer > 0) {
const alpha = Math.min(1, levelBannerTimer * 1.5);
ctx.save();
ctx.globalAlpha = alpha;
ctx.textAlign = 'center';
ctx.shadowColor = '#00f3ff';
ctx.shadowBlur = 16;
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 24px ui-monospace, Menlo, Consolas, monospace';
const suffix = loopCycle > 0 ? ` [OVERDRIVE +${loopCycle}]` : '';
ctx.fillText(LEVELS[levelIndex].name + suffix, LOGICAL_W / 2, LOGICAL_H * 0.62);
ctx.shadowBlur = 0;
ctx.fillStyle = '#00f3ff';
ctx.font = 'bold 13px ui-monospace, Menlo, Consolas, monospace';
ctx.fillText('CLICK OR PRESS SPACE TO LAUNCH', LOGICAL_W / 2, LOGICAL_H * 0.62 + 28);
ctx.restore();
}
}
function drawOverlay(title, subtitle, prompt, accent = '#00f3ff') {
ctx.fillStyle = 'rgba(5, 7, 18, 0.78)';
ctx.fillRect(0, TOP_BAR_H, LOGICAL_W, LOGICAL_H - TOP_BAR_H);
const cx = LOGICAL_W / 2;
const cy = LOGICAL_H / 2 + 10;
ctx.save();
ctx.shadowColor = accent;
ctx.shadowBlur = 25;
ctx.strokeStyle = accent;
ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(13, 17, 38, 0.94)';
roundRect(cx - 250, cy - 115, 500, 230, 14);
ctx.fill();
ctx.stroke();
ctx.restore();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.save();
ctx.shadowColor = accent;
ctx.shadowBlur = 18;
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 34px ui-monospace, Menlo, Consolas, monospace';
ctx.fillText(title, cx, cy - 52);
ctx.restore();
ctx.fillStyle = 'rgba(232, 240, 255, 0.85)';
ctx.font = '15px ui-monospace, Menlo, Consolas, monospace';
subtitle.forEach((line, idx) => {
ctx.fillText(line, cx, cy - 4 + idx * 24);
});
const blink = 0.6 + 0.4 * Math.sin(performance.now() * 0.007);
ctx.fillStyle = accent;
ctx.globalAlpha = blink;
ctx.font = 'bold 16px ui-monospace, Menlo, Consolas, monospace';
ctx.fillText(prompt, cx, cy + 78);
ctx.globalAlpha = 1;
}
function render() {
ctx.save();
// Apply screen shake
if (shakeTime > 0 && shakeMag > 0) {
const sx = (Math.random() * 2 - 1) * shakeMag;
const sy = (Math.random() * 2 - 1) * shakeMag;
ctx.translate(sx, sy);
}
drawBackground();
if (state !== 'TITLE') {
drawBricks();
drawPowerups();
drawPaddle();
drawBalls();
drawEffects();
} else {
drawEffects();
}
drawHUD();
if (state === 'TITLE') {
drawOverlay(
'NEON BREAKOUT',
[
'5 Handcrafted Sectors + Overdrive Loop',
'Chain brick hits without touching paddle for COMBOS!',
'Watch out for Red Explosive Cores & Power-up Capsules'
],
'[ CLICK OR PRESS SPACE TO START ]',
'#00f3ff'
);
} else if (state === 'PAUSED') {
drawOverlay(
'SYSTEM PAUSED',
['Take a breather, pilot.'],
'[ CLICK OR PRESS P / SPACE TO RESUME ]',
'#ffe600'
);
} else if (state === 'LEVEL_CLEAR') {
const nextIdx = (levelIndex + 1) % LEVELS.length;
drawOverlay(
'SECTOR CLEARED!',
[
`Clear Bonus Awarded! Current Score: ${score}`,
`Next Up: ${LEVELS[nextIdx].name}`
],
'[ CLICK OR PRESS SPACE FOR NEXT SECTOR ]',
'#00ff88'
);
} else if (state === 'GAME_OVER') {
drawOverlay(
'GAME OVER',
[
`Final Score: ${score} | Max Combo: ${maxCombo}x`,
score >= highScore && score > 0 ? 'NEW HIGH SCORE ACHIEVED!' : `High Score: ${highScore}`
],
'[ CLICK OR PRESS SPACE TO PLAY AGAIN ]',
'#ff2a85'
);
}
ctx.restore();
}
// =========================================================
// MAIN ANIMATION LOOP
// =========================================================
let lastTime = performance.now();
function frame(now) {
const dt = Math.min(0.033, Math.max(0.001, (now - lastTime) / 1000));
lastTime = now;
update(dt);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>

