SOURCE / PINNED RELEASE
Made of little things.
Obsidian Breaker
- Release
- 44c589c52043…
- Author-recorded commit
- 9ee5b61ce1c9…
- License
- LICENSE
- Author’s source reference
- nostr://npub1dmmh0futyszl4445mv4xrygkgsgj8vpu79yx3qyy9qxmz4pemtzqlqmc87/wss%3A%2F%2Fgit.napplet.soy%2F/n-de5dc6a02e7
Archive hash verified: 240ec416a9cbfd9c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import './napplet-settings.js';
import { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { SoundEngine } from './audio.js';
import { runtimeHasDomain } from './domain-availability.js';
import { brickSpec, GRID_COLUMNS, LEVELS, type BrickKind } from './levels.js';
import {
DEFAULT_SETTINGS,
onGameSettings,
type GameSettings,
} from './napplet-settings.js';
import './styles.css';
const TAU = Math.PI * 2;
const AIM_MAX = 1.02;
const STEP = 1 / 120;
const MAX_BALLS = 4;
const MAX_LIVES = 5;
const STORAGE_KEY = 'obsidian-breaker/best-v1';
const BRICK_ASPECT = 0.52;
const BALL_SPEED_SCALE = 1.5;
type Phase = 'docked' | 'playing' | 'paused' | 'levelClear' | 'gameOver' | 'victory';
type PowerKind = 'wide' | 'slow' | 'split' | 'blast';
interface Brick {
col: number;
row: number;
kind: BrickKind;
hp: number;
maxHp: number;
x: number;
y: number;
w: number;
h: number;
alive: boolean;
flash: number;
seed: number;
}
interface Ball {
x: number;
y: number;
vx: number;
vy: number;
r: number;
stuck: boolean;
trail: Array<{ x: number; y: number; life: number }>;
}
interface Mote {
x: number;
y: number;
radius: number;
drift: number;
speed: number;
phase: number;
alpha: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
size: number;
color: string;
gravity: number;
kind: 'spark' | 'shard';
rotation: number;
spin: number;
}
interface Ring {
x: number;
y: number;
life: number;
maxLife: number;
from: number;
to: number;
color: string;
}
interface FloatText {
x: number;
y: number;
vy: number;
life: number;
maxLife: number;
text: string;
color: string;
size: number;
}
interface PowerUp {
x: number;
y: number;
vy: number;
kind: PowerKind;
spin: number;
}
interface Burst {
x: number;
y: number;
radius: number;
delay: number;
}
interface Point {
x: number;
y: number;
}
interface Prediction {
points: Point[];
hitBrick: Brick | null;
lost: boolean;
bounces: number;
catches: Point[];
impacts: number;
}
const palette: Record<
BrickKind,
{ top: string; bottom: string; edge: string; glint: string; shard: string }
> = {
shale: {
top: '#d9803f',
bottom: '#743116',
edge: '#ffbe84',
glint: 'rgba(255,225,190,0.55)',
shard: '#ff9a4d',
},
granite: {
top: '#a37b66',
bottom: '#4b3228',
edge: '#d9b7a0',
glint: 'rgba(255,230,215,0.35)',
shard: '#c49a82',
},
basalt: {
top: '#7f5d9b',
bottom: '#31213f',
edge: '#c3a0e0',
glint: 'rgba(230,210,255,0.35)',
shard: '#a97ccc',
},
volatile: {
top: '#3edc8e',
bottom: '#0f4c33',
edge: '#b9ffdc',
glint: 'rgba(210,255,235,0.65)',
shard: '#5affae',
},
obsidian: {
top: '#46576a',
bottom: '#141a23',
edge: '#9db4cc',
glint: 'rgba(210,230,255,0.3)',
shard: '#7c93a8',
},
};
const powerColors: Record<PowerKind, { core: string; glow: string; label: string }> = {
wide: { core: '#7ce8ff', glow: 'rgba(80,210,255,0.55)', label: 'W' },
slow: { core: '#c9a2ff', glow: 'rgba(180,120,255,0.55)', label: 'C' },
split: { core: '#b6ff7a', glow: 'rgba(140,255,120,0.55)', label: 'M' },
blast: { core: '#ffb45c', glow: 'rgba(255,120,40,0.65)', label: 'B' },
};
const audio = new SoundEngine();
function requireElement<T extends HTMLElement>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Missing required element: ${selector}`);
return element;
}
const elements = {
app: requireElement<HTMLElement>('#app'),
hud: requireElement<HTMLElement>('.hud'),
canvas: requireElement<HTMLCanvasElement>('#board'),
score: requireElement<HTMLElement>('#scoreValue'),
chain: requireElement<HTMLElement>('#chainValue'),
chainStat: requireElement<HTMLElement>('#chainStat'),
lives: requireElement<HTMLElement>('#livesValue'),
level: requireElement<HTMLElement>('#levelValue'),
best: requireElement<HTMLElement>('#bestValue'),
guideButton: requireElement<HTMLButtonElement>('#guideButton'),
soundButton: requireElement<HTMLButtonElement>('#soundButton'),
warpButton: requireElement<HTMLButtonElement>('#warpButton'),
bulletButton: requireElement<HTMLButtonElement>('#bulletButton'),
pauseButton: requireElement<HTMLButtonElement>('#pauseButton'),
bulletMeter: requireElement<HTMLElement>('#bulletMeter'),
bulletFill: requireElement<HTMLElement>('#bulletFill'),
banner: requireElement<HTMLElement>('#banner'),
bannerKicker: requireElement<HTMLElement>('#bannerKicker'),
bannerTitle: requireElement<HTMLElement>('#bannerTitle'),
hint: requireElement<HTMLElement>('#hint'),
toast: requireElement<HTMLElement>('#toast'),
overlay: requireElement<HTMLElement>('#overlay'),
overlayKicker: requireElement<HTMLElement>('#overlayKicker'),
overlayTitle: requireElement<HTMLElement>('#overlayTitle'),
overlayBody: requireElement<HTMLElement>('#overlayBody'),
overlayActions: requireElement<HTMLElement>('#overlayActions'),
overlayNote: requireElement<HTMLElement>('#overlayNote'),
status: requireElement<HTMLElement>('#gameStatus'),
};
const context2d = elements.canvas.getContext('2d');
if (!context2d) throw new Error('Canvas 2D is unavailable');
const ctx: CanvasRenderingContext2D = context2d;
const g = {
width: 0,
height: 0,
dpr: 1,
phase: 'docked' as Phase,
resumePhase: 'docked' as Phase,
level: 0,
score: 0,
best: 0,
lives: 3,
comboTimer: 0,
combo: 0,
time: 0,
aimT: 0,
aimDir: 1,
shake: 0,
flash: 0,
slowTimer: 0,
wideTimer: 0,
blastTimer: 0,
blastCooldown: 0,
levelClearTimer: 0,
bulletCharge: 1,
bulletActive: false,
warpActive: false,
warpReady: false,
warpHits: 0,
warpCooldown: 0,
warpTimeLeft: 0,
warpCheckTimer: 0,
warpHintShown: false,
lastBulletPercent: -1,
hasSized: false,
pendingLaunch: false,
bestDirty: false,
storageWarned: false,
lastLives: -1,
ballRadius: 5,
paddleBaseW: 90,
paddleH: 10,
bricks: [] as Brick[],
balls: [] as Ball[],
motes: [] as Mote[],
particles: [] as Particle[],
rings: [] as Ring[],
floats: [] as FloatText[],
powerups: [] as PowerUp[],
bursts: [] as Burst[],
keys: { left: false, right: false },
bounds: { top: 40, bottom: 560 },
paddle: { x: 0, y: 0, w: 90, h: 10, targetX: 0, vx: 0 },
settings: { ...DEFAULT_SETTINGS } as GameSettings,
guide: DEFAULT_SETTINGS.showGuide,
soundOn: true,
bg: null as HTMLCanvasElement | null,
acc: 0,
};
const bannerTimer = { id: 0 };
const toastTimer = { id: 0 };
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
function formatNumber(value: number): string {
return Math.round(value).toLocaleString('en-US');
}
function random(seed: number): number {
const value = Math.sin(seed * 12.9898) * 43758.5453;
return value - Math.floor(value);
}
function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setText(element: HTMLElement, value: string): void {
if (element.textContent !== value) element.textContent = value;
}
function announce(message: string): void {
elements.status.textContent = message;
}
function showBanner(kicker: string, title: string, duration = 1500): void {
window.clearTimeout(bannerTimer.id);
elements.bannerKicker.textContent = kicker;
elements.bannerTitle.textContent = title;
elements.banner.classList.add('is-visible');
bannerTimer.id = window.setTimeout(() => elements.banner.classList.remove('is-visible'), duration);
}
function showToast(message: string, duration = 1500): void {
window.clearTimeout(toastTimer.id);
elements.toast.textContent = message;
elements.toast.classList.add('is-visible');
toastTimer.id = window.setTimeout(() => elements.toast.classList.remove('is-visible'), duration);
}
function updateGuideButton(): void {
elements.guideButton.setAttribute('aria-pressed', String(g.guide));
elements.guideButton.classList.toggle('is-off', !g.guide);
}
function updateSoundButton(): void {
elements.soundButton.setAttribute('aria-pressed', String(g.soundOn));
elements.soundButton.classList.toggle('is-off', !g.soundOn);
elements.soundButton.setAttribute('aria-label', g.soundOn ? 'Mute sound' : 'Unmute sound');
}
function updatePauseButton(): void {
const paused = g.phase === 'paused';
elements.pauseButton.setAttribute('aria-label', paused ? 'Resume' : 'Pause');
elements.pauseButton.title = paused ? 'Resume' : 'Pause';
}
function updateHud(): void {
setText(elements.score, formatNumber(g.score));
setText(elements.best, formatNumber(Math.max(g.best, g.score)));
setText(elements.chain, g.combo > 1 ? `x${Math.min(g.combo, 99)}` : '—');
elements.chainStat.classList.toggle('is-hot', g.combo > 1);
setText(
elements.level,
`${String(g.level + 1).padStart(2, '0')}/${String(LEVELS.length).padStart(2, '0')}`,
);
if (g.lives !== g.lastLives) {
const filled = clamp(g.lives, 0, MAX_LIVES);
const empty = Math.max(0, MAX_LIVES - filled);
elements.lives.textContent = '◆'.repeat(filled) + '◇'.repeat(empty);
elements.lives.setAttribute('aria-label', `${g.lives} shields`);
g.lastLives = g.lives;
}
}
function timeScale(): number {
let scale = 1;
if (g.bulletActive) scale *= 0.34;
if (g.warpActive) scale *= 2.4;
return scale;
}
function updateAbilityHud(): void {
const percent = Math.round(g.bulletCharge * 100);
if (percent !== g.lastBulletPercent) {
g.lastBulletPercent = percent;
elements.bulletFill.style.transform = `scaleX(${g.bulletCharge.toFixed(3)})`;
elements.bulletMeter.setAttribute('aria-valuenow', String(percent));
elements.bulletButton.setAttribute('aria-label', `Bullet time (B): ${percent}% charged`);
elements.bulletButton.title = `Bullet time (B): ${percent}% charged`;
}
elements.bulletButton.classList.toggle('is-active', g.bulletActive);
elements.bulletButton.setAttribute('aria-pressed', String(g.bulletActive));
elements.bulletMeter.classList.toggle('is-active', g.bulletActive);
elements.bulletMeter.classList.toggle('is-low', g.bulletCharge < 0.22);
const canWarp = g.warpActive || (g.warpReady && g.warpCooldown <= 0);
elements.warpButton.classList.toggle('is-ready', g.warpReady && !g.warpActive && g.warpCooldown <= 0);
elements.warpButton.classList.toggle('is-active', g.warpActive);
elements.warpButton.setAttribute('aria-pressed', String(g.warpActive));
elements.warpButton.setAttribute('aria-disabled', String(!canWarp));
let label: string;
if (g.warpActive) label = 'Warp active (F to release)';
else if (g.warpCooldown > 0) label = `Warp recharging (${Math.ceil(g.warpCooldown)}s)`;
else if (g.warpReady) label = 'Warp ready (F): fast-forward through 3 impacts';
else label = 'Warp (F): needs 3 projected wall or stone impacts';
if (elements.warpButton.getAttribute('aria-label') !== label) {
elements.warpButton.setAttribute('aria-label', label);
elements.warpButton.title = label;
}
}
function setWarpReady(ready: boolean): void {
if (g.warpReady === ready) return;
g.warpReady = ready;
updateAbilityHud();
if (ready && !g.warpHintShown) {
g.warpHintShown = true;
showToast('WARP READY · PRESS F OR TAP »', 1500);
}
}
function toggleBulletTime(): void {
if (g.bulletActive) {
g.bulletActive = false;
audio.play('bullet');
showToast('Bullet time released', 900);
updateAbilityHud();
return;
}
if (g.phase !== 'docked' && g.phase !== 'playing') return;
if (g.bulletCharge < 0.12) {
showToast('Bullet time is recharging', 1200);
return;
}
if (g.warpActive) stopWarp(false);
g.bulletActive = true;
g.flash = Math.max(g.flash, 0.08);
audio.play('bullet');
showToast('BULLET TIME', 900);
updateAbilityHud();
announce('Bullet time engaged.');
}
function activateWarp(): void {
if (g.warpActive) {
stopWarp(false);
return;
}
if (g.phase !== 'playing') {
showToast('Warp needs a live ball', 1100);
return;
}
if (g.warpCooldown > 0) {
showToast('Warp is recharging', 1100);
return;
}
if (!g.warpReady) {
showToast('Warp needs 3 projected impacts', 1300);
return;
}
g.bulletActive = false;
g.warpActive = true;
g.warpReady = false;
g.warpHits = 0;
g.warpTimeLeft = 4;
g.flash = Math.max(g.flash, 0.18);
g.shake = Math.max(g.shake, 2);
audio.play('warp');
showToast('WARP · 3 IMPACTS', 1000);
updateAbilityHud();
announce('Warp engaged. Fast-forwarding through three impacts.');
}
function stopWarp(spent: boolean): void {
if (!g.warpActive) return;
g.warpActive = false;
g.warpHits = 0;
g.warpCooldown = spent ? 2.6 : 0.6;
if (spent) showToast('WARP SPENT', 1000);
updateAbilityHud();
}
function registerWarpImpact(): void {
if (!g.warpActive) return;
g.warpHits += 1;
if (g.warpHits >= 3) stopWarp(true);
}
function updateWarpReadiness(): void {
if (g.phase !== 'playing' || g.warpActive || g.warpCooldown > 0) {
setWarpReady(false);
return;
}
const ball = g.balls[0];
if (!ball || ball.stuck) {
setWarpReady(false);
return;
}
const speed = Math.hypot(ball.vx, ball.vy);
if (speed < 0.001) {
setWarpReady(false);
return;
}
const prediction = castPrediction(ball.x, ball.y, ball.vx / speed, ball.vy / speed, ball.r, 5);
setWarpReady(prediction.impacts >= 3);
}
function updateAbilities(dt: number): void {
if (g.bulletActive) {
g.bulletCharge = Math.max(0, g.bulletCharge - dt * 0.24);
if (g.bulletCharge <= 0) {
g.bulletActive = false;
showToast('Bullet time depleted', 1200);
announce('Bullet time depleted.');
}
} else if (g.phase === 'docked' || g.phase === 'playing' || g.phase === 'levelClear') {
g.bulletCharge = Math.min(1, g.bulletCharge + dt * 0.1);
}
if (g.warpActive) {
g.warpTimeLeft -= dt;
if (g.warpTimeLeft <= 0) stopWarp(true);
} else if (g.warpCooldown > 0) {
g.warpCooldown = Math.max(0, g.warpCooldown - dt);
}
if (g.blastTimer > 0) g.blastTimer = Math.max(0, g.blastTimer - dt);
if (g.blastCooldown > 0) g.blastCooldown = Math.max(0, g.blastCooldown - dt);
g.warpCheckTimer -= dt;
if (g.warpCheckTimer <= 0) {
g.warpCheckTimer = 0.12;
updateWarpReadiness();
}
updateAbilityHud();
}
function showOverlay(
kicker: string,
title: string,
body: string,
actions: Array<{ label: string; primary?: boolean; onClick: () => void }>,
note = '',
): void {
elements.overlayKicker.textContent = kicker;
elements.overlayTitle.textContent = title;
elements.overlayBody.textContent = body;
elements.overlayNote.textContent = note;
elements.overlayActions.replaceChildren();
for (const action of actions) {
const button = document.createElement('button');
button.type = 'button';
button.className = action.primary ? 'btn btn-primary' : 'btn';
button.textContent = action.label;
button.addEventListener('click', action.onClick);
elements.overlayActions.append(button);
}
elements.overlay.hidden = false;
}
function hideOverlay(): void {
elements.overlay.hidden = true;
}
function livesForDifficulty(): number {
if (g.settings.difficulty === 'chill') return 5;
if (g.settings.difficulty === 'ruthless') return 2;
return 3;
}
function difficultyFactor(): number {
if (g.settings.difficulty === 'chill') return 0.86;
if (g.settings.difficulty === 'ruthless') return 1.16;
return 1;
}
function currentSpeed(): number {
const base = clamp(g.height * 0.58, 120, 330) * BALL_SPEED_SCALE;
const levelFactor = LEVELS[g.level]?.speed ?? 1;
const slow = g.slowTimer > 0 ? 0.74 : 1;
return base * levelFactor * difficultyFactor() * slow;
}
// ── Layout ────────────────────────────────────────────────────────────────
function buildBricks(levelIndex: number): Brick[] {
const level = LEVELS[levelIndex];
if (!level) return [];
const bricks: Brick[] = [];
level.rows.forEach((row, rowIndex) => {
for (let col = 0; col < GRID_COLUMNS; col += 1) {
const spec = brickSpec(row[col] ?? '.');
if (!spec) continue;
bricks.push({
col,
row: rowIndex,
kind: spec.kind,
hp: spec.hp,
maxHp: spec.kind === 'obsidian' ? 1 : spec.hp,
x: 0,
y: 0,
w: 0,
h: 0,
alive: true,
flash: 0,
seed: rowIndex * 37 + col * 61 + levelIndex * 131 + 1,
});
}
});
return bricks;
}
function computeMetrics(): void {
// Reserve the real HUD footprint: at short frames the styled header is
// taller than the proportional inset, and bricks must never spawn beneath it.
const hudBottom = elements.hud.getBoundingClientRect().bottom - elements.app.getBoundingClientRect().top;
const guidedTop = clamp(g.height * 0.105, 22, 74);
const hudGap = clamp(g.height * 0.012, 2, 8);
g.bounds.top = clamp(Math.max(guidedTop, hudBottom + hudGap), 22, g.height * 0.46);
g.ballRadius = clamp(Math.min(g.width, g.height) * 0.012, 3.2, 7);
g.paddleBaseW = clamp(g.width * 0.15, 42, 150);
g.paddleH = clamp(g.height * 0.02, 7, 14);
const bottomInset = clamp(g.height * 0.065, 16, 46);
g.paddle.y = g.height - bottomInset - g.paddleH;
g.bounds.bottom = g.paddle.y;
}
function applyPaddleWidth(): void {
g.paddle.w = g.paddleBaseW * (g.wideTimer > 0 ? 1.45 : 1);
g.paddle.h = g.paddleH;
const half = g.paddle.w / 2;
g.paddle.x = clamp(g.paddle.x || g.width / 2, half, g.width - half);
g.paddle.targetX = clamp(g.paddle.targetX || g.paddle.x, half, g.width - half);
}
function paddleRect(): { x: number; y: number; w: number; h: number } {
return {
x: g.paddle.x - g.paddle.w / 2,
y: g.paddle.y,
w: g.paddle.w,
h: g.paddle.h,
};
}
function layoutBricks(): void {
const level = LEVELS[g.level];
if (!level) return;
const rows = level.rows.length;
const gap = clamp(g.width * 0.006, 0.8, 5);
const side = clamp(g.width * 0.02, 3, 24);
const top = g.bounds.top + clamp(g.height * 0.015, 2, 10);
const region = Math.max(40, (g.paddle.y - top) * 0.72);
const maxWidth = (g.width - side * 2 - gap * (GRID_COLUMNS - 1)) / GRID_COLUMNS;
const maxHeight = (region - gap * (rows - 1)) / rows;
const brickWidth = Math.max(3, Math.min(maxWidth, maxHeight / BRICK_ASPECT));
const brickHeight = brickWidth * BRICK_ASPECT;
const totalWidth = GRID_COLUMNS * brickWidth + gap * (GRID_COLUMNS - 1);
const totalHeight = rows * brickHeight + gap * (rows - 1);
const originX = (g.width - totalWidth) / 2;
const originY = top + Math.max(0, region - totalHeight) * 0.5;
for (const brick of g.bricks) {
brick.x = originX + brick.col * (brickWidth + gap);
brick.y = originY + brick.row * (brickHeight + gap);
brick.w = brickWidth;
brick.h = brickHeight;
}
}
function makeBall(x: number, y: number): Ball {
return { x, y, vx: 0, vy: 0, r: g.ballRadius, stuck: true, trail: [] };
}
function resetDocked(): void {
g.balls = [makeBall(g.paddle.x, g.paddle.y - g.ballRadius - 2)];
g.aimT = 0;
g.aimDir = 1;
}
function resetMotes(): void {
const count = Math.round(clamp((g.width * g.height) / 16000, 12, 44));
g.motes = Array.from({ length: count }, () => ({
x: Math.random() * g.width,
y: Math.random() * g.height,
radius: 0.6 + Math.random() * 1.8,
drift: 6 + Math.random() * 16,
speed: 4 + Math.random() * 16,
phase: Math.random() * TAU,
alpha: 0.08 + Math.random() * 0.24,
}));
}
function resizeCanvas(): void {
const rect = elements.app.getBoundingClientRect();
const width = Math.max(120, Math.round(rect.width));
const height = Math.max(100, Math.round(rect.height));
const oldWidth = g.width;
const oldHeight = g.height;
g.width = width;
g.height = height;
g.dpr = Math.min(window.devicePixelRatio || 1, 2);
elements.canvas.width = Math.round(width * g.dpr);
elements.canvas.height = Math.round(height * g.dpr);
computeMetrics();
layoutBricks();
if (g.hasSized && oldWidth > 0 && oldHeight > 0) {
const scaleX = width / oldWidth;
const scaleY = height / oldHeight;
for (const ball of g.balls) {
ball.x *= scaleX;
ball.y *= scaleY;
ball.r = g.ballRadius;
}
g.paddle.x *= scaleX;
g.paddle.targetX *= scaleX;
for (const power of g.powerups) {
power.x *= scaleX;
power.y *= scaleY;
}
}
applyPaddleWidth();
if (g.phase === 'docked') resetDocked();
buildBackground();
resetMotes();
g.hasSized = true;
}
function buildBackground(): void {
const background = document.createElement('canvas');
background.width = Math.max(1, Math.round(g.width * g.dpr));
background.height = Math.max(1, Math.round(g.height * g.dpr));
const paint = background.getContext('2d');
if (!paint) return;
paint.scale(g.dpr, g.dpr);
const width = g.width;
const height = g.height;
const base = paint.createLinearGradient(0, 0, 0, height);
base.addColorStop(0, '#130c14');
base.addColorStop(0.45, '#0d0810');
base.addColorStop(1, '#060407');
paint.fillStyle = base;
paint.fillRect(0, 0, width, height);
const topGlow = paint.createRadialGradient(
width * 0.5,
-height * 0.12,
height * 0.04,
width * 0.5,
-height * 0.12,
height * 0.95,
);
topGlow.addColorStop(0, 'rgba(255,110,40,0.18)');
topGlow.addColorStop(0.5, 'rgba(255,80,30,0.05)');
topGlow.addColorStop(1, 'rgba(255,60,20,0)');
paint.fillStyle = topGlow;
paint.fillRect(0, 0, width, height);
const bottomGlow = paint.createRadialGradient(
width * 0.5,
height * 1.06,
height * 0.02,
width * 0.5,
height * 1.06,
height * 0.7,
);
bottomGlow.addColorStop(0, 'rgba(255,90,25,0.22)');
bottomGlow.addColorStop(1, 'rgba(255,50,10,0)');
paint.fillStyle = bottomGlow;
paint.fillRect(0, height * 0.55, width, height * 0.45);
paint.lineWidth = 1;
for (let line = 0; line < 24; line += 1) {
const y = height * (0.14 + (line / 24) * 0.9);
const alpha = 0.016 + random(line + 3) * 0.04;
paint.strokeStyle = `rgba(255,150,90,${alpha})`;
paint.beginPath();
for (let x = -10; x <= width + 10; x += width / 12) {
const jitter = Math.sin(x * 0.012 + line * 1.7) * height * 0.006;
if (x <= -10) paint.moveTo(x, y + jitter);
else paint.lineTo(x, y + jitter);
}
paint.stroke();
}
for (let vein = 0; vein < 5; vein += 1) {
const startX = random(vein * 7) * width;
const startY = height * (0.35 + random(vein * 11) * 0.45);
paint.strokeStyle = `rgba(255,92,35,${0.035 + random(vein * 5) * 0.03})`;
paint.lineWidth = 1 + random(vein * 13) * 1.4;
paint.beginPath();
paint.moveTo(startX, startY);
paint.bezierCurveTo(
startX + (random(vein + 4) - 0.5) * width * 0.3,
startY - height * 0.12,
startX + (random(vein + 9) - 0.5) * width * 0.4,
startY + height * 0.16,
startX + (random(vein + 2) - 0.5) * width * 0.5,
startY + height * 0.24,
);
paint.stroke();
}
const speckles = Math.round(clamp((width * height) / 9000, 40, 150));
for (let index = 0; index < speckles; index += 1) {
const x = random(index * 3 + 1) * width;
const y = random(index * 7 + 2) * height;
const alpha = 0.04 + random(index * 5) * 0.12;
paint.fillStyle = `rgba(255,190,130,${alpha})`;
paint.fillRect(x, y, 1, 1);
}
const vignette = paint.createRadialGradient(
width / 2,
height / 2,
Math.min(width, height) * 0.25,
width / 2,
height / 2,
Math.max(width, height) * 0.78,
);
vignette.addColorStop(0, 'rgba(0,0,0,0)');
vignette.addColorStop(1, 'rgba(0,0,0,0.55)');
paint.fillStyle = vignette;
paint.fillRect(0, 0, width, height);
g.bg = background;
}
// ── Run flow ──────────────────────────────────────────────────────────────
function startRun(): void {
g.score = 0;
g.level = 0;
g.lives = livesForDifficulty();
g.combo = 0;
g.comboTimer = 0;
g.powerups = [];
g.bursts = [];
g.floats = [];
g.rings = [];
g.particles = [];
g.bulletCharge = 1;
g.bulletActive = false;
g.warpActive = false;
g.warpReady = false;
g.warpHits = 0;
g.warpCooldown = 0;
g.warpTimeLeft = 0;
g.warpHintShown = false;
g.blastTimer = 0;
g.blastCooldown = 0;
hideOverlay();
loadLevel(0);
updateAbilityHud();
updatePauseButton();
announce(`New run. ${g.lives} shields. Seam one, Ashfall.`);
}
function loadLevel(index: number): void {
g.level = index;
g.bricks = buildBricks(index);
g.powerups = [];
g.bursts = [];
g.floats = [];
g.rings = [];
g.particles = [];
g.slowTimer = 0;
g.wideTimer = 0;
g.blastTimer = 0;
g.blastCooldown = 0;
g.bulletActive = false;
g.warpActive = false;
g.warpReady = false;
g.warpHits = 0;
g.warpCooldown = 0;
g.warpTimeLeft = 0;
g.combo = 0;
g.comboTimer = 0;
g.phase = 'docked';
g.acc = 0;
g.shake = 0;
g.flash = 0;
layoutBricks();
applyPaddleWidth();
resetDocked();
updatePauseButton();
updateHud();
updateAbilityHud();
const level = LEVELS[index];
if (level) {
showBanner(`SEAM ${String(index + 1).padStart(2, '0')} / ${String(LEVELS.length).padStart(2, '0')}`, level.name.toUpperCase(), 1700);
announce(`Seam ${index + 1}: ${level.name}. ${level.tagline}.`);
}
}
function launch(): void {
if (g.phase !== 'docked') return;
const ball = g.balls[0];
if (!ball) return;
audio.unlock();
const angle = g.aimT * AIM_MAX;
const speed = currentSpeed();
ball.stuck = false;
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.cos(angle) * speed;
ball.trail = [];
g.phase = 'playing';
g.acc = 0;
audio.play('launch');
announce('Ball away.');
}
function togglePause(): void {
if (g.phase === 'paused') {
resume();
return;
}
if (g.phase !== 'docked' && g.phase !== 'playing') return;
g.resumePhase = g.phase;
g.phase = 'paused';
updatePauseButton();
showOverlay(
'Frozen',
'The seam waits',
'Every impact is held in place. Resume when you are ready.',
[
{ label: 'Resume', primary: true, onClick: resume },
{ label: 'Restart run', onClick: startRun },
],
runtimeHasDomain(STORAGE_DOMAIN) ? '' : 'Storage is unavailable here, so best scores last only for this session.',
);
announce('Paused.');
}
function resume(): void {
if (g.phase !== 'paused') return;
g.phase = g.resumePhase;
hideOverlay();
updatePauseButton();
announce('Resumed.');
}
function endRun(victory: boolean): void {
g.bulletActive = false;
stopWarp(false);
g.warpReady = false;
if (g.score > g.best) {
g.best = g.score;
g.bestDirty = true;
}
if (g.bestDirty) void saveBest();
hideOverlay();
updateHud();
audio.play(victory ? 'win' : 'life');
if (victory) {
g.phase = 'victory';
showOverlay(
'Crown broken',
'Every seam is dust',
`Final score ${formatNumber(g.score)} · Best ${formatNumber(g.best)}. All ${LEVELS.length} strata are cleared.`,
[{ label: 'Run again', primary: true, onClick: startRun }],
'Best scores save when the host provides storage.',
);
announce(`Victory. Final score ${g.score}.`);
} else {
g.phase = 'gameOver';
showOverlay(
'Run ended',
'The seam holds',
`Score ${formatNumber(g.score)} · Best ${formatNumber(g.best)}. The volatile veins reset for the next descent.`,
[{ label: 'Run again', primary: true, onClick: startRun }],
runtimeHasDomain(STORAGE_DOMAIN) ? '' : 'Storage is unavailable here, so best scores last only for this session.',
);
announce(`Run ended. Score ${g.score}. Best ${g.best}.`);
}
updatePauseButton();
}
function clearLevel(): void {
if (g.phase !== 'playing') return;
g.bulletActive = false;
stopWarp(false);
g.warpReady = false;
g.phase = 'levelClear';
g.levelClearTimer = 2.1;
g.acc = 0;
const bonus = 400 + (g.level + 1) * 200 + g.lives * 100;
g.score += bonus;
if (g.score > g.best) {
g.best = g.score;
g.bestDirty = true;
}
void saveBest();
g.flash = Math.max(g.flash, 0.2);
g.shake = Math.max(g.shake, 4);
spawnCelebration();
audio.play('level');
showBanner(`SEAM ${String(g.level + 1).padStart(2, '0')} CLEAR`, `+${formatNumber(bonus)} FRAGMENTS`, 1900);
announce(`Seam ${g.level + 1} cleared. Bonus ${bonus}.`);
updateHud();
}
async function saveBest(): Promise<void> {
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
await storage.setItem(STORAGE_KEY, String(Math.floor(g.best)));
g.bestDirty = false;
} catch {
if (!g.storageWarned) {
g.storageWarned = true;
showToast('This host did not save the best score', 2400);
}
}
}
async function loadBest(): Promise<void> {
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
const raw = await Promise.race([
storage.getItem(STORAGE_KEY),
new Promise<null>((resolve) => {
window.setTimeout(() => resolve(null), 2500);
}),
]);
if (typeof raw === 'string') {
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed)) g.best = Math.max(0, parsed);
}
} catch {
// Optional storage can exist without working methods; keep the run usable.
}
updateHud();
}
// ── Simulation ────────────────────────────────────────────────────────────
function updateAim(dt: number): void {
const pace =
g.settings.aimPace === 'slow' ? 0.34 : g.settings.aimPace === 'quick' ? 0.92 : 0.58;
g.aimT += g.aimDir * pace * dt;
if (g.aimT > 1) {
g.aimT = 1;
g.aimDir = -1;
} else if (g.aimT < -1) {
g.aimT = -1;
g.aimDir = 1;
}
}
function updatePaddle(dt: number): void {
const keyboardSpeed = Math.max(g.width * 1.5, 320);
if (g.keys.left) g.paddle.targetX -= keyboardSpeed * dt;
if (g.keys.right) g.paddle.targetX += keyboardSpeed * dt;
const half = g.paddle.w / 2;
g.paddle.targetX = clamp(g.paddle.targetX, half, g.width - half);
const delta = g.paddle.targetX - g.paddle.x;
const maxMove = Math.max(g.width * 2.4, 620) * dt;
const move = clamp(delta, -maxMove, maxMove);
g.paddle.vx = move / Math.max(dt, 0.0001);
g.paddle.x = clamp(g.paddle.x + move, half, g.width - half);
if (g.phase === 'docked') {
const ball = g.balls[0];
if (ball) {
ball.x = g.paddle.x;
ball.y = g.paddle.y - g.ballRadius - 2;
}
}
}
function updateBall(ball: Ball, dt: number): void {
const speed = currentSpeed();
const magnitude = Math.hypot(ball.vx, ball.vy);
if (!Number.isFinite(magnitude) || magnitude < 0.001) {
ball.vx = 0;
ball.vy = -speed;
} else {
ball.vx = (ball.vx / magnitude) * speed;
ball.vy = (ball.vy / magnitude) * speed;
}
const distance = speed * dt;
const steps = clamp(Math.ceil(distance / Math.max(1, ball.r * 0.65)), 1, 12);
const stepDt = dt / steps;
for (let index = 0; index < steps; index += 1) {
ball.x += ball.vx * stepDt;
ball.y += ball.vy * stepDt;
collideWalls(ball);
collidePaddle(ball);
resolveBrickCollision(ball);
if (g.phase !== 'playing') break;
}
}
function collideWalls(ball: Ball): void {
const radius = ball.r;
const top = g.bounds.top + radius;
if (ball.y < top) {
ball.y = top;
ball.vy = Math.abs(ball.vy);
spawnSparks(ball.x, top, '255,160,90', 2);
registerWarpImpact();
}
if (ball.x < radius) {
ball.x = radius;
ball.vx = Math.abs(ball.vx);
spawnSparks(radius, ball.y, '255,160,90', 2);
registerWarpImpact();
} else if (ball.x > g.width - radius) {
ball.x = g.width - radius;
ball.vx = -Math.abs(ball.vx);
spawnSparks(g.width - radius, ball.y, '255,160,90', 2);
registerWarpImpact();
}
}
function collidePaddle(ball: Ball): void {
const rect = paddleRect();
const closestX = clamp(ball.x, rect.x, rect.x + rect.w);
const closestY = clamp(ball.y, rect.y, rect.y + rect.h);
const dx = ball.x - closestX;
const dy = ball.y - closestY;
if (dx * dx + dy * dy > ball.r * ball.r) return;
if (ball.vy > 0 && ball.y < rect.y + rect.h * 0.9) {
const relative = clamp((ball.x - g.paddle.x) / (g.paddle.w / 2), -1, 1);
const angle = relative * 1.08;
const speed = currentSpeed();
ball.x = clamp(ball.x, rect.x + ball.r, rect.x + rect.w - ball.r);
ball.y = rect.y - ball.r - 0.5;
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle) * speed);
onPaddleHit();
return;
}
const side = ball.x < g.paddle.x ? -1 : 1;
const speed = currentSpeed();
ball.x = g.paddle.x + side * (g.paddle.w / 2 + ball.r + 0.5);
ball.vx = side * Math.abs(ball.vx || speed * 0.4);
ball.vy = -Math.abs(ball.vy || speed * 0.4);
}
function onPaddleHit(): void {
g.combo = 0;
g.comboTimer = 0;
g.shake = Math.max(g.shake, 1.2);
audio.play('paddle');
spawnSparks(g.paddle.x, g.paddle.y, '255,190,120', 4);
}
function resolveBrickCollision(ball: Ball): void {
let best: Brick | null = null;
let bestPenetration = 0;
let bestX = 0;
let bestY = 0;
for (const brick of g.bricks) {
if (!brick.alive) continue;
const closestX = clamp(ball.x, brick.x, brick.x + brick.w);
const closestY = clamp(ball.y, brick.y, brick.y + brick.h);
const dx = ball.x - closestX;
const dy = ball.y - closestY;
const distance = Math.hypot(dx, dy);
let nx = 0;
let ny = 0;
let penetration = 0;
if (distance > 0.0001) {
if (distance >= ball.r) continue;
nx = dx / distance;
ny = dy / distance;
penetration = ball.r - distance;
} else {
const left = ball.x - brick.x;
const right = brick.x + brick.w - ball.x;
const top = ball.y - brick.y;
const bottom = brick.y + brick.h - ball.y;
const minimum = Math.min(left, right, top, bottom);
if (minimum === left) {
nx = -1;
penetration = left + ball.r;
} else if (minimum === right) {
nx = 1;
penetration = right + ball.r;
} else if (minimum === top) {
ny = -1;
penetration = top + ball.r;
} else {
ny = 1;
penetration = bottom + ball.r;
}
}
if (penetration > bestPenetration) {
bestPenetration = penetration;
best = brick;
bestX = nx;
bestY = ny;
}
}
if (!best) return;
ball.x += bestX * (bestPenetration + 0.05);
ball.y += bestY * (bestPenetration + 0.05);
const dot = ball.vx * bestX + ball.vy * bestY;
if (dot < 0) {
ball.vx -= 2 * dot * bestX;
ball.vy -= 2 * dot * bestY;
}
registerWarpImpact();
if (g.blastTimer > 0 && g.blastCooldown <= 0) {
g.blastCooldown = 0.16;
const radius = Math.max(ball.r * 7, best.w * 1.8);
detonate(ball.x, ball.y, radius, 2);
spawnSparks(ball.x, ball.y, '255,180,100', 9);
}
if (best.kind === 'obsidian') {
if (dot < 0) {
best.flash = 0.18;
g.shake = Math.max(g.shake, 1.4);
spawnSparks(ball.x, ball.y, '170,200,230', 3);
audio.play('paddle');
}
return;
}
damageBrick(best, 1);
}
function damageBrick(brick: Brick, amount: number): void {
if (!brick.alive || brick.kind === 'obsidian') return;
brick.flash = 0.2;
brick.hp -= amount;
if (brick.hp > 0) {
g.score += 15;
g.comboTimer = Math.max(g.comboTimer, 1.1);
audio.play('brick');
spawnSparks(
brick.x + brick.w / 2,
brick.y + brick.h / 2,
palette[brick.kind].shard === '#ff9a4d' ? '255,160,90' : '220,190,190',
3,
);
return;
}
destroyBrick(brick);
}
function destroyBrick(brick: Brick): void {
brick.alive = false;
brick.flash = 0;
g.combo += 1;
g.comboTimer = 3.2;
const multiplier = 1 + Math.min(g.combo - 1, 12) * 0.5;
const base =
brick.kind === 'volatile' ? 140 : brick.kind === 'basalt' ? 90 : brick.kind === 'granite' ? 70 : 55;
g.score += Math.round(base * multiplier);
const centerX = brick.x + brick.w / 2;
const centerY = brick.y + brick.h / 2;
spawnShards(centerX, centerY, palette[brick.kind].shard, brick.kind === 'volatile' ? 18 : 10);
g.shake = Math.max(g.shake, brick.kind === 'volatile' ? 4 : 2.4);
audio.play('crack');
if (brick.kind === 'volatile') {
const radius = Math.max(brick.w, brick.h) * 2.9;
g.bursts.push({ x: centerX, y: centerY, radius, delay: 0.06 });
} else if (g.powerups.length < 2 && Math.random() < 0.13) {
spawnPowerUp(centerX, centerY);
}
if (g.combo > 1 && g.combo % 5 === 0) {
g.floats.push({
x: centerX,
y: centerY,
vy: -34,
life: 1,
maxLife: 1,
text: `CHAIN x${g.combo}`,
color: '#ffd7a1',
size: clamp(g.height * 0.024, 9, 16),
});
}
}
function detonate(x: number, y: number, radius: number, damage = 3): void {
g.rings.push({ x, y, life: 0.55, maxLife: 0.55, from: radius * 0.08, to: radius, color: '90,255,165' });
g.rings.push({ x, y, life: 0.3, maxLife: 0.3, from: radius * 0.05, to: radius * 0.6, color: '255,190,110' });
g.flash = Math.max(g.flash, 0.24);
g.shake = Math.max(g.shake, 7);
audio.play('boom');
for (let index = 0; index < 22; index += 1) {
const angle = Math.random() * TAU;
const speed = 40 + Math.random() * Math.min(260, g.height * 0.5);
g.particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0.4 + Math.random() * 0.5,
maxLife: 0.9,
size: 1.4 + Math.random() * 2.4,
color: Math.random() < 0.45 ? '120,255,170' : '255,165,80',
gravity: 120,
kind: 'spark',
rotation: 0,
spin: 0,
});
}
trimParticles();
const targets = g.bricks.filter((brick) => {
if (!brick.alive || brick.kind === 'obsidian') return false;
const dx = brick.x + brick.w / 2 - x;
const dy = brick.y + brick.h / 2 - y;
return Math.hypot(dx, dy) <= radius;
});
for (const target of targets) damageBrick(target, damage);
}
function spawnSparks(x: number, y: number, color: string, count: number): void {
for (let index = 0; index < count; index += 1) {
const angle = Math.random() * TAU;
const speed = 25 + Math.random() * 120;
g.particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 20,
life: 0.22 + Math.random() * 0.32,
maxLife: 0.55,
size: 0.8 + Math.random() * 1.4,
color,
gravity: 80,
kind: 'spark',
rotation: 0,
spin: 0,
});
}
trimParticles();
}
function spawnShards(x: number, y: number, color: string, count: number): void {
for (let index = 0; index < count; index += 1) {
const angle = Math.random() * TAU;
const speed = 40 + Math.random() * 220;
g.particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 40,
life: 0.45 + Math.random() * 0.55,
maxLife: 1,
size: 2 + Math.random() * 4,
color,
gravity: 320,
kind: 'shard',
rotation: Math.random() * TAU,
spin: (Math.random() - 0.5) * 18,
});
}
trimParticles();
}
function trimParticles(): void {
if (g.particles.length > 420) g.particles.splice(0, g.particles.length - 420);
}
function spawnCelebration(): void {
for (let index = 0; index < 60; index += 1) {
const x = Math.random() * g.width;
const y = g.bounds.top + Math.random() * (g.paddle.y - g.bounds.top);
spawnSparks(x, y, index % 3 === 0 ? '120,255,170' : '255,180,90', 1);
}
}
function spawnPowerUp(x: number, y: number): void {
const roll = Math.random();
const kind: PowerKind =
roll < 0.3 ? 'wide' : roll < 0.56 ? 'split' : roll < 0.8 ? 'blast' : 'slow';
g.powerups.push({
x: clamp(x, 16, g.width - 16),
y,
vy: clamp(g.height * 0.12, 36, 90),
kind,
spin: Math.random() * TAU,
});
}
function splitBall(): void {
const source = g.balls.find((ball) => !ball.stuck) ?? g.balls[0];
if (!source) return;
if (g.balls.length >= MAX_BALLS) {
g.score += 150;
showToast('SHARD CAP · +150', 900);
return;
}
const speed = currentSpeed();
const base = Math.atan2(source.vy || -speed, source.vx || 0);
const angle = base + (Math.random() < 0.5 ? 0.45 : -0.45);
g.balls.push({
x: source.x,
y: source.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: g.ballRadius,
stuck: false,
trail: [],
});
spawnSparks(source.x, source.y, '182,255,122', 8);
g.shake = Math.max(g.shake, 1.6);
}
function collectPowerUp(power: PowerUp): void {
audio.play('power');
const label =
power.kind === 'wide'
? 'WIDE SLAB'
: power.kind === 'slow'
? 'COOLANT'
: power.kind === 'split'
? 'SPLIT SHARD'
: 'BLAST CORE';
g.floats.push({
x: power.x,
y: power.y - 8,
vy: -42,
life: 1.1,
maxLife: 1.1,
text: label,
color: powerColors[power.kind].core,
size: clamp(g.height * 0.026, 10, 17),
});
showToast(label, 1100);
if (power.kind === 'wide') {
g.wideTimer = 20;
applyPaddleWidth();
} else if (power.kind === 'slow') {
g.slowTimer = 14;
} else if (power.kind === 'split') {
splitBall();
} else if (power.kind === 'blast') {
g.blastTimer = 12;
g.blastCooldown = 0;
g.floats.push({
x: power.x,
y: power.y - 24,
vy: -36,
life: 1.2,
maxLife: 1.2,
text: 'BLAST RADIUS',
color: powerColors.blast.core,
size: clamp(g.height * 0.026, 10, 17),
});
}
}
function updatePowerUps(dt: number): void {
const rect = paddleRect();
for (let index = g.powerups.length - 1; index >= 0; index -= 1) {
const power = g.powerups[index];
if (!power) continue;
power.y += power.vy * dt;
power.spin += dt * 4;
if (
power.y + 10 >= rect.y &&
power.y - 10 <= rect.y + rect.h &&
power.x + 10 >= rect.x &&
power.x - 10 <= rect.x + rect.w
) {
collectPowerUp(power);
g.powerups.splice(index, 1);
continue;
}
if (power.y > g.height + 30) g.powerups.splice(index, 1);
}
}
function updateBursts(dt: number): void {
for (const burst of g.bursts) burst.delay -= dt;
const ready = g.bursts.filter((burst) => burst.delay <= 0);
if (ready.length === 0) return;
g.bursts = g.bursts.filter((burst) => burst.delay > 0);
for (const burst of ready) detonate(burst.x, burst.y, burst.radius);
}
function loseLife(): void {
g.lives -= 1;
g.combo = 0;
g.comboTimer = 0;
g.wideTimer = 0;
g.slowTimer = 0;
g.blastTimer = 0;
g.blastCooldown = 0;
g.bulletActive = false;
stopWarp(false);
g.warpReady = false;
g.powerups = [];
g.shake = Math.max(g.shake, 9);
g.flash = Math.max(g.flash, 0.4);
audio.play('life');
applyPaddleWidth();
updateHud();
if (g.lives <= 0) {
endRun(false);
return;
}
resetDocked();
g.phase = 'docked';
showBanner('SHIELD LOST', `${g.lives} REMAIN`, 1300);
announce(`Shield lost. ${g.lives} remain.`);
updatePauseButton();
}
function checkLostBalls(): void {
if (g.phase !== 'playing') return;
const remaining = g.balls.filter((ball) => ball.y - ball.r < g.height + 30);
if (remaining.length !== g.balls.length) g.balls = remaining;
if (g.balls.length === 0) loseLife();
}
function maybeClearLevel(): void {
if (g.phase !== 'playing') return;
const remaining = g.bricks.some((brick) => brick.alive && brick.kind !== 'obsidian');
if (!remaining) clearLevel();
}
function updateTrails(dt: number): void {
for (const ball of g.balls) {
if (!ball.stuck) ball.trail.push({ x: ball.x, y: ball.y, life: 1 });
for (const point of ball.trail) point.life -= dt * 4.2;
ball.trail = ball.trail.filter((point) => point.life > 0).slice(-24);
}
}
function updateEffects(dt: number): void {
for (let index = g.particles.length - 1; index >= 0; index -= 1) {
const particle = g.particles[index];
if (!particle) continue;
particle.life -= dt;
if (particle.life <= 0) {
g.particles.splice(index, 1);
continue;
}
particle.vy += particle.gravity * dt;
particle.x += particle.vx * dt;
particle.y += particle.vy * dt;
particle.rotation += particle.spin * dt;
}
for (let index = g.rings.length - 1; index >= 0; index -= 1) {
const ring = g.rings[index];
if (!ring) continue;
ring.life -= dt;
if (ring.life <= 0) g.rings.splice(index, 1);
}
for (let index = g.floats.length - 1; index >= 0; index -= 1) {
const float = g.floats[index];
if (!float) continue;
float.life -= dt;
float.y += float.vy * dt;
if (float.life <= 0) g.floats.splice(index, 1);
}
for (const brick of g.bricks) {
if (brick.flash > 0) brick.flash = Math.max(0, brick.flash - dt * 4);
}
}
function updateMotes(dt: number): void {
for (const mote of g.motes) {
mote.y -= mote.speed * dt;
mote.x += Math.sin(g.time * 0.6 + mote.phase) * mote.drift * dt;
if (mote.y < -6) {
mote.y = g.height + 6;
mote.x = Math.random() * g.width;
}
if (mote.x < -6) mote.x = g.width + 6;
if (mote.x > g.width + 6) mote.x = -6;
}
}
function tick(dt: number): void {
if (g.phase === 'docked') updateAim(dt);
updatePaddle(dt);
if (g.phase === 'playing') {
for (const ball of g.balls) {
if (ball.stuck) continue;
updateBall(ball, dt);
if (g.phase !== 'playing') break;
}
}
updateTrails(dt);
updatePowerUps(dt);
updateBursts(dt);
if (g.comboTimer > 0) {
g.comboTimer -= dt;
if (g.comboTimer <= 0) g.combo = 0;
}
if (g.slowTimer > 0) {
g.slowTimer = Math.max(0, g.slowTimer - dt);
}
if (g.wideTimer > 0) {
g.wideTimer = Math.max(0, g.wideTimer - dt);
if (g.wideTimer === 0) applyPaddleWidth();
}
if (g.phase === 'playing') {
maybeClearLevel();
if (g.phase === 'playing') checkLostBalls();
}
}
function update(dt: number): void {
g.time += dt;
const ambient = g.bulletActive ? 0.5 : g.warpActive ? 1.8 : 1;
updateMotes(dt * ambient);
updateHud();
if (g.phase === 'paused') return;
updateAbilities(dt);
const scale = timeScale();
const simDt = dt * scale;
g.shake = Math.max(0, g.shake - simDt * 26);
g.flash = Math.max(0, g.flash - simDt * 1.6);
updateEffects(simDt);
if (g.phase === 'levelClear') {
g.levelClearTimer -= dt;
if (g.levelClearTimer <= 0) {
if (g.level >= LEVELS.length - 1) endRun(true);
else loadLevel(g.level + 1);
}
return;
}
if (g.phase === 'docked' || g.phase === 'playing') {
g.acc = Math.min(g.acc + simDt, 0.12);
while (g.acc >= STEP) {
tick(STEP);
g.acc -= STEP;
if (g.phase !== 'docked' && g.phase !== 'playing') {
g.acc = 0;
break;
}
}
}
}
// ── Prediction ────────────────────────────────────────────────────────────
function rayRect(
originX: number,
originY: number,
directionX: number,
directionY: number,
rectX: number,
rectY: number,
rectW: number,
rectH: number,
): number | null {
let minimum = Number.NEGATIVE_INFINITY;
let maximum = Number.POSITIVE_INFINITY;
const axes: Array<[number, number, number, number]> = [
[originX, directionX, rectX, rectX + rectW],
[originY, directionY, rectY, rectY + rectH],
];
for (const [origin, direction, low, high] of axes) {
if (Math.abs(direction) < 1e-9) {
if (origin < low || origin > high) return null;
continue;
}
let near = (low - origin) / direction;
let far = (high - origin) / direction;
if (near > far) [near, far] = [far, near];
minimum = Math.max(minimum, near);
maximum = Math.min(maximum, far);
if (minimum > maximum) return null;
}
return maximum >= Math.max(minimum, 0) ? Math.max(minimum, 0) : null;
}
function castPrediction(
startX: number,
startY: number,
directionX: number,
directionY: number,
radius: number,
maxBounces: number,
): Prediction {
const magnitude = Math.hypot(directionX, directionY) || 1;
let dx = directionX / magnitude;
let dy = directionY / magnitude;
let x = startX;
let y = startY;
const points: Point[] = [{ x, y }];
const catches: Point[] = [];
let hitBrick: Brick | null = null;
let lost = false;
let bounces = 0;
let impacts = 0;
const paddle = paddleRect();
for (let bounce = 0; bounce <= maxBounces; bounce += 1) {
let wallT = Number.POSITIVE_INFINITY;
let wall: 'left' | 'right' | 'top' | 'bottom' | null = null;
if (dx > 1e-6) {
const t = (g.width - radius - x) / dx;
if (t > 1e-4) {
wallT = t;
wall = 'right';
}
} else if (dx < -1e-6) {
const t = (radius - x) / dx;
if (t > 1e-4) {
wallT = t;
wall = 'left';
}
}
if (dy > 1e-6) {
const t = (g.height - radius - y) / dy;
if (t > 1e-4 && t < wallT) {
wallT = t;
wall = 'bottom';
}
} else if (dy < -1e-6) {
const t = (g.bounds.top + radius - y) / dy;
if (t > 1e-4 && t < wallT) {
wallT = t;
wall = 'top';
}
}
let brickT = Number.POSITIVE_INFINITY;
let brickHit: Brick | null = null;
for (const brick of g.bricks) {
if (!brick.alive) continue;
const t = rayRect(
x,
y,
dx,
dy,
brick.x - radius,
brick.y - radius,
brick.w + radius * 2,
brick.h + radius * 2,
);
if (t !== null && t > 1e-4 && t < brickT) {
brickT = t;
brickHit = brick;
}
}
// The slab is a live collider in the guide: moving it recalculates the
// return path and the catch marker before the ball ever gets there.
let paddleT = Number.POSITIVE_INFINITY;
let paddleHitX = 0;
if (dy > 1e-6) {
const top = paddle.y - radius;
const t = (top - y) / dy;
if (t > 1e-4) {
const candidateX = x + dx * t;
if (candidateX >= paddle.x - radius && candidateX <= paddle.x + paddle.w + radius) {
paddleT = t;
paddleHitX = candidateX;
}
}
}
if (brickHit && brickT <= wallT && brickT <= paddleT) {
hitBrick = brickHit;
impacts += 1;
points.push({ x: x + dx * brickT, y: y + dy * brickT });
break;
}
if (paddleT < wallT && paddleT < brickT) {
const catchX = clamp(
paddleHitX,
paddle.x + radius * 0.2,
paddle.x + paddle.w - radius * 0.2,
);
const catchY = paddle.y - radius - 0.5;
const point = { x: catchX, y: catchY };
points.push(point);
catches.push(point);
const relative = clamp((catchX - g.paddle.x) / (g.paddle.w / 2), -1, 1);
const angle = relative * 1.08;
dx = Math.sin(angle);
dy = -Math.cos(angle);
x = catchX + dx * 0.02;
y = catchY + dy * 0.02;
bounces += 1;
continue;
}
if (!wall || !Number.isFinite(wallT)) break;
const hitX = x + dx * wallT;
const hitY = y + dy * wallT;
points.push({ x: hitX, y: hitY });
if (wall === 'bottom') {
lost = true;
break;
}
x = hitX;
y = hitY;
if (wall === 'left' || wall === 'right') dx = -dx;
else dy = -dy;
x += dx * 0.02;
y += dy * 0.02;
bounces += 1;
impacts += 1;
}
return { points, hitBrick, lost, bounces, catches, impacts };
}
// ── Rendering ─────────────────────────────────────────────────────────────
function roundedPath(
paint: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
const r = Math.max(0, Math.min(radius, width / 2, height / 2));
paint.beginPath();
paint.moveTo(x + r, y);
paint.lineTo(x + width - r, y);
paint.quadraticCurveTo(x + width, y, x + width, y + r);
paint.lineTo(x + width, y + height - r);
paint.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
paint.lineTo(x + r, y + height);
paint.quadraticCurveTo(x, y + height, x, y + height - r);
paint.lineTo(x, y + r);
paint.quadraticCurveTo(x, y, x + r, y);
paint.closePath();
}
function drawMotes(): void {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const mote of g.motes) {
const flicker = 0.7 + Math.sin(g.time * 2 + mote.phase) * 0.3;
ctx.fillStyle = `rgba(255,150,80,${mote.alpha * flicker})`;
ctx.beginPath();
ctx.arc(mote.x, mote.y, mote.radius, 0, TAU);
ctx.fill();
}
ctx.restore();
}
function drawBricks(time: number): void {
for (const brick of g.bricks) {
if (!brick.alive) continue;
const colors = palette[brick.kind];
const radius = Math.min(4, brick.h * 0.3);
ctx.save();
if (brick.kind === 'volatile') {
const pulse = 0.55 + Math.sin(time * 3.4 + brick.seed) * 0.25;
ctx.shadowColor = `rgba(90,255,170,${0.4 * pulse})`;
ctx.shadowBlur = 9;
}
roundedPath(ctx, brick.x, brick.y, brick.w, brick.h, radius);
const fill = ctx.createLinearGradient(0, brick.y, 0, brick.y + brick.h);
fill.addColorStop(0, colors.top);
fill.addColorStop(1, colors.bottom);
ctx.fillStyle = fill;
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = colors.edge;
ctx.lineWidth = Math.max(0.7, brick.h * 0.05);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(brick.x + radius, brick.y + Math.max(0.7, brick.h * 0.09));
ctx.lineTo(brick.x + brick.w - radius, brick.y + Math.max(0.7, brick.h * 0.09));
ctx.strokeStyle = colors.glint;
ctx.lineWidth = Math.max(0.6, brick.h * 0.04);
ctx.stroke();
if (brick.kind === 'obsidian') {
ctx.save();
roundedPath(ctx, brick.x, brick.y, brick.w, brick.h, radius);
ctx.clip();
ctx.strokeStyle = 'rgba(190,220,245,0.13)';
ctx.lineWidth = Math.max(0.6, brick.h * 0.03);
for (let x = brick.x - brick.h; x < brick.x + brick.w + brick.h; x += brick.h * 0.8) {
ctx.beginPath();
ctx.moveTo(x, brick.y + brick.h);
ctx.lineTo(x + brick.h, brick.y);
ctx.stroke();
}
ctx.restore();
ctx.fillStyle = 'rgba(215,235,255,0.35)';
ctx.beginPath();
ctx.arc(brick.x + brick.w * 0.16, brick.y + brick.h * 0.5, Math.max(0.8, brick.h * 0.07), 0, TAU);
ctx.arc(brick.x + brick.w * 0.84, brick.y + brick.h * 0.5, Math.max(0.8, brick.h * 0.07), 0, TAU);
ctx.fill();
}
if (brick.kind === 'volatile') {
const cx = brick.x + brick.w / 2;
const cy = brick.y + brick.h / 2;
const size = Math.min(brick.w, brick.h) * 0.22;
const pulse = 0.7 + Math.sin(time * 5 + brick.seed) * 0.3;
ctx.fillStyle = `rgba(200,255,225,${0.7 + pulse * 0.3})`;
ctx.beginPath();
ctx.moveTo(cx, cy - size);
ctx.lineTo(cx + size, cy);
ctx.lineTo(cx, cy + size);
ctx.lineTo(cx - size, cy);
ctx.closePath();
ctx.fill();
}
if (brick.maxHp > 1 && brick.maxHp < 10) {
const pips = Math.max(1, brick.maxHp);
const pipSize = Math.max(0.8, brick.h * 0.07);
for (let pip = 0; pip < pips; pip += 1) {
ctx.fillStyle =
pip < brick.hp ? 'rgba(255,235,210,0.75)' : 'rgba(0,0,0,0.35)';
ctx.fillRect(
brick.x + brick.w * 0.14 + pip * pipSize * 2.6,
brick.y + brick.h - pipSize * 2.2,
pipSize,
pipSize,
);
}
}
const damage = 1 - brick.hp / Math.max(1, brick.maxHp);
if (damage > 0.05) {
const cracks = Math.min(4, Math.ceil(damage * 4));
ctx.strokeStyle = 'rgba(8,5,8,0.6)';
ctx.lineWidth = Math.max(0.6, brick.h * 0.035);
for (let crack = 0; crack < cracks; crack += 1) {
const seed = brick.seed + crack * 17;
const startX = brick.x + brick.w * (0.2 + random(seed) * 0.6);
const startY = brick.y + brick.h * (0.2 + random(seed + 3) * 0.6);
const angle = random(seed + 7) * TAU;
const length = brick.w * (0.18 + random(seed + 9) * 0.22);
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(startX + Math.cos(angle) * length, startY + Math.sin(angle) * length * 0.65);
ctx.stroke();
}
}
if (brick.flash > 0) {
roundedPath(ctx, brick.x, brick.y, brick.w, brick.h, radius);
ctx.fillStyle = `rgba(255,245,230,${brick.flash * 0.8})`;
ctx.fill();
}
ctx.restore();
}
}
function drawPrediction(time: number): void {
if (!g.guide) return;
if (g.phase !== 'docked' && g.phase !== 'playing') return;
if (g.balls.length === 0) return;
const docked = g.phase === 'docked';
const balls = docked ? g.balls.slice(0, 1) : g.balls.slice(0, 2);
balls.forEach((ball, index) => {
if (!ball) return;
let directionX: number;
let directionY: number;
if (docked || ball.stuck) {
const angle = g.aimT * AIM_MAX;
directionX = Math.sin(angle);
directionY = -Math.cos(angle);
} else {
const speed = Math.hypot(ball.vx, ball.vy);
if (speed < 0.001) return;
directionX = ball.vx / speed;
directionY = ball.vy / speed;
}
const prediction = castPrediction(
ball.x,
ball.y,
directionX,
directionY,
ball.r,
docked ? 5 : 3,
);
const alpha = docked ? 0.8 : index === 0 ? 0.32 : 0.16;
const color = docked ? '255,186,105' : '120,215,245';
const catchColor = docked ? '125,255,190' : '110,245,200';
ctx.save();
ctx.lineCap = 'round';
ctx.setLineDash(docked ? [5, 7] : [3, 9]);
for (let segment = 0; segment < prediction.points.length - 1; segment += 1) {
const from = prediction.points[segment];
const to = prediction.points[segment + 1];
if (!from || !to) continue;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.strokeStyle = `rgba(${color},${alpha * 0.18})`;
ctx.lineWidth = 3;
ctx.stroke();
ctx.strokeStyle = `rgba(${color},${alpha})`;
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.setLineDash([]);
for (let point = 1; point < prediction.points.length - 1; point += 1) {
const bounce = prediction.points[point];
if (!bounce) continue;
const isCatch = prediction.catches.some(
(entry) => Math.abs(entry.x - bounce.x) < 0.5 && Math.abs(entry.y - bounce.y) < 0.5,
);
if (isCatch) continue;
ctx.fillStyle = `rgba(${color},${alpha * 0.55})`;
ctx.beginPath();
ctx.arc(bounce.x, bounce.y, 1.6, 0, TAU);
ctx.fill();
}
for (const catchPoint of prediction.catches) {
const pulse = 1 + Math.sin(time * 5) * 0.18;
ctx.strokeStyle = `rgba(${catchColor},${alpha})`;
ctx.fillStyle = `rgba(${catchColor},${alpha * 0.12})`;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(catchPoint.x, catchPoint.y, ball.r * 1.9 * pulse, 0, TAU);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(catchPoint.x, catchPoint.y - ball.r * 2.2);
ctx.lineTo(catchPoint.x, catchPoint.y - ball.r * 6);
ctx.stroke();
ctx.fillStyle = `rgba(${catchColor},${Math.min(1, alpha + 0.12)})`;
ctx.fillRect(
catchPoint.x - Math.max(1, ball.r * 0.35),
g.paddle.y - 2,
Math.max(2, ball.r * 0.7),
g.paddle.h + 4,
);
ctx.beginPath();
ctx.arc(catchPoint.x, catchPoint.y, Math.max(1, ball.r * 0.42), 0, TAU);
ctx.fillStyle = `rgba(${catchColor},${alpha})`;
ctx.fill();
}
const end = prediction.points[prediction.points.length - 1];
if (end && prediction.hitBrick) {
const pulse = 1 + Math.sin(time * 6) * 0.15;
ctx.strokeStyle = `rgba(${color},${alpha})`;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(end.x, end.y, (docked ? 5 : 3.5) * pulse, 0, TAU);
ctx.stroke();
ctx.beginPath();
ctx.arc(end.x, end.y, 1.4, 0, TAU);
ctx.fillStyle = `rgba(${color},${alpha})`;
ctx.fill();
} else if (end && prediction.lost) {
ctx.strokeStyle = `rgba(255,90,80,${alpha + 0.1})`;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(end.x - 3, end.y - 3);
ctx.lineTo(end.x + 3, end.y + 3);
ctx.moveTo(end.x + 3, end.y - 3);
ctx.lineTo(end.x - 3, end.y + 3);
ctx.stroke();
}
ctx.restore();
});
}
function drawPowerUps(time: number): void {
for (const power of g.powerups) {
const colors = powerColors[power.kind];
const size = clamp(g.width * 0.018, 6.5, 11);
const y = power.y + Math.sin(time * 5 + power.spin) * 2;
ctx.save();
ctx.translate(power.x, y);
ctx.rotate(Math.PI / 4);
ctx.shadowColor = colors.glow;
ctx.shadowBlur = 10;
const fill = ctx.createLinearGradient(-size, -size, size, size);
fill.addColorStop(0, '#ffffff');
fill.addColorStop(0.35, colors.core);
fill.addColorStop(1, '#0a1210');
ctx.fillStyle = fill;
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.65)';
ctx.lineWidth = 0.8;
ctx.strokeRect(-size / 2, -size / 2, size, size);
ctx.restore();
ctx.fillStyle = '#061014';
ctx.font = `700 ${Math.round(size * 1.15)}px ui-monospace, "SFMono-Regular", Menlo, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(colors.label, power.x, y + 0.5);
}
}
function drawParticles(): void {
for (const particle of g.particles) {
const alpha = clamp(particle.life / particle.maxLife, 0, 1);
ctx.save();
ctx.globalAlpha = alpha;
if (particle.kind === 'spark') {
ctx.strokeStyle = `rgb(${particle.color})`;
ctx.lineWidth = particle.size;
ctx.beginPath();
ctx.moveTo(particle.x, particle.y);
ctx.lineTo(particle.x - particle.vx * 0.018, particle.y - particle.vy * 0.018);
ctx.stroke();
} else {
ctx.translate(particle.x, particle.y);
ctx.rotate(particle.rotation);
ctx.fillStyle = `rgb(${particle.color})`;
ctx.fillRect(-particle.size / 2, -particle.size / 2, particle.size, particle.size * 0.7);
}
ctx.restore();
}
}
function drawRings(): void {
for (const ring of g.rings) {
const progress = 1 - ring.life / ring.maxLife;
const radius = ring.from + (ring.to - ring.from) * progress;
const alpha = Math.max(0, 1 - progress);
ctx.save();
ctx.strokeStyle = `rgba(${ring.color},${alpha * 0.85})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(ring.x, ring.y, radius, 0, TAU);
ctx.stroke();
ctx.restore();
}
}
function drawFloats(): void {
for (const float of g.floats) {
const alpha = clamp(float.life / float.maxLife, 0, 1);
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = float.color;
ctx.font = `700 ${Math.round(float.size)}px ui-monospace, "SFMono-Regular", Menlo, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(float.text, float.x, float.y);
ctx.restore();
}
}
function drawPaddle(): void {
const rect = paddleRect();
const wide = g.wideTimer > 0;
ctx.save();
const body = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h);
if (wide) {
body.addColorStop(0, '#bdf5ff');
body.addColorStop(0.5, '#4fbfe0');
body.addColorStop(1, '#123746');
} else {
body.addColorStop(0, '#e6c3a2');
body.addColorStop(0.42, '#8c5a3c');
body.addColorStop(1, '#2a1712');
}
roundedPath(ctx, rect.x, rect.y, rect.w, rect.h, rect.h * 0.45);
ctx.fillStyle = body;
ctx.fill();
ctx.strokeStyle = wide ? 'rgba(150,240,255,0.8)' : 'rgba(255,190,130,0.75)';
ctx.lineWidth = 1;
ctx.stroke();
const seam = ctx.createLinearGradient(rect.x, 0, rect.x + rect.w, 0);
seam.addColorStop(0, 'rgba(255,120,40,0)');
seam.addColorStop(0.5, wide ? 'rgba(170,245,255,0.95)' : 'rgba(255,150,60,0.95)');
seam.addColorStop(1, 'rgba(255,120,40,0)');
ctx.fillStyle = seam;
ctx.fillRect(rect.x + rect.w * 0.1, rect.y + rect.h * 0.42, rect.w * 0.8, Math.max(1, rect.h * 0.16));
ctx.fillStyle = wide ? 'rgba(200,250,255,0.65)' : 'rgba(255,220,170,0.55)';
ctx.fillRect(rect.x + rect.w * 0.07, rect.y + rect.h * 0.25, rect.w * 0.03, rect.h * 0.5);
ctx.fillRect(rect.x + rect.w * 0.9, rect.y + rect.h * 0.25, rect.w * 0.03, rect.h * 0.5);
ctx.restore();
}
function drawBalls(): void {
for (const ball of g.balls) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let index = 0; index < ball.trail.length; index += 1) {
const point = ball.trail[index];
if (!point) continue;
const alpha = Math.max(0, point.life) * 0.38;
const radius = ball.r * (0.35 + Math.max(0, point.life) * 0.65);
ctx.fillStyle = `rgba(255,120,45,${alpha})`;
ctx.beginPath();
ctx.arc(point.x, point.y, radius, 0, TAU);
ctx.fill();
}
ctx.restore();
const glow = ctx.createRadialGradient(ball.x, ball.y, ball.r * 0.1, ball.x, ball.y, ball.r * 2.6);
glow.addColorStop(0, 'rgba(255,250,230,1)');
glow.addColorStop(0.25, 'rgba(255,200,120,0.95)');
glow.addColorStop(0.62, 'rgba(255,110,40,0.4)');
glow.addColorStop(1, 'rgba(255,80,20,0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r * 2.6, 0, TAU);
ctx.fill();
ctx.fillStyle = '#fff4dd';
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r * 0.68, 0, TAU);
ctx.fill();
if (ball.stuck) {
const pulse = 0.5 + Math.sin(g.time * 4) * 0.2;
ctx.strokeStyle = `rgba(255,190,110,${pulse})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r * 2.2, 0, TAU);
ctx.stroke();
}
if (g.blastTimer > 0) {
const pulse = 0.6 + Math.sin(g.time * 8) * 0.18;
ctx.strokeStyle = `rgba(255,170,85,${pulse})`;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r * 2.7, 0, TAU);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,215,150,0.9)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(
ball.x,
ball.y,
ball.r * 3.2,
-Math.PI / 2,
-Math.PI / 2 + TAU * clamp(g.blastTimer / 12, 0, 1),
);
ctx.stroke();
} else if (g.warpReady && ball === g.balls[0] && !ball.stuck) {
const pulse = 0.45 + Math.sin(g.time * 6) * 0.15;
ctx.strokeStyle = `rgba(87,242,168,${pulse})`;
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r * 2.4, 0, TAU);
ctx.stroke();
ctx.setLineDash([]);
}
}
}
function render(time: number): void {
if (!g.hasSized) return;
ctx.setTransform(g.dpr, 0, 0, g.dpr, 0, 0);
ctx.clearRect(0, 0, g.width, g.height);
const shakeEnabled = g.settings.screenShake && !prefersReducedMotion();
const shake = shakeEnabled ? Math.min(g.shake, 14) : 0;
ctx.save();
if (shake > 0.1) {
ctx.translate((Math.random() * 2 - 1) * shake, (Math.random() * 2 - 1) * shake);
}
if (g.bg) ctx.drawImage(g.bg, 0, 0, g.width, g.height);
else {
ctx.fillStyle = '#08060a';
ctx.fillRect(0, 0, g.width, g.height);
}
drawMotes();
drawBricks(time);
drawPrediction(time);
drawPowerUps(time);
drawParticles();
drawPaddle();
drawBalls();
drawRings();
drawFloats();
ctx.restore();
if (g.bulletActive) {
const centerX = g.width / 2;
const centerY = g.height / 2;
const tint = ctx.createRadialGradient(
centerX,
centerY,
Math.min(g.width, g.height) * 0.24,
centerX,
centerY,
Math.max(g.width, g.height) * 0.74,
);
tint.addColorStop(0, 'rgba(70,170,235,0)');
tint.addColorStop(0.7, 'rgba(70,170,235,0.05)');
tint.addColorStop(1, 'rgba(120,225,255,0.18)');
ctx.fillStyle = tint;
ctx.fillRect(0, 0, g.width, g.height);
}
if (g.warpActive) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.lineWidth = 1.6;
for (let index = 0; index < 10; index += 1) {
const left = index % 2 === 0;
const travel = (g.time * 900 + index * 91) % (g.height + 90);
const y = travel - 45;
const length = 28 + (index % 4) * 18;
const startX = left ? 0 : g.width;
const endX = left ? length * 2.2 : g.width - length * 2.2;
const streak = ctx.createLinearGradient(startX, y, endX, y);
streak.addColorStop(0, 'rgba(255,175,95,0.28)');
streak.addColorStop(1, 'rgba(255,175,95,0)');
ctx.strokeStyle = streak;
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
ctx.stroke();
}
ctx.restore();
}
if (g.flash > 0) {
ctx.fillStyle = `rgba(255,120,55,${g.flash * 0.16})`;
ctx.fillRect(0, 0, g.width, g.height);
}
}
// ── Input ─────────────────────────────────────────────────────────────────
function pointerToPaddle(event: PointerEvent): void {
const rect = elements.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
g.paddle.targetX = clamp(x, g.paddle.w / 2, g.width - g.paddle.w / 2);
}
function bindInput(): void {
elements.canvas.addEventListener('pointermove', (event) => {
pointerToPaddle(event);
});
elements.canvas.addEventListener('pointerdown', (event) => {
audio.unlock();
pointerToPaddle(event);
try {
elements.canvas.setPointerCapture(event.pointerId);
} catch {
// Pointer capture is a convenience, not a requirement.
}
if (event.pointerType === 'mouse' && g.phase === 'docked') {
launch();
} else if (event.pointerType !== 'mouse') {
g.pendingLaunch = true;
}
event.preventDefault();
});
elements.canvas.addEventListener('pointerup', (event) => {
if (g.pendingLaunch && g.phase === 'docked') launch();
g.pendingLaunch = false;
event.preventDefault();
});
elements.canvas.addEventListener('pointercancel', () => {
g.pendingLaunch = false;
});
elements.canvas.addEventListener('pointerleave', () => {
g.pendingLaunch = false;
});
elements.canvas.addEventListener('contextmenu', (event) => event.preventDefault());
window.addEventListener('keydown', (event) => {
const key = event.key.toLowerCase();
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', ' '].includes(key)) {
event.preventDefault();
}
if (key === 'arrowleft' || key === 'a') {
g.keys.left = true;
audio.unlock();
} else if (key === 'arrowright' || key === 'd') {
g.keys.right = true;
audio.unlock();
} else if (key === ' ' || key === 'enter') {
audio.unlock();
if (g.phase === 'docked') launch();
else if (g.phase === 'paused') resume();
else if (g.phase === 'gameOver' || g.phase === 'victory') startRun();
} else if (key === 'p' || key === 'escape') {
togglePause();
} else if (key === 'r') {
startRun();
} else if (key === 'g') {
toggleGuide();
} else if (key === 'm') {
toggleSound();
} else if (key === 'b') {
audio.unlock();
toggleBulletTime();
} else if (key === 'f') {
audio.unlock();
activateWarp();
}
});
window.addEventListener('keyup', (event) => {
const key = event.key.toLowerCase();
if (key === 'arrowleft' || key === 'a') g.keys.left = false;
if (key === 'arrowright' || key === 'd') g.keys.right = false;
});
window.addEventListener('blur', () => {
g.keys.left = false;
g.keys.right = false;
if (g.phase === 'docked' || g.phase === 'playing') togglePause();
});
document.addEventListener('visibilitychange', () => {
if (document.hidden && (g.phase === 'docked' || g.phase === 'playing')) togglePause();
});
elements.guideButton.addEventListener('click', () => {
toggleGuide();
audio.unlock();
});
elements.soundButton.addEventListener('click', () => {
toggleSound();
});
elements.warpButton.addEventListener('click', () => {
audio.unlock();
activateWarp();
});
elements.bulletButton.addEventListener('click', () => {
audio.unlock();
toggleBulletTime();
});
elements.pauseButton.addEventListener('click', () => {
togglePause();
audio.unlock();
});
}
function toggleGuide(): void {
g.guide = !g.guide;
updateGuideButton();
showToast(g.guide ? 'Bounce prediction on' : 'Bounce prediction off');
}
function toggleSound(): void {
g.soundOn = !g.soundOn;
audio.setMuted(!g.soundOn);
updateSoundButton();
showToast(g.soundOn ? 'Sound on' : 'Sound muted');
}
// ── Boot ──────────────────────────────────────────────────────────────────
const stopSettings = onGameSettings((settings) => {
g.settings = settings;
g.guide = settings.showGuide;
updateGuideButton();
});
window.addEventListener('pagehide', stopSettings, { once: true });
g.bricks = buildBricks(0);
resizeCanvas();
resetDocked();
g.phase = 'docked';
g.lives = livesForDifficulty();
updateHud();
updateGuideButton();
updateSoundButton();
updatePauseButton();
updateAbilityHud();
bindInput();
void loadBest();
showBanner(
`SEAM 01 / ${String(LEVELS.length).padStart(2, '0')}`,
LEVELS[0]?.name.toUpperCase() ?? 'ASHFALL',
1900,
);
announce('Obsidian Breaker. Drag to move, tap or press Space to launch. B slows time, F warps.');
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(() => resizeCanvas());
observer.observe(elements.app);
}
window.addEventListener('resize', resizeCanvas);
let lastFrame = 0;
function frame(now: number): void {
requestAnimationFrame(frame);
if (lastFrame === 0) lastFrame = now;
const dt = clamp((now - lastFrame) / 1000, 0.001, 0.05);
lastFrame = now;
update(dt);
render(now / 1000);
}
requestAnimationFrame(frame);
