Skip to content

Instantly share code, notes, and snippets.

@DreAdeDcoRpSE
Forked from straker/README.md
Last active September 12, 2026 00:24
Show Gist options
  • Select an option

  • Save DreAdeDcoRpSE/3c129241551b9f554615cb633dd328cd to your computer and use it in GitHub Desktop.

Select an option

Save DreAdeDcoRpSE/3c129241551b9f554615cb633dd328cd to your computer and use it in GitHub Desktop.
Basic Breakout HTML and JavaScript Game

Breakout Arcade

This project started as a fork of the Basic Breakout HTML and JavaScript Game by straker and has since been expanded into a more complete Breakout-style arcade game.

The game is still built entirely with HTML, CSS, and JavaScript and does not require any external libraries or frameworks. Just open the HTML file in a modern web browser and play.

Features

  • Responsive game area for desktop, laptop, and tablet screens
  • Keyboard, mouse, touch, and pen controls
  • 5 starting lives
  • Classic Breakout-style brick scoring
    • 7 points
    • 5 points
    • 3 points
    • 1 point
  • Multiple levels and custom brick patterns
  • Rotating color palettes on later levels
  • Full-width and half-width bricks
  • Progressive ball speed
  • Higher minimum ball speed on later levels
  • Paddle-angle control based on where the ball hits the paddle
  • Paddle shrinking on higher levels
  • Perfect Clear bonus
    • Clear a level without losing a life to earn +1 life
  • Multiball bonus
    • Every 75 points adds a second ball when one is available
    • Maximum of 2 active balls
  • Extra-ball progress resets when a life is lost
  • Ball serves from the paddle before play begins
  • Custom game sounds using the Web Audio API
  • Custom GAME OVER display
  • Fixed-timestep gameplay for more consistent speed between browsers
  • High-speed collision handling to help prevent the ball from passing through multiple bricks
  • Built-in testing mode for quickly testing later levels
  • Separate Breakout Pattern Editor for creating custom levels

Controls

Keyboard

  • Left Arrow - Move paddle left
  • Right Arrow - Move paddle right
  • Space - Serve the ball

Before the ball is served, it remains on the paddle so you can position the paddle and choose where you want the serve to begin.

Mouse

Click the game to lock the mouse pointer to the game.

Move the mouse left or right to control the paddle.

Press ESC to release the mouse pointer.

Tablet / Touch

Touch and drag left or right to position the paddle.

Tap to serve the ball.

Screens smaller than 600 pixels wide are currently not supported.

Scoring

The four brick tiers are worth:

Tier Points
A / Red 7
B / Orange 5
C / Green 3
D / Yellow 1

Level 1 uses the classic red, orange, green, and yellow colors.

Later levels can use different color palettes, but the four point tiers remain 7, 5, 3, and 1.

Levels

Level 1 uses the traditional solid Breakout brick layout.

Later levels use patterns stored inside the game's laterPatterns array.

Patterns can use either the standard 14-column format or the newer 28-position half-brick format.

The included Breakout Pattern Editor is the easiest and safest way to create new patterns.

Breakout Pattern Editor

The Breakout Pattern Editor is a separate HTML tool included with the project.

It allows custom brick layouts to be designed visually instead of manually typing each pattern.

No installation or web server is required. Open the Pattern Editor HTML file directly in your browser.

Using the Pattern Editor

The editor contains 14 rows with 28 half-brick positions per row.

Select a brick tier:

  • A - 7 points
  • B - 5 points
  • C - 3 points
  • D - 1 point
  • Eraser - Removes bricks

Then select the brick size:

  • Full - Normal full-width brick
  • Half - Half-width brick

Click or drag across the editor to draw your pattern.

Right-clicking a position erases it.

The generated pattern is automatically displayed in the output area.

When finished, click Copy Pattern.

Pattern Format

The Pattern Editor generates a 14-row pattern using 28 half-brick positions per row.

Example:

[
  '............................',
  '............................',
  '............................',
  '............................',
  '............................',
  'AABBCCDDa..........aDDCCBBAA',
  '.AABBCCDDb........bDDCCBBAA.',
  '..AABBCCDDc......cDDCCBBAA..',
  '...AABBCCDDd....dDDCCBBAA...',
  '....AABBCCDDa..aDDCCBBAA....',
  '.....AABBCCDDbbDDCCBBAA.....',
  '......AABBCCDDDDCCBBAA......',
  '.......aABBCCDDCCBBAa.......',
  '........aABBCCDDBBAa........'
]

Each character represents part of the brick grid.

Full Bricks

Two matching uppercase letters create one full-width brick:

AA
BB
CC
DD

Half Bricks

A lowercase letter creates one half-width brick:

a
b
c
d

Empty Space

A period represents one empty half-brick position:

.

Each row in a half-brick pattern must contain exactly 28 positions.

The Pattern Editor handles this formatting automatically, so using the editor is recommended instead of manually constructing patterns.

Adding a New Pattern to the Game

Open the main Breakout HTML file in a text or code editor.

Search for:

const laterPatterns = [

Inside it you will see the existing level patterns:

const laterPatterns = [
  [
    // pattern
  ],
  [
    // another pattern
  ],
  [
    // another pattern
  ]
];

Copy your completed pattern from the Breakout Pattern Editor and add it as another entry inside laterPatterns.

For example:

const laterPatterns = [
  [
    // existing pattern
  ],

  [
    // existing pattern
  ],

  [
    '............................',
    '............................',
    '............................',
    '............................',
    '............................',
    'AABBCCDDa..........aDDCCBBAA',
    '.AABBCCDDb........bDDCCBBAA.',
    '..AABBCCDDc......cDDCCBBAA..',
    '...AABBCCDDd....dDDCCBBAA...',
    '....AABBCCDDa..aDDCCBBAA....',
    '.....AABBCCDDbbDDCCBBAA.....',
    '......AABBCCDDDDCCBBAA......',
    '.......aABBCCDDCCBBAa.......',
    '........aABBCCDDBBAa........'
  ]
];

Important

Be sure there is a comma between each pattern in the laterPatterns array.

For example:

  ],
  [

Do not add a comma after the final ];.

If the commas or brackets are accidentally removed, the JavaScript will no longer be valid and the game may not start.

The safest method is to copy the complete output generated by the Pattern Editor and paste it between two existing patterns or immediately before the final closing ];.

Loading an Existing Pattern

The Pattern Editor can also load patterns that are already in the game.

Open Load Existing Pattern, copy one complete pattern from laterPatterns, and paste it into the box.

The editor will validate the pattern before loading it.

If there is a formatting problem, such as an invalid row length or an incorrectly paired full brick, the editor will display an error rather than loading a broken pattern.

This makes it useful for both creating new levels and modifying existing ones.

Testing Levels

The game contains a testing system for quickly checking custom patterns and later-level gameplay.

Near the top of the JavaScript is:

const TESTING_SYSTEM = 1;

When set to:

const TESTING_SYSTEM = 1;

testing mode is enabled and S can be pressed during gameplay to skip directly to the next level.

Testing skips also advance the level's minimum ball speed and paddle-shrinking progression so later-level behavior can be tested without manually clearing every previous level.

Testing skips do not award score or Perfect Clear bonus lives.

Before publishing or distributing the normal game, testing mode can be disabled with:

const TESTING_SYSTEM = 0;

Original Project

This project is based on the Basic Breakout HTML and JavaScript Game created by straker.

The original project was intentionally designed as a basic implementation with several features left as exercises for the reader.

