Everyone who learns to code eventually tries to build a falling block game. Most people stop somewhere around the rotation code, because that is where it stops feeling like a beginner project. It is worth pushing through. Once the pieces turn properly, the rest of the game falls into place quickly, and you end up with something people actually want to play instead of another to-do list.
This is how Stellix Blocks came together. One HTML file, no libraries, works offline, works on a phone. I will walk through the decisions in the order I made them.
Decide the shape of the well first
The classic well is ten columns wide and twenty rows tall. I went wider β fourteen by twenty-four β and that one choice changes the whole feel of the game.
A wider well means a row needs fourteen blocks instead of ten, so clears come slower and you spend more time managing the surface. It also gives beginners room to recover from a bad stack instead of topping out in ninety seconds. If you are building this for friends or students rather than for competitive players, wider is friendlier.
The grid itself is just an array of rows, and each row is an array that holds either nothing or a colour. That is the entire data model for the board. Every rule in the game is a question asked of that array.
Store pieces as small square matrices
There are seven shapes. Each one is stored as a small square grid of ones and zeros β a three by three grid for most of them, four by four for the long piece, two by two for the square.
Why square, when the shapes are not square? Because rotation becomes trivial. To turn a square matrix clockwise, you build a new one where each column becomes a row in reverse order. Two lines of loops, and it works for every piece without special cases.
The square piece is worth a moment of thought. Rotate it and nothing changes, which is exactly right β a two by two block looks identical from every angle. You do not need to write an exception for it. The general code handles it correctly by accident, which is a good sign the model is right.
Make rotation forgiving
Here is where most home-made versions feel wrong. You slide a piece against the left wall, press rotate, and nothing happens, because the rotated shape would stick through the wall.
Real games solve this with wall kicks. After rotating, before you accept the result, test it in a few nudged positions: where it is, one cell left, one cell right, two cells left, two cells right. Take the first position that fits. If none fit, refuse the rotation.
That is maybe six lines of code and it is the difference between a game that feels stiff and one that feels responsive. Players never notice the kick happening; they only notice its absence.
Use a bag, not random
If you pick each new piece at random, players will sometimes wait twenty pieces for the long straight one, and that feels unfair rather than challenging.
The fix is the seven-bag system. Put all seven shapes in a bag, shuffle it, and deal them out one at a time. When the bag is empty, refill and shuffle again. Over any seven pieces you get each shape exactly once, in an unpredictable order.
The result is a game that still surprises you but never abandons you. It is one of those rare changes that makes something both fairer and more fun at the same time.
Falling, locking and clearing
Gravity is a timer. Accumulate the elapsed milliseconds each frame, and when the total passes the current drop interval, try to move the piece down one row. If it can move, move it. If it cannot, the piece locks: copy its cells into the board array with their colour, and check for completed rows.
Checking for a completed row is one line of thinking β a row is complete if every cell in it holds a colour. Collect the indices of all complete rows, because clearing several at once is where the points are.
To remove a row, cut it out of the array and add an empty row at the top. Everything above slides down automatically because you changed the array’s length and then restored it. No shifting loops, no off-by-one errors.
Score by how many rows went at once. One row is worth a hundred, two are worth three hundred, three are worth five hundred, four are worth eight hundred β all multiplied by the current level. The jump from one row to four being eight times the score, not four, is what makes people take risks and build tall.
Make the clear feel like something
A row that simply disappears is a missed opportunity. In Stellix Blocks, a completed row flashes white for a quarter of a second, then bursts.
The burst is a particle effect and it is simpler than it looks. For every cell in the cleared row, create three small squares in that cell’s colour, give each a random sideways speed and an upward kick, then each frame move them, add a little gravity to the vertical speed, and reduce their opacity. When a particle fades out or falls off the bottom, remove it.
Two practical notes. Cap the total number of particles, otherwise a four-row clear on a wide board can spawn enough squares to stutter on an older phone. And add a short screen shake at the same moment β offset the whole canvas by a couple of random pixels per frame and let that offset decay. Together they turn a quiet event into a satisfying one.
Sound without a single audio file
You do not need to ship MP3s. The Web Audio API can generate every sound this game needs from oscillators and noise.
A move is a very short square wave around 230 Hz. A rotation is slightly higher and softer. Landing is a low sine tone that slides downward. A hard drop is filtered noise plus a deep boom. A line clear is a burst of noise with a rising four-note arpeggio played over it, and if you cleared four rows at once, a low sawtooth growl underneath.
Two rules keep this from breaking. Browsers will not let audio start until the user interacts with the page, so create the audio context on the first click or key press, not on page load. And every sound function should quietly do nothing if the context does not exist, so a browser that blocks audio never throws an error mid-game.
Add a mute button. Some people play with sound off, and taking that choice away from them is rude.
The quality-of-life features
Three additions separate a demo from a game people replay.
The ghost piece is a faded copy of the current piece drawn where it would land. Calculate it by dropping a virtual copy until it collides. It removes the guesswork from placement and costs almost nothing.
The hold slot lets a player park a piece for later, once per piece. Swapping into an empty slot pulls the next piece from the queue; swapping into a full one exchanges them. The once-per-piece limit stops infinite swapping.
The next queue shows the coming three pieces so players can plan two moves ahead rather than react.
Make it work on a phone
Keyboard first: arrows to move and rotate, space to hard drop, a letter for hold, another to pause.
For touch, read gestures on the board itself. Dragging sideways past one cell width moves the piece and resets the reference point so a long drag keeps moving it. Dragging down soft drops. A quick tap with no drag rotates. A fast upward swipe hard drops. Add on-screen buttons as well, because some people prefer buttons and gestures are not discoverable.
Finally, size the canvas from the smaller of the available width and height so the well always fits on screen without scrolling, and redraw at the device pixel ratio so the blocks stay sharp on a phone.
Build all of that and you have written collision detection, matrix maths, a shuffle algorithm, a particle system, procedural audio and gesture handling β in one file, in one sitting.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Stellix Blocks β Coding Stellix</title>
<meta name="description" content="Stellix Blocks β a falling block puzzle game by Coding Stellix. Hold piece, next queue, ghost preview, rising levels and full touch controls in one HTML file." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400;500;600;700&family=Space+Mono:wght@700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#060a16;
--panel:rgba(255,255,255,.045);
--stroke:rgba(255,255,255,.10);
--grid:rgba(255,255,255,.05);
--ink:#e9edff;
--muted:#8b93b8;
--a1:#7b61ff;
--a2:#00d3f2;
--hot:#ff5d8f;
--gold:#ffd60a;
--well:rgba(255,255,255,.025);
--shadow:0 26px 70px rgba(0,0,0,.6);
}
html[data-theme="light"]{
--bg:#eef1fb;
--panel:rgba(12,18,48,.045);
--stroke:rgba(12,18,48,.11);
--grid:rgba(12,18,48,.07);
--ink:#0d1330;
--muted:#5b628a;
--well:rgba(12,18,48,.03);
--shadow:0 26px 60px rgba(40,50,110,.18);
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{
font-family:'Jost',sans-serif;background:var(--bg);color:var(--ink);
min-height:100vh;display:flex;flex-direction:column;align-items:center;
padding:16px 14px 30px;overflow-x:hidden;
transition:background .35s ease,color .35s ease;
}
body::before{
content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
background:
radial-gradient(56vw 46vw at 8% -6%, rgba(123,97,255,.30), transparent 62%),
radial-gradient(48vw 40vw at 100% 4%, rgba(0,211,242,.18), transparent 60%),
radial-gradient(56vw 46vw at 50% 112%, rgba(255,93,143,.16), transparent 64%);
}
html[data-theme="light"] body::before{opacity:.5}
.wrap{position:relative;z-index:1;width:100%;max-width:780px;display:flex;flex-direction:column;gap:14px}
header{display:flex;align-items:center;justify-content:space-between;gap:12px}
.brand{display:flex;align-items:center;gap:10px}
.mark{width:38px;height:38px;border-radius:12px;flex:none;display:grid;place-items:center;
background:linear-gradient(140deg,var(--a1),var(--a2));box-shadow:0 8px 26px rgba(123,97,255,.45)}
.mark svg{width:19px;height:19px}
.brand b{display:block;font-size:.95rem;font-weight:600;line-height:1.1}
.brand small{display:block;font-size:.66rem;letter-spacing:.24em;text-transform:uppercase;color:var(--muted)}
.icon-btn{width:40px;height:40px;border-radius:12px;border:1px solid var(--stroke);background:var(--panel);
color:var(--ink);cursor:pointer;display:grid;place-items:center;transition:transform .18s,border-color .18s}
.icon-btn:hover{transform:translateY(-2px);border-color:var(--a1)}
.icon-btn:focus-visible{outline:2px solid var(--a2);outline-offset:3px}
.icon-btn svg{width:19px;height:19px}
.title{text-align:center}
.title h1{font-size:clamp(1.9rem,8.5vw,2.7rem);font-weight:700;letter-spacing:-.03em;line-height:1;
background:linear-gradient(100deg,var(--a1),var(--a2) 60%,var(--hot));
-webkit-background-clip:text;background-clip:text;color:transparent}
.title p{color:var(--muted);font-size:.88rem;margin-top:5px;font-weight:300}
.scores{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.sc{background:var(--panel);border:1px solid var(--stroke);border-radius:14px;padding:9px 6px;text-align:center;backdrop-filter:blur(10px)}
.sc span{display:block;font-size:.58rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
.sc strong{font-family:'Space Mono',monospace;font-size:1.1rem;letter-spacing:-.03em}
.stage{display:grid;grid-template-columns:120px 1fr 120px;gap:14px;align-items:start}
.side{display:flex;flex-direction:column;gap:10px}
.slot{background:var(--panel);border:1px solid var(--stroke);border-radius:16px;padding:9px 8px 11px;backdrop-filter:blur(10px)}
.slot h3{font-size:.58rem;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);font-weight:500;text-align:center;margin-bottom:6px}
.slot canvas{display:block;width:100%;height:auto}
.well{position:relative;border-radius:18px;overflow:hidden;border:1px solid var(--stroke);
background:var(--well);box-shadow:var(--shadow);backdrop-filter:blur(10px)}
#board{display:block;margin:0 auto;touch-action:none}
.overlay{position:absolute;inset:0;display:none;place-items:center;text-align:center;padding:20px;z-index:4;
background:rgba(6,10,22,.88);backdrop-filter:blur(6px)}
html[data-theme="light"] .overlay{background:rgba(238,241,251,.92)}
.overlay.show{display:grid;animation:pop .28s ease}
@keyframes pop{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}
.overlay .tag{font-size:.62rem;letter-spacing:.26em;text-transform:uppercase;color:var(--a2);margin-bottom:6px}
.overlay h2{font-size:1.6rem;font-weight:700;letter-spacing:-.02em}
.overlay p{color:var(--muted);font-size:.86rem;margin:8px 0 15px;line-height:1.5}
.overlay .final{font-family:'Space Mono',monospace;font-size:1.7rem;margin-bottom:14px}
.btn{font-family:'Jost',sans-serif;font-size:.86rem;font-weight:500;padding:10px 18px;border-radius:12px;
cursor:pointer;border:1px solid var(--stroke);background:var(--panel);color:var(--ink);
display:inline-flex;align-items:center;justify-content:center;gap:7px;
transition:transform .16s,border-color .16s}
.btn:hover{transform:translateY(-2px);border-color:var(--a1)}
.btn:focus-visible{outline:2px solid var(--a2);outline-offset:3px}
.btn.primary{background:linear-gradient(120deg,var(--a1),var(--a2));border-color:transparent;color:#060a16;
font-weight:600;box-shadow:0 10px 30px rgba(123,97,255,.42)}
.btn svg{width:15px;height:15px}
.tools{display:flex;gap:8px}
.tools .btn{flex:1}
.pad{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.pad button{height:58px;border-radius:15px;border:1px solid var(--stroke);background:var(--panel);color:var(--ink);
cursor:pointer;display:grid;place-items:center;gap:2px;transition:transform .12s,background .12s}
.pad button:active{transform:scale(.94);background:rgba(123,97,255,.25)}
.pad button:focus-visible{outline:2px solid var(--a2);outline-offset:2px}
.pad button svg{width:20px;height:20px}
.pad button small{font-size:.54rem;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
.tip{text-align:center;font-size:.76rem;color:var(--muted);font-weight:300}
footer{text-align:center;font-size:.74rem;color:var(--muted);font-weight:300;border-top:1px solid var(--stroke);padding-top:12px}
footer b{color:var(--ink);font-weight:600}
@media (max-width:520px){
.stage{grid-template-columns:1fr;gap:10px}
.side{flex-direction:row}
.side .slot{flex:1}
.side.right{order:-1}
.side.left{order:-2}
.slot canvas{max-height:70px;object-fit:contain}
}
@media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition-duration:.01ms!important}}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<div class="mark">
<svg viewBox="0 0 24 24" fill="#060a16"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5" opacity=".55"/><rect x="3" y="13" width="8" height="8" rx="1.5" opacity=".55"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg>
</div>
<div><b>Coding Stellix</b><small>Blocks</small></div>
</div>
<div style="display:flex;gap:8px">
<button class="icon-btn" id="soundBtn" aria-label="Turn sound off" title="Sound">
<svg id="soundIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/>
</svg>
</button>
<button class="icon-btn" id="themeBtn" aria-label="Switch to light mode" title="Switch theme">
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>
</svg>
</button>
</div>
</header>
<div class="title">
<h1>Stellix Blocks</h1>
<p>A wide 14 x 24 well. Complete a row and watch it blow up.</p>
</div>
<div class="scores">
<div class="sc"><span>Score</span><strong id="sScore">0</strong></div>
<div class="sc"><span>Lines</span><strong id="sLines">0</strong></div>
<div class="sc"><span>Level</span><strong id="sLevel">1</strong></div>
</div>
<div class="stage">
<div class="side left">
<div class="slot"><h3>Hold</h3><canvas id="holdCv" width="180" height="140"></canvas></div>
</div>
<div class="well">
<canvas id="board"></canvas>
<div class="overlay show" id="ovStart">
<div>
<div class="tag">Coding Stellix</div>
<h2>Stellix Blocks</h2>
<p>Arrows to move, up to rotate, space to drop.<br>On a phone: swipe and tap the board.</p>
<button class="btn primary" id="startBtn">Start game</button>
</div>
</div>
<div class="overlay" id="ovPause">
<div>
<div class="tag">Paused</div>
<h2>Take your time</h2>
<p>The stack is not going anywhere.</p>
<button class="btn primary" id="resumeBtn">Resume</button>
</div>
</div>
<div class="overlay" id="ovOver">
<div>
<div class="tag" id="overTag">Game over</div>
<h2>Stack topped out</h2>
<div class="final" id="finalScore">0</div>
<p id="overLine">Lines cleared: 0</p>
<button class="btn primary" id="againBtn">Play again</button>
</div>
</div>
</div>
<div class="side right">
<div class="slot"><h3>Next</h3><canvas id="nextCv" width="180" height="330"></canvas></div>
</div>
</div>
<div class="tools">
<button class="btn" id="pauseBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 5v14M15 5v14"/></svg>
Pause
</button>
<button class="btn" id="restartBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>
Restart
</button>
</div>
<div class="pad" aria-label="Game controls">
<button data-act="left" aria-label="Move left"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg><small>Left</small></button>
<button data-act="rotate" aria-label="Rotate"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg><small>Rotate</small></button>
<button data-act="right" aria-label="Move right"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg><small>Right</small></button>
<button data-act="drop" aria-label="Hard drop"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M6 12l6 6 6-6M5 21h14"/></svg><small>Drop</small></button>
</div>
<p class="tip" id="tipLine">Bigger well, more room to build. Complete a row and it blasts apart.</p>
<footer>Built by <b>Coding Stellix</b></footer>
</div>
<script>
(function(){
"use strict";
/* ================= board setup ================= */
const COLS=14, ROWS=24;
const board=document.getElementById('board'), bx=board.getContext('2d');
const holdCv=document.getElementById('holdCv'), hx=holdCv.getContext('2d');
const nextCv=document.getElementById('nextCv'), nx=nextCv.getContext('2d');
const $=id=>document.getElementById(id);
const ovStart=$('ovStart'), ovPause=$('ovPause'), ovOver=$('ovOver'), tipLine=$('tipLine');
const PIECES={
I:{c:'#00d3f2',m:[[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]]},
J:{c:'#4c6ef5',m:[[1,0,0],[1,1,1],[0,0,0]]},
L:{c:'#ff9f1c',m:[[0,0,1],[1,1,1],[0,0,0]]},
O:{c:'#ffd60a',m:[[1,1],[1,1]]},
S:{c:'#2ec4b6',m:[[0,1,1],[1,1,0],[0,0,0]]},
T:{c:'#c77dff',m:[[0,1,0],[1,1,1],[0,0,0]]},
Z:{c:'#ff5d8f',m:[[1,1,0],[0,1,1],[0,0,0]]}
};
const NAMES=Object.keys(PIECES);
/* ================= sound ================= */
const SFX={
ctx:null, on:true,
init(){
if(!this.ctx){
const AC=window.AudioContext||window.webkitAudioContext;
if(AC){ try{ this.ctx=new AC(); }catch(e){ this.ctx=null; } }
}
if(this.ctx && this.ctx.state==='suspended') this.ctx.resume();
},
tone(f,dur,type,vol,slideTo){
if(!this.on||!this.ctx) return;
const t=this.ctx.currentTime;
const o=this.ctx.createOscillator(), g=this.ctx.createGain();
o.type=type||'sine';
o.frequency.setValueAtTime(f,t);
if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(30,slideTo),t+dur);
g.gain.setValueAtTime(vol||0.07,t);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g); g.connect(this.ctx.destination);
o.start(t); o.stop(t+dur+0.03);
},
noise(dur,vol,cut){
if(!this.on||!this.ctx) return;
const c=this.ctx, len=Math.max(1,Math.floor(c.sampleRate*dur));
const buf=c.createBuffer(1,len,c.sampleRate), d=buf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/len,1.6);
const src=c.createBufferSource(); src.buffer=buf;
const f=c.createBiquadFilter(); f.type='lowpass'; f.frequency.value=cut||2000;
const g=c.createGain(); g.gain.value=vol||0.12;
src.connect(f); f.connect(g); g.connect(c.destination);
src.start();
},
move(){ this.tone(230,0.045,'square',0.045); },
turn(){ this.tone(370,0.06,'triangle',0.055); },
land(){ this.tone(150,0.09,'sine',0.08,110); },
slam(){ this.noise(0.13,0.10,900); this.tone(95,0.14,'sine',0.10,55); },
hold(){ this.tone(500,0.07,'triangle',0.05,700); },
blast(n){
this.noise(n>=4?0.55:0.32, n>=4?0.22:0.15, n>=4?3200:2200);
const notes=[523,659,784,1047];
for(let i=0;i<Math.min(n+1,4);i++){
setTimeout(()=>this.tone(notes[i],0.17,'square',0.085),i*65);
}
if(n>=4) setTimeout(()=>this.tone(170,0.55,'sawtooth',0.10,55),40);
},
levelUp(){ [523,698,880].forEach((f,i)=>setTimeout(()=>this.tone(f,0.16,'triangle',0.08),i*90)); },
over(){ [440,349,262,175].forEach((f,i)=>setTimeout(()=>this.tone(f,0.28,'sawtooth',0.09),i*130)); }
};
const G={
grid:[], cur:null, hold:null, holdUsed:false, bag:[], queue:[],
score:0, lines:0, level:1, drop:0, speed:800,
state:'ready', // ready | playing | paused | over
clearing:null, shake:0, flash:0, bits:[]
};
function emptyGrid(){ return Array.from({length:ROWS},()=>new Array(COLS).fill(null)); }
/* ================= 7-bag randomiser ================= */
function refill(){
const bag=NAMES.slice();
for(let i=bag.length-1;i>0;i--){ const j=(Math.random()*(i+1))|0; [bag[i],bag[j]]=[bag[j],bag[i]]; }
G.bag.push(...bag);
}
function pull(){ if(G.bag.length<3) refill(); return G.bag.shift(); }
function spawn(name){
const p=PIECES[name];
const m=p.m.map(r=>r.slice());
return { n:name, c:p.c, m, x:((COLS-m[0].length)/2)|0, y:name==='I'?-1:0 };
}
function newPiece(){
while(G.queue.length<3) G.queue.push(pull());
G.cur=spawn(G.queue.shift());
G.queue.push(pull());
G.holdUsed=false;
if(collide(G.cur,0,0)) gameOver();
drawSide();
}
/* ================= collision + locking ================= */
function collide(p,dx,dy,mat){
const m=mat||p.m;
for(let r=0;r<m.length;r++){
for(let c=0;c<m[r].length;c++){
if(!m[r][c]) continue;
const nxp=p.x+c+dx, nyp=p.y+r+dy;
if(nxp<0||nxp>=COLS||nyp>=ROWS) return true;
if(nyp>=0 && G.grid[nyp][nxp]) return true;
}
}
return false;
}
function lock(){
G.cur.m.forEach((row,r)=>row.forEach((v,c)=>{
if(!v) return;
const y=G.cur.y+r, x=G.cur.x+c;
if(y>=0) G.grid[y][x]=G.cur.c;
}));
const full=[];
for(let r=0;r<ROWS;r++) if(G.grid[r].every(Boolean)) full.push(r);
if(full.length){
G.clearing={rows:full,t:0};
G.shake=full.length>=4?10:4;
G.cur=null;
} else {
newPiece();
}
}
function blastRows(rows){
rows.forEach(r=>{
for(let c=0;c<COLS;c++){
const col=G.grid[r][c]||'#ffffff';
for(let k=0;k<3;k++){
G.bits.push({
x:(c+Math.random())*CELL,
y:(r+Math.random())*CELL,
vx:(Math.random()-0.5)*7,
vy:-(1.5+Math.random()*4.5),
s:CELL*(0.16+Math.random()*0.22),
c:col, life:1
});
}
}
});
if(G.bits.length>900) G.bits.splice(0,G.bits.length-900);
}
function finishClear(){
const rows=G.clearing.rows;
blastRows(rows);
SFX.blast(rows.length);
rows.sort((a,b)=>a-b).forEach(r=>{ G.grid.splice(r,1); G.grid.unshift(new Array(COLS).fill(null)); });
const n=rows.length;
G.score += [0,100,300,500,800][n] * G.level;
G.lines += n;
const nextLevel=Math.floor(G.lines/10)+1;
if(nextLevel>G.level){
G.level=nextLevel;
G.speed=Math.max(90, 800-(G.level-1)*70);
SFX.levelUp();
tipLine.textContent='Level '+G.level+' β the stack falls faster now.';
} else if(n===4){
tipLine.textContent='Four rows at once. That is the maximum payout.';
}
G.clearing=null;
updateScores();
newPiece();
}
/* ================= moves ================= */
function move(dx){ if(G.state!=='playing'||G.clearing) return; if(!collide(G.cur,dx,0)){ G.cur.x+=dx; SFX.move(); } }
function softDrop(){
if(G.state!=='playing'||G.clearing) return;
if(!collide(G.cur,0,1)){ G.cur.y++; G.score+=1; updateScores(); }
else { SFX.land(); lock(); }
G.drop=0;
}
function hardDrop(){
if(G.state!=='playing'||G.clearing) return;
let d=0;
while(!collide(G.cur,0,1)){ G.cur.y++; d++; }
G.score+=d*2; G.shake=Math.max(G.shake,7);
SFX.slam(); updateScores(); lock(); G.drop=0;
}
function rotate(){
if(G.state!=='playing'||G.clearing) return;
const m=G.cur.m, n=m.length;
const out=Array.from({length:n},()=>new Array(n).fill(0));
for(let r=0;r<n;r++) for(let c=0;c<n;c++) out[c][n-1-r]=m[r][c];
for(const k of [0,-1,1,-2,2]){
if(!collide(G.cur,k,0,out)){ G.cur.m=out; G.cur.x+=k; SFX.turn(); return; }
}
G.shake=3;
}
function holdPiece(){
if(G.state!=='playing'||G.holdUsed||G.clearing) return;
const swap=G.hold;
G.hold=G.cur.n;
if(swap) G.cur=spawn(swap); else { G.cur=spawn(G.queue.shift()); G.queue.push(pull()); }
G.holdUsed=true; SFX.hold();
if(collide(G.cur,0,0)) gameOver();
drawSide();
}
/* ================= sizing ================= */
let CELL=24, DPR=1;
function resize(){
const rect=board.parentElement.getBoundingClientRect();
const availH=Math.min(window.innerHeight*0.66, 760);
let w=rect.width, cell=w/COLS;
if(cell*ROWS>availH) cell=availH/ROWS;
CELL=Math.max(10,cell);
DPR=Math.min(window.devicePixelRatio||1,2);
board.width=CELL*COLS*DPR; board.height=CELL*ROWS*DPR;
board.style.width=(CELL*COLS)+'px'; board.style.height=(CELL*ROWS)+'px';
bx.setTransform(DPR,0,0,DPR,0,0);
}
/* ================= drawing ================= */
function css(v){ return getComputedStyle(document.documentElement).getPropertyValue(v).trim(); }
let GRIDCOL='rgba(255,255,255,.05)';
function readColors(){ GRIDCOL=css('--grid'); }
readColors();
function block(ctx,x,y,size,color,alpha){
ctx.globalAlpha=alpha===undefined?1:alpha;
const pad=Math.max(1,size*0.07), s=size-pad*2;
ctx.fillStyle=color;
roundRect(ctx,x+pad,y+pad,s,s,Math.max(2,size*0.16));
ctx.fill();
ctx.fillStyle='rgba(255,255,255,.30)';
roundRect(ctx,x+pad+s*0.14,y+pad+s*0.13,s*0.72,s*0.20,Math.max(1,size*0.09));
ctx.fill();
ctx.globalAlpha=1;
}
function roundRect(ctx,x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
function drawBoard(now){
const W=CELL*COLS, H=CELL*ROWS;
bx.clearRect(0,0,W,H);
let ox=0,oy=0;
if(G.shake>0){ ox=(Math.random()-.5)*G.shake; oy=(Math.random()-.5)*G.shake; G.shake*=0.84; if(G.shake<.3)G.shake=0; }
bx.save(); bx.translate(ox,oy);
// grid lines
bx.strokeStyle=GRIDCOL; bx.lineWidth=1;
bx.beginPath();
for(let c=1;c<COLS;c++){ bx.moveTo(c*CELL+.5,0); bx.lineTo(c*CELL+.5,H); }
for(let r=1;r<ROWS;r++){ bx.moveTo(0,r*CELL+.5); bx.lineTo(W,r*CELL+.5); }
bx.stroke();
// settled blocks
for(let r=0;r<ROWS;r++){
for(let c=0;c<COLS;c++){
if(G.grid[r][c]) block(bx,c*CELL,r*CELL,CELL,G.grid[r][c]);
}
}
// clearing flash
if(G.clearing){
G.clearing.t+=16;
const a=0.9*(1-G.clearing.t/260);
bx.fillStyle='rgba(255,255,255,'+Math.max(0,a)+')';
G.clearing.rows.forEach(r=>bx.fillRect(0,r*CELL,W,CELL));
if(G.clearing.t>=260) finishClear();
}
// blast debris
for(let i=G.bits.length-1;i>=0;i--){
const b=G.bits[i];
b.x+=b.vx; b.y+=b.vy; b.vy+=0.34; b.vx*=0.99; b.life-=0.022;
if(b.life<=0 || b.y>ROWS*CELL+40){ G.bits.splice(i,1); continue; }
bx.globalAlpha=Math.max(0,b.life);
bx.fillStyle=b.c;
bx.fillRect(b.x,b.y,b.s,b.s);
bx.globalAlpha=1;
}
if(G.cur && G.state!=='over'){
// ghost
let gy=0;
while(!collide(G.cur,0,gy+1)) gy++;
G.cur.m.forEach((row,r)=>row.forEach((v,c)=>{
if(!v) return;
const y=G.cur.y+r+gy;
if(y>=0) block(bx,(G.cur.x+c)*CELL,y*CELL,CELL,G.cur.c,0.20);
}));
// active piece
G.cur.m.forEach((row,r)=>row.forEach((v,c)=>{
if(!v) return;
const y=G.cur.y+r;
if(y>=0) block(bx,(G.cur.x+c)*CELL,y*CELL,CELL,G.cur.c);
}));
}
bx.restore();
}
function drawMini(ctx,cv,names,cellSize){
ctx.clearRect(0,0,cv.width,cv.height);
names.forEach((name,i)=>{
if(!name) return;
const p=PIECES[name], m=p.m;
let minC=99,maxC=-1,minR=99,maxR=-1;
m.forEach((row,r)=>row.forEach((v,c)=>{ if(v){ minC=Math.min(minC,c);maxC=Math.max(maxC,c);minR=Math.min(minR,r);maxR=Math.max(maxR,r);} }));
const w=(maxC-minC+1)*cellSize, h=(maxR-minR+1)*cellSize;
const slotH=cv.height/names.length;
const ox=(cv.width-w)/2, oy=i*slotH+(slotH-h)/2;
for(let r=minR;r<=maxR;r++)
for(let c=minC;c<=maxC;c++)
if(m[r][c]) block(ctx,ox+(c-minC)*cellSize,oy+(r-minR)*cellSize,cellSize,p.c);
});
}
function drawSide(){
drawMini(hx,holdCv,[G.hold],30);
drawMini(nx,nextCv,G.queue.slice(0,3),30);
}
/* ================= loop ================= */
let last=0;
function loop(now){
requestAnimationFrame(loop);
const dt=now-last; last=now;
if(G.state==='playing' && !G.clearing){
G.drop+=dt;
if(G.drop>=G.speed){
G.drop=0;
if(!collide(G.cur,0,1)) G.cur.y++;
else { SFX.land(); lock(); }
}
}
drawBoard(now);
}
/* ================= state ================= */
function updateScores(){
$('sScore').textContent=G.score;
$('sLines').textContent=G.lines;
$('sLevel').textContent=G.level;
}
function reset(){
G.grid=emptyGrid(); G.bag=[]; G.queue=[]; G.hold=null; G.holdUsed=false;
G.score=0; G.lines=0; G.level=1; G.speed=800; G.drop=0; G.clearing=null; G.shake=0; G.bits=[];
refill(); updateScores();
G.state='playing';
newPiece();
}
function gameOver(){
G.state='over';
SFX.over();
$('finalScore').textContent=G.score;
$('overLine').textContent='Lines cleared: '+G.lines+' Β· Level '+G.level;
$('overTag').textContent = G.lines>=20 ? 'Strong run' : 'Game over';
setTimeout(()=>ovOver.classList.add('show'),260);
}
function togglePause(){
if(G.state==='playing'){ G.state='paused'; ovPause.classList.add('show'); }
else if(G.state==='paused'){ G.state='playing'; ovPause.classList.remove('show'); }
}
/* ================= input ================= */
window.addEventListener('keydown',e=>{
SFX.init();
const k=e.key;
if(['ArrowLeft','ArrowRight','ArrowDown','ArrowUp',' '].includes(k)) e.preventDefault();
if(k==='ArrowLeft') move(-1);
else if(k==='ArrowRight') move(1);
else if(k==='ArrowDown') softDrop();
else if(k==='ArrowUp'||k==='x'||k==='X') rotate();
else if(k===' ') hardDrop();
else if(k==='c'||k==='C') holdPiece();
else if(k==='p'||k==='P') togglePause();
});
document.querySelectorAll('.pad button').forEach(b=>{
b.addEventListener('click',()=>{
SFX.init();
const a=b.dataset.act;
if(a==='left') move(-1);
else if(a==='right') move(1);
else if(a==='rotate') rotate();
else if(a==='drop') hardDrop();
});
});
holdCv.addEventListener('click',holdPiece);
let tx=0,ty=0,tt=0,moved=false;
board.addEventListener('touchstart',e=>{
SFX.init();
const t=e.touches[0]; tx=t.clientX; ty=t.clientY; tt=Date.now(); moved=false;
},{passive:true});
board.addEventListener('touchmove',e=>{
const t=e.touches[0];
const dx=t.clientX-tx, dy=t.clientY-ty;
if(Math.abs(dx)>CELL && Math.abs(dx)>Math.abs(dy)){
move(dx>0?1:-1); tx=t.clientX; moved=true;
} else if(dy>CELL*1.2 && Math.abs(dy)>Math.abs(dx)){
softDrop(); ty=t.clientY; moved=true;
}
},{passive:true});
board.addEventListener('touchend',e=>{
const t=e.changedTouches[0];
const dy=t.clientY-ty;
if(moved) return;
if(dy<-40) hardDrop();
else if(Date.now()-tt<260) rotate();
},{passive:true});
$('startBtn').addEventListener('click',()=>{ SFX.init(); ovStart.classList.remove('show'); reset(); });
$('againBtn').addEventListener('click',()=>{ ovOver.classList.remove('show'); reset(); tipLine.textContent='Fresh stack. Keep the surface flat.'; });
$('resumeBtn').addEventListener('click',togglePause);
$('pauseBtn').addEventListener('click',togglePause);
$('restartBtn').addEventListener('click',()=>{ ovOver.classList.remove('show'); ovPause.classList.remove('show'); reset(); });
/* sound toggle */
const soundBtn=$('soundBtn'), soundIcon=$('soundIcon');
const SND_ON='<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/>';
const SND_OFF='<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M22 9l-6 6M16 9l6 6"/>';
soundBtn.addEventListener('click',()=>{
SFX.on=!SFX.on;
SFX.init();
soundIcon.innerHTML=SFX.on?SND_ON:SND_OFF;
soundBtn.setAttribute('aria-label',SFX.on?'Turn sound off':'Turn sound on');
soundBtn.style.color=SFX.on?'':'var(--muted)';
if(SFX.on) SFX.turn();
tipLine.textContent=SFX.on?'Sound on β line clears blast.':'Sound off.';
});
/* theme */
const themeBtn=$('themeBtn'), themeIcon=$('themeIcon');
const MOON='<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>';
const SUN='<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>';
themeBtn.addEventListener('click',()=>{
const light=document.documentElement.getAttribute('data-theme')==='light';
document.documentElement.setAttribute('data-theme',light?'dark':'light');
themeIcon.innerHTML=light?SUN:MOON;
themeBtn.setAttribute('aria-label',light?'Switch to light mode':'Switch to dark mode');
setTimeout(readColors,60);
});
window.addEventListener('resize',resize);
/* ================= boot ================= */
G.grid=emptyGrid();
refill();
G.queue=[pull(),pull(),pull()];
resize(); drawSide(); updateScores();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>



