Cosmic Tetris — Classic Stacking with a Cosmic Layer
Tetris Guideline core (SRS · 7-bag · lock delay) layered with a hot-pink outer energy ring, blackhole absorb, UFO enemies and lightning — keeping the rules intact while adding a cosmic skin, plus the canvas optimization story.
Why Tetris
Our arcade lineup had stacked up shooters — Galaga, Asteroids, Cosmic Barrage, Cosmic Flick, Cosmic Pinball. The puzzle/stacking slot was empty.
Tetris is a proven classic. Rules are universal, focus hits fast. Layering a cosmic skin follows the same playbook as Pinball — familiar core + cosmic layer. The challenge this time was: how do we add cosmic weirdness without breaking the rules players already know?
1. Core — respecting the Tetris Guideline
No wheel-reinvention. The core follows the official guideline:
- 7-bag randomizer: each shuffle contains all 7 pieces exactly once — fair distribution.
- SRS (Super Rotation System): 5-step wall-kick tables per rotation transition. I piece uses a separate table.
- Lock delay + move reset: 0.5s grace once a piece touches bottom; movement/rotation resets the timer (max 15 resets).
- Scoring: Single 100 / Double 300 / Triple 500 / Tetris 800, T-spin bonus, Back-to-back ×1.5, Combo bonus.
Get this right and the game is already fun.
2. Cosmic layer — ENERGY CORE + BLACKHOLE
This is where the page's identity enters.
ENERGY CORE — the board's own outline becomes the gauge
Originally I put a separate gauge bar above the board. A user pointed at the neon outer rim and said: "What if that line was the gauge?" — that suggestion shaped the final form. There's no separate UI widget anymore: the outer rim itself fills with hot-pink, clockwise, as energy charges.
// Trace clockwise from the upper-right corner
const tracePath = () => {
ctx.beginPath();
ctx.moveTo(BOARD_X + W, BOARD_Y);
let r = remain;
// Seg1: upper-right → lower-right (H)
const s1 = Math.min(r, H);
ctx.lineTo(BOARD_X + W, BOARD_Y + s1);
r -= s1;
if (r <= 0) return;
// ... 4 segments total
};
The 3-layer neon stroke pattern from Pinball (outer aura + body + bright core) was extracted into a strokeNeonRect helper. Both the board's base outline (purple) and the energy gauge (hot pink) use the same grammar.
Charge per line clear:
| Action | Charge |
|---|---|
| Single | +8 |
| Double | +18 |
| Triple | +32 |
| Tetris (4 lines) | +52 |
| Lock (anti-stall) | +0.5 |
A single Tetris fills more than half the ring. Players naturally gravitate toward Tetris setups.
BLACKHOLE — the comeback button
When the ring is full, B (or the dedicated mobile button) triggers it.
- Absorbs the bottom 3 rows outright + 2,000 bonus
- Outer ring pulses pink → "ready" affordance
- 3 prelude lightning strikes in quick succession when triggered
- During the 1.5s absorb, additional lightning fires every 0.2~0.66s
Lightning was lifted from Pinball's ambient code — zigzag path + maxAge fade. Two layers: outer glow (shadowBlur 14) + bright core (shadowBlur 0).
function spawnLightning(state, frameOffset = 0) {
// ... 5~9 segment zigzag path
state.lightnings.push({ points, age: -frameOffset, maxAge: 18 });
}
The age: -frameOffset trick lets us push 3 lightning at once but they appear at frames 0/6/12. No separate scheduling queue needed.
3. METEOR — periodic disruption (toned down after UFOs)
Every 50 seconds a meteor strikes. A 1.5s warning highlights one column (down from two), and one stack cell in that column vanishes.
The first version was 30s / 2 columns / 2 cells. After UFOs were added, the roles overlapped too much, so I weakened meteor. Lesson: stacking too many simultaneous disruptions kills the cumulative buildup feel — players never get to settle.
4. UFO enemies — Phase A: shootable invaders
UFOs introduced the largest variability. The Pinball UFO assets (rendering, motion, HP, lasers) ported well into a simpler Tetris context.
Spawn rules
- Level 5+ triggers, every 25~40 seconds (random)
- 1~3 simultaneous spawns based on level
- Slide in from top/left/right (easeOutCubic)
- Slot stagger: when multiple UFOs spawn, they evenly distribute across the board width — no overlap
Targeting — top filled cell first
function findTopFilledCell(state) {
for (let r = BOARD_BUFFER_ROWS; r < state.board.length; r++) {
for (let c = 0; c < BOARD_COLS; c++) {
if (state.board[r][c] !== null) return [r, c];
}
}
return null;
}
A key game-design decision: target the highest filled cell instead of random. That makes UFO attacks feel closer to clearing the top of your stack — helpful, not destructive. Random target destruction would feel like RNG luck.
The shootdown — piece-as-projectile
The most satisfying mechanic. If a falling piece overlaps a UFO's body, the UFO is destroyed: level × 500 points + an explosion particle + "UFO DOWN +N" label. Players aren't just defending — they have an active counter-attack.
function checkPieceUfoCollision(state) {
if (!state.current || state.ufos.length === 0) return;
for (const u of state.ufos) {
if (u.despawnProgress > 0) continue;
if (u.spawnProgress < 1) continue; // invincible while entering
for (const [cx, cy] of cells) {
if (dx * dx + dy * dy < (CELL_SIZE * 0.4 + UFO_R) ** 2) {
u.despawnProgress = 0.001;
state.score += SCORE_UFO_DESTROY * state.level;
spawnExplosion(state, u.x, u.y);
}
}
}
}
When a Blackhole is triggered, all UFOs immediately start despawning. Otherwise they'd be visibly frozen for 1.5 seconds — awkward.
5. Canvas optimization — OffscreenCanvas caches
The biggest hotspot turned out to be drawing settled blocks. The board is 10×20 = 200 cells, and each cell needs a gradient + 2 polygon paths + a stroke. At 60fps that's potentially 70k+ paint calls per second.
Solution: per-PieceType OffscreenCanvas cache.
const _cellCache: Map<PieceType, OffscreenCanvas | HTMLCanvasElement> = new Map();
function getCellCache(type: PieceType) {
let cache = _cellCache.get(type);
if (!cache) {
cache = buildCellCache(type); // built once
_cellCache.set(type, cache);
}
return cache;
}
function drawBlockCell(ctx, col, row, type, alpha) {
const cache = getCellCache(type);
// alpha < 1 (ghost) needs save/restore for globalAlpha; otherwise raw drawImage
ctx.drawImage(cache, x, y);
}
Now every frame is just drawImage calls. Same pattern as Pinball.
The board frame cache uses the same approach — grid + neon outline rendered once into an OffscreenCanvas, then drawImage once per frame.
6. Cache pitfall — don't cache text
I initially cached the "ENERGY CORE" label inside an OffscreenCanvas too. User feedback: "Why does the text look blurry?"
Cause: a cache canvas built at CSS logical size (w × 24) has that resolution baked in. On screen it's drawn through ctx.drawImage after DPR(2x~3x) + fit scale, so it's bilinearly upscaled. Solid rects don't show it, but text edges do.
Fix was simple: don't cache text. Render directly each frame with ctx.fillText. The ctx already has DPR transform applied, so glyphs land at native resolution — sharp.
function drawEnergyBar(ctx, ...) {
ctx.drawImage(_energyPanelCache, x, y); // gauge track cached
ctx.fillText('ENERGY CORE', x, y); // text rendered live (sharp)
}
The label itself is gone now (replaced by the outer-rim gauge), but the lesson stuck for other game HUDs.
7. The CSS vs JS sizing tug-of-war
The hook initially set canvas.style.width/height directly every frame from the parent's clientWidth/Height. That caused a problem when the user tried to tweak CSS — the inline style overwrote everything.
Fix: delegate sizing to CSS. The hook only reads canvas.clientWidth/Height and updates the drawing buffer + ctx transform.
const recompute = () => {
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
dprRef.current = setupCanvasScale(canvas, cssW, cssH, { setStyle: false });
const sx = cssW / LOGICAL_W;
const sy = cssH / LOGICAL_H;
const fit = Math.min(sx, sy);
cssFitRef.current = {
cssW,
cssH,
fit,
offX: (cssW - LOGICAL_W * fit) / 2,
offY: (cssH - LOGICAL_H * fit) / 2,
};
};
Render-time transform:
ctx.setTransform(fit * dpr, 0, 0, fit * dpr, offX * dpr, offY * dpr);
drawCosmicTetris(ctx, state, LOGICAL_W, LOGICAL_H, isMobile);
Now CSS owns the box (aspect-ratio, max-width, max-height, transform), and JS owns the fit + centering inside it. Clean separation, no debug nightmare.
8. Audio — BGM mutes, SFX always plays
The speaker button only mutes BGM (per audio.md rule). I introduced a regression where a guard accidentally muted SFX too — quickly removed.
A separate, sneakier bug: BGM didn't respect isMuted when the game started. Phase-entry effect and isMuted effect were separate; if you started muted, the isMuted effect didn't fire (no value change), so BGM played at default volume.
// After fix — phase + isMuted in same deps, immediate setVolume after BGM start
useEffect(() => {
if (startPhases.includes(s.phase)) {
if (ctx) {
startTetrisBGM(ctx);
setTetrisBGMVolume(isMuted ? 0 : BGM_VOLUME); // ← apply right away
}
} else {
setTetrisBGMVolume(isMuted ? 0 : BGM_VOLUME);
}
}, [ui.phase, isMuted]);
The same bug existed in the Pinball hook — fixed in tandem.
9. SFX synthesis — gain floor
Mid-development the user reported: "I don't hear the lock sound when blocks land." Debugging revealed — 180→80Hz sine sweep thump alone is barely audible on regular laptop/mobile speakers. Sub-100Hz is poorly reproduced on small drivers.
export function playTetrisLock(ctx) {
// ❌ before: 80Hz sine only — inaudible
// ✅ after: mid-range click + thump + noise burst
sfxBlip(ctx, 1100, 0.02, 'square', 0.22); // transient click
sfxSweep(ctx, 380, 160, 0.09, 'square', 0.3); // mid thump
sfxNoise(ctx, 0.06, 0.22, 1400);
}
Rule of thumb: every game SFX should sit in the 200~2000Hz mid-range with a transient. A short 1100Hz click is far more perceivable than a 80Hz thump.
10. Retro
- Familiar + differentiated: keep the core rules sacred. Add disruptions (Blackhole, Meteor, UFO) but never let them break the rules players know — the moment that happens it becomes RNG, not skill.
- Don't pile up disruptions: weakening Meteor when UFOs were added was the right call. Too many simultaneous external interferences erase the buildup loop.
- Counter-attack = satisfaction: making UFOs shootable by colliding falling pieces (instead of being purely defensive) was the single best decision.
- Reuse existing visual elements as UI: the outer neon rim becoming the gauge is way cleaner than a separate widget.
- Don't cache everything: text caches blur on upscale. Cache static shapes/gradients only.
- Pick one owner for sizing: CSS owns the box, JS owns the inner fit. Don't have both fight for size.
Next up: Phase B (UFO transformation beam — turning the falling piece into a different one) or Radial Tetris (circular grid around a central blackhole). Both are on hold pending Phase A user feedback.
Guestbook
Leave a short note about this post
Loading...