This fork expands on that foundation with additional gameplay systems, level support, controls, collision handling, browser consistency improvements, and the Breakout Pattern Editor.

License

The original project was released under:

CC0 1.0 Universal

You're free to use the original game and code in any project, personal or commercial. Attribution is not required, but is appreciated.

See the original project's license for complete details.

<!DOCTYPE html>
<html>
<head>
<title>Breakout Arcade</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body {
height: 100%;
margin: 0;
}
body {
background: black;
color: white;
display: flex;
align-items: center;
justify-content: center;
overflow-x: auto;
font-family: monospace;
}
#game-shell {
position: relative;
width: clamp(400px, 95vw, 1000px);
}
#game {
display: block;
touch-action: none;
user-select: none;
width: 100%;
height: 560px;
cursor: none;
}
.overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
padding: 24px;
box-sizing: border-box;
}
.overlay.hidden {
display: none;
}
.panel {
width: min(680px, 92%);
border: 3px solid white;
padding: 22px 26px;
box-sizing: border-box;
background: #050505;
text-align: left;
box-shadow: 0 0 0 4px #222;
}
.panel h1 {
margin: 0 0 16px;
text-align: center;
font-size: 30px;
letter-spacing: 3px;
}
.panel h2 {
margin: 18px 0 8px;
font-size: 16px;
color: cyan;
}
.panel p {
margin: 6px 0;
line-height: 1.45;
font-size: 14px;
}
.score-guide {
display: grid;
grid-template-columns: repeat(4, minmax(80px, 1fr));
gap: 8px;
margin: 10px 0 4px;
}
.score-item {
border: 1px solid #666;
padding: 8px;
text-align: center;
font-weight: bold;
}
.swatch {
display: block;
height: 12px;
margin-bottom: 6px;
}
#start-game {
display: block;
margin: 20px auto 0;
padding: 10px 24px;
border: 2px solid white;
background: black;
color: white;
font: bold 16px monospace;
cursor: pointer;
}
#start-game:hover {
background: white;
color: black;
}
#phone-block .panel {
text-align: center;
}
@media (max-width: 599px) {
#game-shell {
width: 100vw;
min-width: 0;
}
}
</style>
</head>
<body>
<div id="game-shell">
<canvas width="400" height="560" id="game"></canvas>
<div id="instructions" class="overlay">
<div class="panel">
<h1>BREAKOUT</h1>
<h2>BRICK VALUES</h2>
<div class="score-guide">
<div class="score-item"><span class="swatch" style="background:red"></span>7 POINTS</div>
<div class="score-item"><span class="swatch" style="background:orange"></span>5 POINTS</div>
<div class="score-item"><span class="swatch" style="background:green"></span>3 POINTS</div>
<div class="score-item"><span class="swatch" style="background:yellow"></span>1 POINT</div>
</div>
<p>Later levels use different colors and patterns, but keep the same 7 / 5 / 3 / 1 point tiers.</p>
<h2>CONTROLS</h2>
<p><strong>Keyboard:</strong> Left / Right Arrow keys move the paddle and waiting ball. Space serves the ball upward.</p>
<p id="testing-control-note" style="display:none;"><strong>Testing:</strong> Press S to skip directly to the next level.</p>
<p><strong>Mouse:</strong> Click the game to lock the mouse in. Move left/right to control the paddle. Press ESC to release the mouse.</p>
<p><strong>Tablet:</strong> Touch and drag left/right to position the paddle and waiting ball. Tap to serve.</p>
<h2>BONUSES</h2>
<p>Every <strong>75 score points</strong>, an extra ball is added to the field (maximum 2 active balls).</p>
<p>Clear an entire level without losing a life and you earn <strong>+1 life</strong>.</p>
<p>Each higher level raises the minimum ball speed. If you lose a life, the next ball resets to that level's minimum speed instead of staying at the faster speed you reached.</p>
<button id="start-game" type="button">START GAME</button>
</div>
</div>
<div id="phone-block" class="overlay hidden">
<div class="panel">
<h1>SCREEN TOO SMALL</h1>
<p>This version of Breakout is designed for desktop/laptop computers and tablets.</p>
<p>Please use a screen at least 600 pixels wide.</p>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('game');
const instructions = document.getElementById('instructions');
const phoneBlock = document.getElementById('phone-block');
const startGameButton = document.getElementById('start-game');
const context = canvas.getContext('2d');
const testingControlNote = document.getElementById('testing-control-note');
const MIN_GAME_WIDTH = 400;
const MAX_GAME_WIDTH = 1000;
const HUD_HEIGHT = 64;
const GAME_HEIGHT = 560 + HUD_HEIGHT;
const BRICK_COLUMNS = 14;
const MAX_BALLS = 2;
const STARTING_LIVES = 5;
const START_BALL_SPEED = 1.35;
const BALL_SPEED_INCREASE = 0.015;
const BALL_SPEED_INCREASE_PER_LEVEL = 0.002;
const MAX_BALL_SPEED_INCREASE = 0.035;
const MAX_BALL_SPEED = 5.00;
const MULTIBALL_SCORE_INTERVAL = 75;
const PADDLE_BASE_WIDTH = 70;
const PADDLE_SHRINK_START_LEVEL = 11;
const PADDLE_SCALE_STEP = 0.25;
const PADDLE_MIN_SCALE = 0.50;
const LEVEL_MIN_SPEED_INCREASE = 0.20;
const MIN_SUPPORTED_VIEWPORT = 600;
const MAX_PADDLE_BOUNCE_ANGLE = 65 * Math.PI / 180;
// Logical ball speeds are tuned like the original frame-based game.
// This multiplier converts them to a faster arcade pace while the fixed
// timestep keeps that pace identical across Chrome, Edge, Firefox, Brave,
// and different monitor refresh rates.
const BALL_MOVEMENT_SCALE = 2.25;
// Run gameplay at a fixed 60 updates per second. Rendering can happen at
// whatever rate the browser/display supports without changing game speed.
const FIXED_TIMESTEP = 1000 / 60;
const MAX_CATCHUP_STEPS = 30;
// TESTING ONLY:
// 1 = Press S during gameplay to skip directly to the next level.
// 0 = Disable level skipping for normal/public gameplay.
const TESTING_SYSTEM = 0;
let lastFrameTime = 0;
let accumulator = 0;
const brickGap = 2;
const brickHeight = 12;
const wallSize = 12;
const BRICK_TOP = wallSize + HUD_HEIGHT;
const BALL_START_Y = 260 + HUD_HEIGHT;
let brickWidth = 25;
// Level 1 always keeps the classic four solid color bands.
const level1 = [
'..............',
'..............',
'..............',
'..............',
'..............',
'..............',
'RRRRRRRRRRRRRR',
'RRRRRRRRRRRRRR',
'OOOOOOOOOOOOOO',
'OOOOOOOOOOOOOO',
'GGGGGGGGGGGGGG',
'GGGGGGGGGGGGGG',
'YYYYYYYYYYYYYY',
'YYYYYYYYYYYYYY'
];
// After Level 1, these patterns repeat in order while their palettes rotate.
const laterPatterns = [
[
'..............',
'..............',
'..............',
'..............',
'..............',
'..............',
'ABABABABABABAB',
'BABABABABABABA',
'CDCDCDCDCDCDCD',
'DCDCDCDCDCDCDC',
'ABABABABABABAB',
'BABABABABABABA',
'CDCDCDCDCDCDCD',
'DCDCDCDCDCDCDC'
],
[
'.........AAAAAAAAAA.........',
'.......AAAAAAAAAAAAAA.......',
'.....AAAAAAAAAAAAAAAAAA.....',
'.....AAAAAAAAAAAAAAAAAA.....',
'....AAAA....CCCC....AAAA....',
'....AADD....CCCC....DDAA....',
'....AABBd...CCCC...dBBAA....',
'....AABBDDccc..cccDDBBAA....',
'....AABBDDcc....ccDDBBAA....',
'.....DDBBdcc.cc.ccdBBDD.....',
'.....DDBBddcccccccdBBDD.....',
'......DDBB..c..c..BBDD......',
'.......DDBB......BBDD.......',
'........DDDDDDDDDDDD........'
],
[
'..............',
'..............',
'..............',
'..............',
'..............',
'......AA......',
'.....ABBA.....',
'....ABCCBA....',
'...ABCDDCBA...',
'..ABCDDDDCBA..',
'.ABCDDDDDDCBA.',
'ABCDDDDDDDDCBA',
'.ABCCCCCCCCBA.',
'..AABBBBBBAA..'
],
[
'..............',
'..............',
'..............',
'..............',
'..............',
'AA..BB..CC..DD',
'AA..BB..CC..DD',
'..CC..DD..AA..',
'..CC..DD..AA..',
'BB..AA..DD..CC',
'BB..AA..DD..CC',
'..DD..CC..BB..',
'..DD..CC..BB..',
'AABBCCDDAABBCC'
],
[
'..............',
'..............',
'..............',
'..............',
'..............',
'A............A',
'AA..........AA',
'BBB........BBB',
'BBBB......BBBB',
'CCCCC....CCCCC',
'CCCCCC..CCCCCC',
'DDDDDDDDDDDDDD',
'.DDDDDDDDDDDD.',
'..AAAAAAAAAA..'
],
[
'............................',
'............................',
'............................',
'............................',
'............................',
'AABBCCDDa..........aDDCCBBAA',
'.AABBCCDDb........bDDCCBBAA.',
'..AABBCCDDc......cDDCCBBAA..',
'...AABBCCDDd....dDDCCBBAA...',
'....AABBCCDDa..aDDCCBBAA....',
'.....AABBCCDDbbDDCCBBAA.....',
'......AABBCCDDDDCCBBAA......',
'.......aABBCCDDCCBBAa.......',
'........aABBCCDDBBAa........'
]
];
const palettes = [
{ A: '#ff3b30', B: '#ff9500', C: '#34c759', D: '#ffd60a' },
{ A: '#ff2d55', B: '#af52de', C: '#5ac8fa', D: '#30d158' },
{ A: '#00ffff', B: '#007aff', C: '#bf5af2', D: '#ff375f' },
{ A: '#ffffff', B: '#64d2ff', C: '#ffd60a', D: '#ff9f0a' },
{ A: '#30d158', B: '#00c7be', C: '#0a84ff', D: '#5e5ce6' }
];
const classicColors = {
R: 'red',
O: 'orange',
G: 'green',
Y: 'yellow'
};
const classicPoints = { R: 7, O: 5, G: 3, Y: 1 };
const laterPoints = { A: 7, B: 5, C: 3, D: 1 };
const bricks = [];
const paddle = {
x: 0,
y: 480,
width: PADDLE_BASE_WIDTH,
height: brickHeight,
dx: 0
};
const balls = [];
let score = 0;
let multiballProgress = 0;
let lives = STARTING_LIVES;
let level = 1;
let gameOver = false;
let audioContext = null;
let currentSpeed = START_BALL_SPEED;
let lostLifeThisLevel = false;
let levelClearedPending = false;
let levelBannerUntil = 0;
let bonusBannerUntil = 0;
let gameStarted = false;
let screenSupported = true;
let paddleScale = 1.0;
function ensureAudio() {
if (!audioContext) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) audioContext = new AudioCtx();
}
if (audioContext && audioContext.state === 'suspended') audioContext.resume();
}
function playTone(frequency, duration = 0.05, volume = 0.05, type = 'square', slideTo = null) {
if (!audioContext) return;
const now = audioContext.currentTime;
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, now);
if (slideTo !== null) {
oscillator.frequency.exponentialRampToValueAtTime(Math.max(1, slideTo), now + duration);
}
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
oscillator.connect(gain);
gain.connect(audioContext.destination);
oscillator.start(now);
oscillator.stop(now + duration);
}
function playBrickSound(pointValue) {
const tones = { 7: 420, 5: 360, 3: 300, 1: 240 };
playTone(tones[pointValue] || 300, 0.045, 0.045, 'square');
}
function playWallSound() { playTone(150, 0.025, 0.025, 'square'); }
function playPaddleSound() { playTone(190, 0.04, 0.035, 'square'); }
function playLifeLostSound() { playTone(220, 0.22, 0.055, 'sawtooth', 70); }
function playMultiballSound() {
playTone(440, 0.06, 0.04, 'square');
setTimeout(() => playTone(660, 0.08, 0.04, 'square'), 55);
}
function playExtraLifeSound() {
playTone(520, 0.07, 0.04, 'square');
setTimeout(() => playTone(700, 0.07, 0.04, 'square'), 70);
setTimeout(() => playTone(880, 0.10, 0.04, 'square'), 140);
}
function playLevelClearSound() {
playTone(330, 0.06, 0.04, 'square');
setTimeout(() => playTone(440, 0.06, 0.04, 'square'), 55);
setTimeout(() => playTone(660, 0.10, 0.04, 'square'), 110);
}
function playGameOverSound() {
playTone(180, 0.18, 0.06, 'square', 120);
setTimeout(() => playTone(120, 0.28, 0.06, 'square', 55), 150);
}
function getMinimumSpeedForLevel(levelNumber) {
return Math.min(MAX_BALL_SPEED, START_BALL_SPEED + ((levelNumber - 1) * LEVEL_MIN_SPEED_INCREASE));
}
function updateScreenSupport() {
screenSupported = window.innerWidth >= MIN_SUPPORTED_VIEWPORT;
phoneBlock.classList.toggle('hidden', screenSupported);
if (!screenSupported) {
instructions.classList.add('hidden');
if (document.pointerLockElement === canvas) document.exitPointerLock?.();
} else if (!gameStarted) {
instructions.classList.remove('hidden');
}
}
function getLevelDefinition(levelNumber) {
if (levelNumber === 1) {
return {
pattern: level1,
colors: classicColors,
points: classicPoints,
halfGrid: false
};
}
const patternIndex = (levelNumber - 2) % laterPatterns.length;
const paletteIndex = Math.floor((levelNumber - 2) / laterPatterns.length) % palettes.length;
return {
pattern: laterPatterns[patternIndex],
colors: palettes[paletteIndex],
points: laterPoints,
halfGrid: laterPatterns[patternIndex].some(row => row.length > BRICK_COLUMNS)
};
}
function buildLevel(levelNumber) {
bricks.length = 0;
const definition = getLevelDefinition(levelNumber);
if (!definition.halfGrid) {
// Standard 14-column map. Each character is one full-width brick position.
for (let row = 0; row < definition.pattern.length; row++) {
const rowData = definition.pattern[row];
for (let col = 0; col < rowData.length; col++) {
const code = rowData[col];
if (code === '.') continue;
bricks.push({
row,
col,
halfCol: null,
x: wallSize + (brickWidth + brickGap) * col,
y: BRICK_TOP + (brickHeight + brickGap) * row,
color: definition.colors[code],
pointValue: definition.points[code] || 1,
width: brickWidth,
height: brickHeight
});
}
}
return;
}
// Half-brick map: rows use 28 half-width cells.
// Two matching uppercase characters (RR, AA, etc.) form one normal brick.
// A lowercase character (r, a, etc.) creates one half-width brick.
// A period is one empty half-cell, so patterns can offset bricks by half a brick.
const halfCellWidth = (canvas.width - (wallSize * 2)) / (BRICK_COLUMNS * 2);
for (let row = 0; row < definition.pattern.length; row++) {
const rowData = definition.pattern[row];
let halfCol = 0;
while (halfCol < rowData.length) {
const rawCode = rowData[halfCol];
if (rawCode === '.') {
halfCol++;
continue;
}
const code = rawCode.toUpperCase();
const isForcedHalf = rawCode !== code;
const hasMatchingPair = !isForcedHalf && rowData[halfCol + 1] === rawCode;
const cellsWide = hasMatchingPair ? 2 : 1;
const gapInset = brickGap / 2;
const x = wallSize + (halfCellWidth * halfCol) + gapInset;
const width = Math.max(2, (halfCellWidth * cellsWide) - brickGap);
bricks.push({
row,
col: null,
halfCol,
cellsWide,
x,
y: BRICK_TOP + (brickHeight + brickGap) * row,
color: definition.colors[code],
pointValue: definition.points[code] || 1,
width,
height: brickHeight
});
halfCol += cellsWide;
}
}
}
function setPaddleScale(newScale) {
const oldCenter = paddle.x + paddle.width / 2;
paddleScale = Math.max(PADDLE_MIN_SCALE, Math.min(1.0, newScale));
paddle.width = PADDLE_BASE_WIDTH * paddleScale;
paddle.x = oldCenter - paddle.width / 2;
paddle.x = Math.max(wallSize, Math.min(paddle.x, canvas.width - wallSize - paddle.width));
}
function restorePaddleAfterMiss() {
setPaddleScale(paddleScale + PADDLE_SCALE_STEP);
}
function shrinkPaddleAfterCleanClear(completedLevel) {
if (completedLevel < PADDLE_SHRINK_START_LEVEL - 1) return;
setPaddleScale(paddleScale - PADDLE_SCALE_STEP);
}
function createBall(x, y, speed = currentSpeed, dx = 0, dy = 0) {
return { x, y, width: 5, height: 5, speed, dx, dy };
}
function resetToSingleBall(speed = currentSpeed) {
balls.length = 0;
const ballWidth = 5;
const ballHeight = 5;
const ballX = paddle.x + (paddle.width / 2) - (ballWidth / 2);
const ballY = paddle.y - ballHeight;
balls.push(createBall(ballX, ballY, speed, 0, 0));
}
function launchBall(ball) {
const direction = Math.random() < 0.5 ? -1 : 1;
const angle = (25 + Math.random() * 20) * Math.PI / 180;
ball.dx = direction * ball.speed * Math.sin(angle);
ball.dy = -Math.abs(ball.speed * Math.cos(angle));
}
function positionWaitingBallsOnPaddle() {
balls.forEach(ball => {
if (ball.dx === 0 && ball.dy === 0) {
ball.x = paddle.x + (paddle.width / 2) - (ball.width / 2);
ball.y = paddle.y - ball.height;
}
});
}
function spawnSecondBall(sourceBall) {
if (balls.length >= MAX_BALLS) return;
const newBall = createBall(sourceBall.x, sourceBall.y, sourceBall.speed);
const sourceDirection = sourceBall.dx === 0 ? 1 : Math.sign(sourceBall.dx);
const angle = (30 + Math.random() * 25) * Math.PI / 180;
newBall.dx = -sourceDirection * newBall.speed * Math.sin(angle);
newBall.dy = -Math.abs(newBall.speed * Math.cos(angle));
balls.push(newBall);
playMultiballSound();
}
function resizeGame() {
const oldWidth = canvas.width || MIN_GAME_WIDTH;
const newWidth = Math.max(MIN_GAME_WIDTH, Math.min(MAX_GAME_WIDTH, Math.floor(window.innerWidth * 0.95)));
const scaleX = newWidth / oldWidth;
canvas.width = newWidth;
canvas.height = GAME_HEIGHT;
paddle.x *= scaleX;
balls.forEach(ball => ball.x *= scaleX);
const usableWidth = canvas.width - (wallSize * 2);
brickWidth = (usableWidth - (brickGap * (BRICK_COLUMNS - 1))) / BRICK_COLUMNS;
const halfCellWidth = usableWidth / (BRICK_COLUMNS * 2);
bricks.forEach(brick => {
if (brick.halfCol !== null && brick.halfCol !== undefined) {
const gapInset = brickGap / 2;
brick.x = wallSize + (halfCellWidth * brick.halfCol) + gapInset;
brick.width = Math.max(2, (halfCellWidth * (brick.cellsWide || 1)) - brickGap);
} else {
brick.x = wallSize + (brickWidth + brickGap) * brick.col;
brick.width = brickWidth;
}
});
paddle.y = canvas.height - 20;
paddle.x = Math.max(wallSize, Math.min(paddle.x, canvas.width - wallSize - paddle.width));
positionWaitingBallsOnPaddle();
balls.forEach(ball => {
ball.x = Math.max(wallSize, Math.min(ball.x, canvas.width - wallSize - ball.width));
});
}
function collides(obj1, obj2) {
return obj1.x < obj2.x + obj2.width &&
obj1.x + obj1.width > obj2.x &&
obj1.y < obj2.y + obj2.height &&
obj1.y + obj1.height > obj2.y;
}
function getBallSpeedIncreaseForLevel(levelNumber) {
return Math.min(
MAX_BALL_SPEED_INCREASE,
BALL_SPEED_INCREASE + ((levelNumber - 1) * BALL_SPEED_INCREASE_PER_LEVEL)
);
}
function increaseBallSpeed(ball) {
const oldSpeed = ball.speed;
const speedIncrease = getBallSpeedIncreaseForLevel(level);
const newSpeed = Math.min(MAX_BALL_SPEED, oldSpeed + speedIncrease);
if (newSpeed === oldSpeed) return;
ball.speed = newSpeed;
currentSpeed = Math.max(currentSpeed, newSpeed);
const magnitude = Math.hypot(ball.dx, ball.dy);
if (magnitude > 0) {
const scale = newSpeed / magnitude;
ball.dx *= scale;
ball.dy *= scale;
}
}
function bounceOffPaddle(ball) {
const ballCenter = ball.x + ball.width / 2;
const paddleCenter = paddle.x + paddle.width / 2;
let hitPosition = (ballCenter - paddleCenter) / (paddle.width / 2);
hitPosition = Math.max(-1, Math.min(1, hitPosition));
const bounceAngle = hitPosition * MAX_PADDLE_BOUNCE_ANGLE;
ball.dx = ball.speed * Math.sin(bounceAngle);
ball.dy = -Math.abs(ball.speed * Math.cos(bounceAngle));
ball.y = paddle.y - ball.height;
}
function handleBall(ball) {
// Split fast movement into smaller steps so the ball cannot tunnel through
// several closely stacked bricks before a collision is resolved.
const totalMoveX = ball.dx * BALL_MOVEMENT_SCALE;
const totalMoveY = ball.dy * BALL_MOVEMENT_SCALE;
const maxMove = Math.max(Math.abs(totalMoveX), Math.abs(totalMoveY));
const steps = Math.max(1, Math.ceil(maxMove / 2));
const stepX = totalMoveX / steps;
const stepY = totalMoveY / steps;
let brickHitThisTick = false;
for (let step = 0; step < steps; step++) {
const previousX = ball.x;
const previousY = ball.y;
ball.x += stepX;
ball.y += stepY;
if (ball.x < wallSize) {
ball.x = wallSize;
ball.dx = Math.abs(ball.dx);
playWallSound();
} else if (ball.x + ball.width > canvas.width - wallSize) {
ball.x = canvas.width - wallSize - ball.width;
ball.dx = -Math.abs(ball.dx);
playWallSound();
}
if (ball.y < wallSize) {
ball.y = wallSize;
ball.dy = Math.abs(ball.dy);
playWallSound();
}
if (ball.dy > 0 && collides(ball, paddle)) {
bounceOffPaddle(ball);
playPaddleSound();
break;
}
if (brickHitThisTick) continue;
for (let i = 0; i < bricks.length; i++) {
const brick = bricks[i];
if (!collides(ball, brick)) continue;
// Work out which face was crossed using the ball's position immediately
// before this small movement step, then place it back outside the brick.
const cameFromBelow = previousY >= brick.y + brick.height;
const cameFromAbove = previousY + ball.height <= brick.y;
const cameFromRight = previousX >= brick.x + brick.width;
const cameFromLeft = previousX + ball.width <= brick.x;
bricks.splice(i, 1);
score += brick.pointValue;
multiballProgress += brick.pointValue;
playBrickSound(brick.pointValue);
if (cameFromBelow) {
ball.y = brick.y + brick.height;
ball.dy = Math.abs(ball.dy);
} else if (cameFromAbove) {
ball.y = brick.y - ball.height;
ball.dy = -Math.abs(ball.dy);
} else if (cameFromRight) {
ball.x = brick.x + brick.width;
ball.dx = Math.abs(ball.dx);
} else if (cameFromLeft) {
ball.x = brick.x - ball.width;
ball.dx = -Math.abs(ball.dx);
} else {
// Fallback for a rare corner overlap: use the shallowest penetration.
const overlapLeft = ball.x + ball.width - brick.x;
const overlapRight = brick.x + brick.width - ball.x;
const overlapTop = ball.y + ball.height - brick.y;
const overlapBottom = brick.y + brick.height - ball.y;
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
if (minOverlap === overlapTop) {
ball.y = brick.y - ball.height;
ball.dy = -Math.abs(ball.dy);
} else if (minOverlap === overlapBottom) {
ball.y = brick.y + brick.height;
ball.dy = Math.abs(ball.dy);
} else if (minOverlap === overlapLeft) {
ball.x = brick.x - ball.width;
ball.dx = -Math.abs(ball.dx);
} else {
ball.x = brick.x + brick.width;
ball.dx = Math.abs(ball.dx);
}
}
increaseBallSpeed(ball);
while (multiballProgress >= MULTIBALL_SCORE_INTERVAL) {
if (balls.length < MAX_BALLS) spawnSecondBall(ball);
multiballProgress -= MULTIBALL_SCORE_INTERVAL;
}
if (bricks.length === 0) levelClearedPending = true;
brickHitThisTick = true;
break;
}
}
}
function skipToNextLevelForTesting() {
if (!TESTING_SYSTEM || !gameStarted || !screenSupported || gameOver) return;
const completedLevel = level;
// Testing skips simulate a clean level clear for paddle-size progression
// so Levels 11+ can be tested without playing through every earlier level.
// Score and bonus lives are still not awarded.
shrinkPaddleAfterCleanClear(completedLevel);
levelClearedPending = false;
level++;
lostLifeThisLevel = false;
multiballProgress = 0;
currentSpeed = getMinimumSpeedForLevel(level);
buildLevel(level);
paddle.x = canvas.width / 2 - paddle.width / 2;
paddle.dx = 0;
resetToSingleBall(currentSpeed);
levelBannerUntil = Date.now() + 1500;
}
function advanceLevel() {
levelClearedPending = false;
playLevelClearSound();
const completedLevel = level;
const cleanClear = !lostLifeThisLevel;
// A perfect clear means the player did not spend a life during this level.
if (cleanClear) {
lives++;
bonusBannerUntil = Date.now() + 1800;
playExtraLifeSound();
shrinkPaddleAfterCleanClear(completedLevel);
}
level++;
lostLifeThisLevel = false;
// Carry forward the speed earned in the previous level, but each new level
// also establishes a slightly higher minimum speed floor.
currentSpeed = Math.max(currentSpeed, getMinimumSpeedForLevel(level));
buildLevel(level);
paddle.x = canvas.width / 2 - paddle.width / 2;
paddle.dx = 0;
resetToSingleBall(currentSpeed);
levelBannerUntil = Date.now() + 1500;
}
const PIXEL_FONT = {
'A': ['01110','10001','10001','11111','10001','10001','10001'],
'E': ['11111','10000','10000','11110','10000','10000','11111'],
'G': ['01111','10000','10000','10111','10001','10001','01110'],
'L': ['10000','10000','10000','10000','10000','10000','11111'],
'M': ['10001','11011','10101','10101','10001','10001','10001'],
'O': ['01110','10001','10001','10001','10001','10001','01110'],
'R': ['11110','10001','10001','11110','10100','10010','10001'],
'V': ['10001','10001','10001','10001','10001','01010','00100'],
' ': ['00000','00000','00000','00000','00000','00000','00000']
};
function drawPixelText(text, centerX, topY, scale) {
const letterWidth = 5 * scale;
const spacing = scale * 2;
const totalWidth = text.length * letterWidth + (text.length - 1) * spacing;
let startX = Math.floor(centerX - totalWidth / 2);
context.fillStyle = 'white';
for (const char of text) {
const pattern = PIXEL_FONT[char] || PIXEL_FONT[' '];
for (let row = 0; row < pattern.length; row++) {
for (let col = 0; col < pattern[row].length; col++) {
if (pattern[row][col] === '1') {
context.fillRect(startX + col * scale, topY + row * scale, scale, scale);
}
}
}
startX += letterWidth + spacing;
}
}
function restartGame() {
score = 0;
lives = STARTING_LIVES;
level = 1;
gameOver = false;
multiballProgress = 0;
currentSpeed = getMinimumSpeedForLevel(level);
lostLifeThisLevel = false;
levelClearedPending = false;
levelBannerUntil = 0;
bonusBannerUntil = 0;
buildLevel(level);
paddleScale = 1.0;
paddle.width = PADDLE_BASE_WIDTH;
paddle.x = canvas.width / 2 - paddle.width / 2;
paddle.dx = 0;
resetToSingleBall(currentSpeed);
}
function updateGame() {
if (gameStarted && !gameOver && screenSupported) paddle.x += paddle.dx;
if (paddle.x < wallSize) {
paddle.x = wallSize;
} else if (paddle.x + paddle.width > canvas.width - wallSize) {
paddle.x = canvas.width - wallSize - paddle.width;
}
// Before launch, keep the waiting ball sitting on top of the paddle.
positionWaitingBallsOnPaddle();
if (gameStarted && screenSupported && !gameOver) {
for (let i = balls.length - 1; i >= 0; i--) {
const ball = balls[i];
handleBall(ball);
if (ball.y > canvas.height) balls.splice(i, 1);
}
if (levelClearedPending) {
advanceLevel();
} else if (balls.length === 0) {
lives--;
lostLifeThisLevel = true;
multiballProgress = 0;
restorePaddleAfterMiss();
playLifeLostSound();
// A lost life resets extra-ball progress and restores 25% paddle width.
// Ball speed also resets to this level's minimum speed.
currentSpeed = getMinimumSpeedForLevel(level);
if (lives <= 0) {
lives = 0;
gameOver = true;
paddle.dx = 0;
playGameOverSound();
} else {
resetToSingleBall(currentSpeed);
}
}
}
}
function drawGame() {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = 'lightgrey';
context.fillRect(0, 0, canvas.width, wallSize);
context.fillRect(0, 0, wallSize, canvas.height);
context.fillRect(canvas.width - wallSize, 0, wallSize, canvas.height);
context.fillStyle = 'white';
context.font = 'bold 18px monospace';
context.textAlign = 'left';
context.fillText('BALLS ' + lives, wallSize + 10, 34);
context.textAlign = 'center';
context.fillText('LEVEL ' + level, canvas.width / 2, 34);
context.textAlign = 'right';
context.fillText('SCORE ' + score, canvas.width - wallSize - 10, 34);
context.font = 'bold 12px monospace';
context.fillStyle = '#bdbdbd';
context.fillText('EXTRA BALL ' + multiballProgress + '/' + MULTIBALL_SCORE_INTERVAL, canvas.width - wallSize - 10, 52);
if (TESTING_SYSTEM) {
context.textAlign = 'left';
context.fillStyle = '#ff4d4d';
context.font = 'bold 12px monospace';
context.fillText('TEST MODE - S: SKIP LEVEL', wallSize + 10, 52);
}
balls.forEach(ball => {
context.fillStyle = 'white';
context.fillRect(ball.x, ball.y, ball.width, ball.height);
});
bricks.forEach(brick => {
context.fillStyle = brick.color;
context.fillRect(brick.x, brick.y, brick.width, brick.height);
});
context.fillStyle = 'cyan';
context.fillRect(paddle.x, paddle.y, paddle.width, paddle.height);
if (Date.now() < levelBannerUntil && !gameOver) {
context.fillStyle = 'white';
context.font = 'bold 28px monospace';
context.textAlign = 'center';
context.fillText('LEVEL ' + level, canvas.width / 2, canvas.height / 2 - 12);
context.font = 'bold 14px monospace';
context.fillText('SPEED ' + currentSpeed.toFixed(2), canvas.width / 2, canvas.height / 2 + 18);
}
if (Date.now() < bonusBannerUntil && !gameOver) {
context.fillStyle = 'white';
context.font = 'bold 16px monospace';
context.textAlign = 'center';
context.fillText('PERFECT CLEAR +1 BALL', canvas.width / 2, canvas.height / 2 + 50);
}
if (gameOver) {
const scale = Math.max(4, Math.min(10, Math.floor(canvas.width / 90)));
drawPixelText('GAME OVER', canvas.width / 2, canvas.height / 2 - (7 * scale) / 2, scale);
context.fillStyle = 'white';
context.font = 'bold 14px monospace';
context.textAlign = 'center';
context.fillText('PRESS SPACE OR TAP TO PLAY AGAIN', canvas.width / 2, canvas.height / 2 + 60);
}
}
function loop(timestamp) {
if (!lastFrameTime) lastFrameTime = timestamp;
const frameDelta = timestamp - lastFrameTime;
lastFrameTime = timestamp;
accumulator += frameDelta;
// Keep the simulation tied to real elapsed time, even when a browser
// delivers requestAnimationFrame callbacks less frequently. A generous
// safety cap prevents a pathological stall from locking the page.
let steps = 0;
while (accumulator >= FIXED_TIMESTEP && steps < MAX_CATCHUP_STEPS) {
updateGame();
accumulator -= FIXED_TIMESTEP;
steps++;
}
// If the browser was stalled for an extreme amount of time, discard only
// the excess beyond our safety cap. Normal foreground frame delays are
// never shortened, so Chrome, Edge and Firefox run at the same game speed.
if (steps === MAX_CATCHUP_STEPS) {
accumulator = 0;
}
drawGame();
requestAnimationFrame(loop);
}
// Do not "catch up" time spent in another tab or while the window is hidden.
// Reset the clock only for an actual visibility transition rather than
// clipping ordinary foreground frames.
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
lastFrameTime = 0;
accumulator = 0;
} else {
lastFrameTime = performance.now();
accumulator = 0;
}
});
function movePaddleToClientX(clientX) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const canvasX = (clientX - rect.left) * scaleX;
paddle.x = canvasX - paddle.width / 2;
paddle.x = Math.max(wallSize, Math.min(paddle.x, canvas.width - wallSize - paddle.width));
}
function launchWaitingBall() {
if (gameOver) {
restartGame();
return;
}
const waitingBall = balls.find(ball => ball.dx === 0 && ball.dy === 0);
if (waitingBall) launchBall(waitingBall);
}
document.addEventListener('keydown', function(e) {
ensureAudio();
if (gameStarted && screenSupported && !gameOver && e.key === 'ArrowLeft') {
paddle.dx = -3;
e.preventDefault();
} else if (gameStarted && screenSupported && !gameOver && e.key === 'ArrowRight') {
paddle.dx = 3;
e.preventDefault();
}
if (gameStarted && screenSupported && e.code === 'Space') {
launchWaitingBall();
e.preventDefault();
}
if (TESTING_SYSTEM && gameStarted && screenSupported && !gameOver && e.key.toLowerCase() === 's') {
skipToNextLevelForTesting();
e.preventDefault();
}
});
document.addEventListener('keyup', function(e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') paddle.dx = 0;
});
canvas.addEventListener('pointerdown', function(e) {
if (!gameStarted || !screenSupported) return;
ensureAudio();
// Mouse users get pointer lock so control continues even when the physical
// mouse would otherwise leave the canvas. Press Esc to release it.
if (e.pointerType === 'mouse' && document.pointerLockElement !== canvas) {
canvas.requestPointerLock?.();
} else if (e.pointerType !== 'mouse') {
canvas.setPointerCapture(e.pointerId);
movePaddleToClientX(e.clientX);
}
if (gameOver || balls.some(ball => ball.dx === 0 && ball.dy === 0)) {
launchWaitingBall();
}
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (gameStarted && screenSupported && document.pointerLockElement === canvas && !gameOver) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
paddle.x += e.movementX * scaleX;
paddle.x = Math.max(wallSize, Math.min(paddle.x, canvas.width - wallSize - paddle.width));
}
});
canvas.addEventListener('pointermove', function(e) {
if (!gameStarted || !screenSupported) return;
if (document.pointerLockElement === canvas) return;
if (e.pointerType === 'mouse' || e.buttons > 0 || e.pointerType === 'touch' || e.pointerType === 'pen') {
movePaddleToClientX(e.clientX);
e.preventDefault();
}
});
canvas.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('resize', function() {
resizeGame();
updateScreenSupport();
});
startGameButton.addEventListener('click', function() {
if (!screenSupported) return;
ensureAudio();
gameStarted = true;
instructions.classList.add('hidden');
});
document.addEventListener('visibilitychange', function() {
// Throw away elapsed hidden-tab time so returning to the game never causes
// a burst of catch-up movement.
lastFrameTime = 0;
accumulator = 0;
});
if (testingControlNote) {
testingControlNote.style.display = TESTING_SYSTEM ? 'block' : 'none';
}
buildLevel(level);
currentSpeed = getMinimumSpeedForLevel(level);
resizeGame();
updateScreenSupport();
paddle.x = canvas.width / 2 - paddle.width / 2;
resetToSingleBall(currentSpeed);
requestAnimationFrame(loop);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Breakout Pattern Editor</title>
<style>
:root {
--bg: #121212;
--panel: #1c1c1c;
--panel-2: #242424;
--border: #3a3a3a;
--text: #f0f0f0;
--muted: #a8a8a8;
--accent: #00d084;
--danger: #d9534f;
--a: #ff4b4b;
--b: #ff9f43;
--c: #2ecc71;
--d: #f1c40f;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: Arial, Helvetica, sans-serif;
min-height: 100vh;
padding: 20px;
}
.wrap {
max-width: 1200px;
margin: 0 auto;
}
h1 {
margin: 0 0 6px;
font-size: 28px;
}
.sub {
color: var(--muted);
margin-bottom: 18px;
line-height: 1.4;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
margin-bottom: 14px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
button {
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
padding: 9px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
button:hover { filter: brightness(1.12); }
button.active { outline: 2px solid var(--accent); }
button.danger { border-color: #7a2f2f; }
.tool-a { background: var(--a); color: #111; font-weight: bold; }
.tool-b { background: var(--b); color: #111; font-weight: bold; }
.tool-c { background: var(--c); color: #111; font-weight: bold; }
.tool-d { background: var(--d); color: #111; font-weight: bold; }
.separator {
width: 1px;
height: 30px;
background: var(--border);
margin: 0 3px;
}
.mode-label {
color: var(--muted);
margin-left: 4px;
}
#gridWrap {
overflow-x: auto;
padding-bottom: 4px;
}
#grid {
display: grid;
gap: 3px;
min-width: 868px;
user-select: none;
}
.cell {
width: 28px;
height: 22px;
border: 1px solid #444;
background: #161616;
border-radius: 2px;
cursor: pointer;
position: relative;
}
.cell:hover { border-color: #777; }
.cell.a { background: var(--a); }
.cell.b { background: var(--b); }
.cell.c { background: var(--c); }
.cell.d { background: var(--d); }
.cell.full-start::after {
content: '';
position: absolute;
top: -1px;
right: -4px;
width: 5px;
height: 22px;
background: inherit;
border-top: 1px solid #444;
border-bottom: 1px solid #444;
z-index: 2;
pointer-events: none;
}
.row-label {
color: var(--muted);
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.output-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.output-head strong { font-size: 16px; }
textarea {
width: 100%;
min-height: 260px;
resize: vertical;
background: #0d0d0d;
color: #d9ffd9;
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
font-family: Consolas, Monaco, monospace;
font-size: 14px;
line-height: 1.45;
}
#patternInput {
min-height: 170px;
margin-top: 10px;
}
.status {
color: var(--accent);
min-height: 18px;
font-size: 13px;
margin-top: 8px;
}
.legend {
color: var(--muted);
font-size: 13px;
line-height: 1.55;
margin-top: 10px;
}
.legend code {
color: #fff;
background: #111;
padding: 1px 4px;
border-radius: 3px;
}
.import-status {
min-height: 18px;
font-size: 13px;
margin-top: 8px;
font-weight: 600;
}
.import-status.success {
color: #72e572;
}
.import-status.error {
color: #ff6b6b;
}
.import-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: #2b2b2b;
border: 1px solid #444;
color: var(--text);
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
text-align: left;
}
.import-toggle:hover {
background: #343434;
}
.import-toggle .arrow {
font-size: 16px;
transition: transform 0.18s ease;
}
.import-toggle.open .arrow {
transform: rotate(90deg);
}
.import-content {
margin-top: 10px;
}
@media (max-width: 700px) {
body { padding: 10px; }
h1 { font-size: 22px; }
}
</style>
</head>
<body>
<div class="wrap">
<h1>Breakout Pattern Editor</h1>
<div class="sub">28 half-brick positions across. Choose a brick type and size, draw the pattern, then copy the generated array into your Breakout HTML.</div>
<div class="panel">
<div class="toolbar">
<button class="tool-a active" data-tool="a">A / 7 pts</button>
<button class="tool-b" data-tool="b">B / 5 pts</button>
<button class="tool-c" data-tool="c">C / 3 pts</button>
<button class="tool-d" data-tool="d">D / 1 pt</button>
<button data-tool="erase">Eraser</button>
<div class="separator"></div>
<span class="mode-label">Size:</span>
<button id="halfBtn">Half Brick</button>
<button id="fullBtn" class="active">Full Brick</button>
<div class="separator"></div>
<button id="clearBtn" class="danger">Clear</button>
</div>
<div class="legend">
Full bricks use two cells and output <code>AA</code>, <code>BB</code>, <code>CC</code>, or <code>DD</code>. Half bricks use one cell and output <code>a</code>, <code>b</code>, <code>c</code>, or <code>d</code>. Empty cells output <code>.</code>. The editor is fixed at <strong>14 rows</strong> to match the Breakout game.
</div>
</div>
<div class="panel">
<button id="importToggle" class="import-toggle" type="button" aria-expanded="false">
<span>Load Existing Pattern</span>
<span class="arrow">▶</span>
</button>
<div id="importContent" class="import-content" hidden>
<div class="output-head" style="margin-top: 10px;">
<strong>Paste Existing Pattern</strong>
<button id="loadPatternBtn">Load Pattern</button>
</div>
<div class="legend">
Paste a complete 14-row Breakout pattern here, including the surrounding <code>[ ]</code> if you have them. Click <strong>Load Pattern</strong> to rebuild the visual editor so you can modify it.
</div>
<textarea id="patternInput" placeholder="[
'............................',
'............................',
...
]"></textarea>
<div id="importStatus" class="import-status"></div>
</div>
</div>
<div class="panel">
<div id="gridWrap">
<div id="grid"></div>
</div>
</div>
<div class="panel">
<div class="output-head">
<strong>Pattern Output</strong>
<button id="copyBtn">Copy Pattern</button>
</div>
<textarea id="output" readonly></textarea>
<div class="status" id="status"></div>
</div>
</div>
<script>
const COLS = 28;
const DEFAULT_ROWS = 14;
let rows = DEFAULT_ROWS;
let tool = 'a';
let sizeMode = 'full';
let mouseDown = false;
// Each cell stores: '.', 'a', 'b', 'c', 'd'.
// Full bricks are represented by two adjacent cells with matching letters
// and tracked in the pairs array so the output becomes uppercase pairs.
let cells = [];
let pairs = [];
const grid = document.getElementById('grid');
const output = document.getElementById('output');
const patternInput = document.getElementById('patternInput');
const importToggle = document.getElementById('importToggle');
const importContent = document.getElementById('importContent');
const importStatus = document.getElementById('importStatus');
const status = document.getElementById('status');
const halfBtn = document.getElementById('halfBtn');
const fullBtn = document.getElementById('fullBtn');
function makeEmptyRow() {
return Array(COLS).fill('.');
}
function makePairRow() {
return Array(COLS).fill(null);
}
function initialize() {
cells = Array.from({ length: rows }, makeEmptyRow);
pairs = Array.from({ length: rows }, makePairRow);
setImportOpen(false);
renderGrid();
updateOutput();
}
function renderGrid() {
grid.innerHTML = '';
grid.style.gridTemplateColumns = `36px repeat(${COLS}, 28px)`;
for (let r = 0; r < rows; r++) {
const label = document.createElement('div');
label.className = 'row-label';
label.textContent = String(r + 1).padStart(2, '0');
grid.appendChild(label);
for (let c = 0; c < COLS; c++) {
const el = document.createElement('div');
el.className = 'cell';
el.dataset.row = r;
el.dataset.col = c;
grid.appendChild(el);
}
}
refreshCells();
}
function refreshCells() {
grid.querySelectorAll('.cell').forEach(el => {
const r = Number(el.dataset.row);
const c = Number(el.dataset.col);
el.className = 'cell';
if (cells[r][c] !== '.') {
el.classList.add(cells[r][c]);
}
const pair = pairs[r][c];
if (pair && pair.start === c) {
el.classList.add('full-start');
}
});
}
function clearPairAt(r, c) {
const pair = pairs[r][c];
if (!pair) {
cells[r][c] = '.';
return;
}
const start = pair.start;
const end = pair.end;
cells[r][start] = '.';
cells[r][end] = '.';
pairs[r][start] = null;
pairs[r][end] = null;
}
function placeHalf(r, c, letter) {
clearPairAt(r, c);
cells[r][c] = letter;
pairs[r][c] = null;
}
function placeFull(r, c, letter) {
if (c >= COLS - 1) {
status.textContent = 'A full brick needs two cells. Choose a cell before the last column.';
return;
}
clearPairAt(r, c);
clearPairAt(r, c + 1);
cells[r][c] = letter;
cells[r][c + 1] = letter;
const pair = { start: c, end: c + 1, letter };
pairs[r][c] = pair;
pairs[r][c + 1] = pair;
}
function eraseAt(r, c) {
clearPairAt(r, c);
}
function applyTool(r, c) {
status.textContent = '';
if (tool === 'erase') {
eraseAt(r, c);
} else if (sizeMode === 'full') {
placeFull(r, c, tool);
} else {
placeHalf(r, c, tool);
}
refreshCells();
updateOutput();
}
function buildRowString(r) {
let result = '';
let c = 0;
while (c < COLS) {
const pair = pairs[r][c];
if (pair && pair.start === c) {
result += pair.letter.toUpperCase() + pair.letter.toUpperCase();
c += 2;
continue;
}
result += cells[r][c];
c++;
}
return result;
}
function parsePatternText(text) {
const quotedRows = [];
const quoteRegex = /['"]([A-Da-d.]+)['"]/g;
let match;
while ((match = quoteRegex.exec(text)) !== null) {
quotedRows.push(match[1]);
}
let patternRows = quotedRows;
// Also accept plain rows without quotes/brackets.
if (patternRows.length === 0) {
patternRows = text
.split(/\r?\n/)
.map(line => line.trim())
.map(line => line.replace(/^[\[\s,]+|[\]\s,]+$/g, ''))
.map(line => line.replace(/^['"]|['"]$/g, ''))
.filter(line => line.length > 0);
}
if (patternRows.length !== DEFAULT_ROWS) {
throw new Error(`Pattern must contain exactly ${DEFAULT_ROWS} rows. Found ${patternRows.length}.`);
}
patternRows.forEach((row, index) => {
if (row.length !== COLS) {
throw new Error(`Row ${index + 1} must be exactly ${COLS} half-brick positions wide. Found ${row.length}.`);
}
if (!/^[A-Da-d.]+$/.test(row)) {
throw new Error(`Row ${index + 1} contains an invalid character. Use only A-D, a-d, and .`);
}
});
return patternRows;
}
function loadPattern(patternRows) {
const newCells = Array.from({ length: DEFAULT_ROWS }, makeEmptyRow);
const newPairs = Array.from({ length: DEFAULT_ROWS }, makePairRow);
for (let r = 0; r < DEFAULT_ROWS; r++) {
const row = patternRows[r];
for (let c = 0; c < COLS; c++) {
const ch = row[c];
if (ch === '.') {
continue;
}
if (/[a-d]/.test(ch)) {
newCells[r][c] = ch;
continue;
}
// Uppercase letters represent full bricks and therefore must be
// written as a matching pair: AA, BB, CC, or DD.
if (/[A-D]/.test(ch)) {
if (c >= COLS - 1 || row[c + 1] !== ch) {
throw new Error(`Row ${r + 1}, position ${c + 1}: uppercase ${ch} must be part of a matching ${ch}${ch} full brick.`);
}
const letter = ch.toLowerCase();
newCells[r][c] = letter;
newCells[r][c + 1] = letter;
const pair = { start: c, end: c + 1, letter };
newPairs[r][c] = pair;
newPairs[r][c + 1] = pair;
c++;
}
}
}
rows = DEFAULT_ROWS;
cells = newCells;
pairs = newPairs;
renderGrid();
updateOutput();
}
function updateOutput() {
const lines = [];
lines.push('[');
for (let r = 0; r < rows; r++) {
const comma = r < rows - 1 ? ',' : '';
lines.push(` '${buildRowString(r)}'${comma}`);
}
lines.push(']');
output.value = lines.join('\n');
}
grid.addEventListener('mousedown', e => {
const cell = e.target.closest('.cell');
if (!cell) return;
mouseDown = true;
applyTool(Number(cell.dataset.row), Number(cell.dataset.col));
});
grid.addEventListener('mouseover', e => {
if (!mouseDown) return;
const cell = e.target.closest('.cell');
if (!cell) return;
applyTool(Number(cell.dataset.row), Number(cell.dataset.col));
});
window.addEventListener('mouseup', () => {
mouseDown = false;
});
grid.addEventListener('contextmenu', e => {
const cell = e.target.closest('.cell');
if (!cell) return;
e.preventDefault();
eraseAt(Number(cell.dataset.row), Number(cell.dataset.col));
refreshCells();
updateOutput();
});
document.querySelectorAll('[data-tool]').forEach(btn => {
btn.addEventListener('click', () => {
tool = btn.dataset.tool;
document.querySelectorAll('[data-tool]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
halfBtn.addEventListener('click', () => {
sizeMode = 'half';
halfBtn.classList.add('active');
fullBtn.classList.remove('active');
});
fullBtn.addEventListener('click', () => {
sizeMode = 'full';
fullBtn.classList.add('active');
halfBtn.classList.remove('active');
});
function setImportOpen(open) {
importContent.hidden = !open;
importToggle.classList.toggle('open', open);
importToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
}
importToggle.addEventListener('click', () => {
setImportOpen(importContent.hidden);
});
document.getElementById('loadPatternBtn').addEventListener('click', () => {
importStatus.textContent = '';
importStatus.className = 'import-status';
status.textContent = '';
try {
const patternRows = parsePatternText(patternInput.value);
loadPattern(patternRows);
importStatus.textContent = 'Pattern loaded successfully. You can now modify it below.';
importStatus.classList.add('success');
// Move the loaded pattern into the output immediately as confirmation.
status.textContent = 'Loaded pattern is ready to edit.';
// Hide the import section again after a successful load.
setImportOpen(false);
} catch (err) {
importStatus.textContent = 'Could not load pattern: ' + err.message;
importStatus.classList.add('error');
patternInput.focus();
}
});
document.getElementById('clearBtn').addEventListener('click', () => {
if (!confirm('Clear the entire pattern and reset the rows?')) return;
rows = DEFAULT_ROWS;
cells = Array.from({ length: rows }, makeEmptyRow);
pairs = Array.from({ length: rows }, makePairRow);
renderGrid();
updateOutput();
importStatus.textContent = '';
importStatus.className = 'import-status';
status.textContent = 'Pattern cleared and rows reset.';
});
document.getElementById('copyBtn').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(output.value);
status.textContent = 'Pattern copied to clipboard.';
} catch (err) {
output.focus();
output.select();
document.execCommand('copy');
status.textContent = 'Pattern copied to clipboard.';
}
});
initialize();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment