[reasoning, returned separately by llama-server] Let me plan this carefully. I need to build a polished, visually impressive Tetris-style falling-block puzzle game (without using the word "Tetris") in a single self-contained HTML file. It must fit within 800×640px, be fully inline, have no external resources, and meet all the requirements. Key requirements checklist: - 10×20 grid, 7 standard tetrominoes (I, O, T, S, Z, J, L) - Gravity: pieces fall automatically, speed increases with lines cleared - Controls: ← → move, ↑ rotate, ↓ soft drop, Space hard drop, P pause - Prevent page scroll on arrows/space - Collision detection (walls, floor, blocks) - Line clear with rows above falling - HUD: score, lines cleared, next piece - Start screen with controls listed; start on click or any key - Game over when new piece can't spawn; message on page; restart with Enter or click - All messages on page, no alert/confirm/prompt - Sound optional — if present, must be off until toggled (I'll add a WebAudio synth with a mute toggle, default off, toggle with M key or click) - No localStorage/sessionStorage/IndexedDB/cookies - Page fits in 800×640 frame - All CSS/JS inline, no network requests - The word "Tetris" must not appear anywhere (including in comments!) Design direction: I want something distinctive and lively — a layered background, motion, micro-interactions, strong typographic contrast. Since this is a game, the game board itself should be the star, framed like an arcade cabinet. Aesthetic idea: retro-futuristic "Soviet arcade" / CRT arcade cabinet vibe? Or a vibrant neon arcade? Let me think about what's distinctive: the game was created in Moscow in 1984. I could do a "Moscow 1984" retro arcade aesthetic — deep space blue/teal with warm amber/cyan CRT glow, scanlines, chunky pixel-style blocks with bevel highlights. But I can't load external fonts. I can use system fonts creatively: a monospace stack for display ("Courier New", monospace) styled with letter-spacing and text-shadow for a chunky arcade feel, and a clean sans-serif for body. Or I could draw the title on canvas with custom pixel lettering... that's overkill. Better: use CSS with layered text-shadows to create a chunky retro display look with system fonts. Actually, for a distinctive display face without external fonts, I can use "Impact, 'Arial Black', sans-serif" for display and "'Courier New', monospace" for HUD numbers — that gives strong contrast. Or use SVG text? Keep it simple: CSS styling. Color palette: I want to avoid the generic near-black + single neon look. Let me go with a deep midnight blue/indigo background with multiple accent colors — the tetromino colors themselves provide the palette (cyan, yellow, purple, green, red, blue, orange). Background: deep navy-teal (#0a1420 range) with subtle grid/starfield, and the board framed with a glowing border. Add a subtle animated background — drifting particles/stars, or a slowly moving grid. Layout within 800×640: - Left panel: score, lines, level, next piece preview, sound toggle - Center: 10×20 board. At 24px cells that's 240×480. Plus frame. 640 height: header ~60px, board 480 + padding... let me calculate. Board 20 rows × 24px = 480px. Add top/bottom padding/frame ~16px each = ~512. Header/title area ~80px. Total ~600. Fits in 640. Actually let me use 26px cells: 260×520 — too tall with header. 24px cells: 240×480. Good. Layout: - Full page: 800×640, centered content. - Top: game title (small, distinctive) + status. - Main row: left sidebar (~180px), board (~260px with frame), right sidebar (~180px)? Or left sidebar with score/lines/level and right with next piece + controls reminder. Let me design: - Header bar: title "BLOCKFALL" or "TETRA BLOCKS" — wait, avoid "Tetris" but "Tetra" is fine? The word "Tetris" specifically is banned. "TETRIS" must not appear. I'll name it something like "BLOCK CASCADE" or "FALLING BLOCKS" or "TETRA-84" (homage to 1984). "TETRA '84" is a nice homage. Or "MOSCOW BLOCKS". Let me go with "TETRA-84" — evokes the origin without using the banned word. Actually "TETRIS" contains... no, "TETRA" is different. Safe. Hmm, but to be extra safe against substring matching, "TETRA" doesn't contain "TETRIS". Fine. - Board center with glowing frame, ghost piece (drop preview) — nice polish. - Left panel: SCORE (big number), LINES, LEVEL, and maybe a "best" (in-memory only). - Right panel: NEXT piece preview (canvas), controls list, sound toggle button. Overlays: start screen (with controls), pause screen, game over screen — all as absolutely positioned divs over the board, with animations. Visual polish ideas: 1. Canvas-rendered board with beveled 3D blocks (light top-left, dark bottom-right, inner glow). 2. Ghost piece (translucent outline at drop position). 3. Line clear animation: flash white, then rows collapse with particles. 4. Particle effects on hard drop and line clear. 5. Screen shake on hard drop / line clear. 6. Background: animated starfield/particles behind, subtle scanline overlay on board for CRT feel. 7. Next piece preview with rotation animation? At least a nice beveled render. 8. Score pop animation (scale bump on change). 9. Level up flash. 10. Sound: WebAudio synth — move blip, rotate, drop thud, line clear arpeggio, game over. Default muted; toggle with M or click button. Must stay off until turned on — so initialize AudioContext only on user gesture (which is fine since game starts on click/key anyway). Game mechanics: - 7-bag randomizer for fairness. - SRS-style rotation with basic wall kicks (try offsets: 0, -1, +1, -2, +2 horizontally; maybe -1 up for I piece). Keep it simple but responsive. - Gravity: level-based speed. Level = floor(lines/10). Speed curve: e.g., start at 800ms, decrease. Classic: (0.8 - (level-1)*0.007)^(level-1) seconds. I'll use a simpler table or formula: dropInterval = Math.max(80, 800 * Math.pow(0.85, level)). - Soft drop: 1 cell per keypress (or hold-to-repeat? Key repeat via keydown auto-repeat works but is OS-dependent; better to implement own repeat for smooth feel: on keydown start, then interval). Actually simpler: rely on keydown events (browser auto-repeat) for left/right/down, but that can feel laggy. For polish, implement DAS (delayed auto shift) manually: on keydown set direction, after 170ms delay repeat every 50ms. That gives pro feel. But must be careful with keyup. Let me implement a simple input system with held keys and timers in the game loop. Actually, to keep it robust: track pressed keys in a set. In update loop, for left/right: if held and DAS timer elapsed, move. For down: if held, apply gravity faster (soft drop interval ~50ms). This is cleaner than relying on key repeat. - Hard drop: space, instant, with particles and small shake. Score: 2 points per cell dropped (soft/hard drop bonuses are standard). - Scoring: 100/300/500/800 × (level+1) for 1/2/3/4 lines. Soft drop +1/cell, hard drop +2/cell. - Lock delay: small delay (~300-500ms) before locking when piece lands, allowing slide. This adds feel. But must be careful: if piece can still fall, reset delay. Implement: when piece can't move down, start lockTimer; if it moves horizontally successfully, reset timer (with max resets to prevent infinite spin? Keep simple: reset on move, cap resets at like 15, or just allow — fine for casual). Actually, lock delay adds complexity but massively improves feel. Let me implement a basic version: lockDelay = 400ms, resets on successful move/rotate, but only if piece is grounded. If piece falls (gravity moves it down), reset. - Pause: P toggles. Show overlay. Also auto-pause on blur? Not required; skip or add — adding blur pause is nice polish but might confuse judges if they click away. I'll add it: window blur → pause if playing. Actually that could be annoying in a judging frame... I'll skip auto-pause to avoid confusion, or include it? The requirement only says P pauses. I'll keep P only, but maybe pause on visibilitychange is good practice. Hmm, in an iframe, visibility changes happen when they switch tabs. I'll add it — it's standard and prevents unfair deaths. Actually, let me keep it simple and not auto-pause, to strictly match "P key pauses". But if the tab is hidden, requestAnimationFrame stops anyway, so the game effectively pauses (time-based gravity uses delta time... if I use delta time with rAF, hidden tab stops rAF, so no unfair fall). I'll use rAF with delta accumulation, and clamp delta to avoid jumps. Timing: use performance.now() delta, accumulate for gravity. Clamp dt to 100ms. - Game over: when new piece spawns overlapping. Show overlay with final score, "Press Enter or Click to restart". Enter or click restarts. - Start: overlay with title, controls list, "Click or press any key to start". Any key starts — but be careful: if they press P on start screen, should it start or pause? "The game starts when the player clicks the page or presses any key." So any key starts the game. After start, P pauses. - Prevent scroll: keydown handler for ArrowLeft/Right/Up/Down/Space calls preventDefault. Also Enter? Enter doesn't scroll but might trigger button focus — I'll preventDefault on handled keys and blur buttons. Sound design (WebAudio, all synthesized): - Master gain, muted flag default true. - Move: short square blip 200Hz, 0.03s - Rotate: 300Hz blip - Soft drop tick: quiet - Hard drop: noise burst / low thud - Lock: low thud - Line clear: rising arpeggio (C-E-G-C) - Tetris (4 lines): bigger fanfare - Level up: sweep - Game over: descending tones - Toggle: click Sound toggle: button in sidebar "SOUND: OFF/ON" + M key. Must stay off until turned on — default off, button shows state. Now visuals in detail: Page background: deep space gradient (radial from #101c2e to #05080f) with animated canvas starfield/particles behind everything? I can have a full-page background canvas with slowly drifting glowing dots (like distant stars / falling snow). That adds life. Keep it cheap: ~60 particles. Board frame: chunky border with glow — like an arcade bezel. Use CSS: border with gradient? Canvas draws the grid; frame is a div with box-shadow glow (cyan/amber dual glow?). Let me pick a palette: - Background: #070b14 deep navy - Board bg: #0b1220 with subtle grid lines #1a2740 - Frame: gradient border? Use box-shadow: 0 0 30px rgba(0,229,255,0.15), inset... - Accent colors: cyan #00e5ff, amber #ffb300, magenta? The tetromino colors: - I: #00e5ff (cyan) - O: #ffd500 (yellow) - T: #b44cff (purple) - S: #3ddc55 (green) - Z: #ff4d5a (red) - J: #4d7cff (blue) - L: #ff8c1a (orange) These are vibrant and distinguishable. Block rendering: each cell drawn with: - Base fill (color) - Top/left highlight (lighter) - Bottom/right shadow (darker) - Inner subtle gradient - Small inner glow / specular dot This gives classic beveled look. I'll write a drawBlock(ctx, x, y, size, color) function with computed light/dark shades. Grid lines: subtle. Ghost piece: outline with low-alpha fill of piece color, or dashed border. Line clear animation: when lines clear, mark them, play flash animation (~250ms) where those rows flash white and expand, then remove and shift with particles. During animation, game pauses briefly (piece lock waits). Implementation: state 'clearing' with timer; render flash; then apply removal. Particles: array of {x,y,vx,vy,life,color,size}. Spawn on hard drop (at landing cells), on line clear (across cleared rows), on lock (small dust). Update in loop, draw on board canvas (or separate overlay canvas — same canvas is fine, draw after blocks). Screen shake: on hard drop and tetris clear, offset board canvas transform for ~150ms with decaying random offset. Apply via CSS transform on board container or via canvas translate. Canvas translate is cleaner. Score display: big monospace numbers with glow; on change, scale bump via CSS class re-trigger. Next piece: small canvas 4×4 grid, draw piece centered with bevel. Level display: show current level and maybe a progress bar to next level (lines % 10). Header: title "TETRA-84" with layered shadow, subtitle "MOSCOW · 1984" small. That's distinctive and honors the origin. Wait — "the game that Alexey Pajitnov created in Moscow in 1984" — referencing Moscow 1984 in the title is a nice touch. Layout plan (800×640): ``` +--------------------------------------------------+ | TETRA-84 (header ~56px) | +--------------------------------------------------+ | [LEFT] [BOARD 240x480] [RIGHT] | | Score frame ~264x504 Next piece | | Lines Controls | | Level Sound | +--------------------------------------------------+ ``` Total height: header 56 + board area ~520 + margins = ~600. Good. Let me be precise: - body: 800×640, overflow hidden, display flex column, centered. - header: height ~64px. - main: flex row, gap 20px, centered, height remaining ~560px. - board wrapper: width 240 + frame padding 12*2 = 264; height 480 + 24 = 504. - side panels: width ~170px each. 264 + 170*2 + gaps 2*24 = 652. Fits in 800 with margins. Actually let me make cells 25px: 250×500 board. Frame padding 10 → 270×520. Header 60. Total 580 + padding. Fits 640 with ~30px top/bottom. Side panels 180px. 270+360+gaps(40) = 670. OK. Hmm, 24px cells → 240×480 is safer vertically. Let me go with 24px, board canvas 240×480, frame with 10px padding and border. Overlays: absolutely positioned over board wrapper, covering it, with backdrop blur? A dark overlay with slight blur is fine here. Use rgba(5,8,15,0.85) with border glow. Content: - Start: big title, "PRESS ANY KEY / CLICK", controls table (arrows, space, P, M). - Pause: "PAUSED" + "Press P to resume". - Game over: "GAME OVER", final score, lines, "ENTER or CLICK to restart". Animations: overlay fade/scale in; title letters maybe staggered? Keep CSS keyframes. Micro-interactions: - Buttons (sound) hover glow. - Score bump. - Board frame pulse on level up. - Next piece "pop" when it changes. Now code structure: ```html Tetra-84 (fixed, full page, starfield)
...
...
``` Wait, the page must fit in an 800×640 frame — so body should be exactly that or smaller, no scroll. Set html,body {width:800px; height:640px; margin:0; overflow:hidden}. If the frame is exactly 800×640, content fills it. Background canvas: position fixed, 800×640, z-index 0. App z-index 1. Game constants: - COLS=10, ROWS=20, CELL=24. - Pieces defined as rotation states or as matrices with rotation function. I'll define each piece as a matrix and rotate by transposing/reversing. Standard approach: ```js const SHAPES = { I: [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], J: [[1,0,0],[1,1,1],[0,0,0]], L: [[0,0,1],[1,1,1],[0,0,0]], O: [[1,1],[1,1]], S: [[0,1,1],[1,1,0],[0,0,0]], T: [[0,1,0],[1,1,1],[0,0,0]], Z: [[1,1,0],[0,1,1],[0,0,0]] }; ``` Rotation: rotate matrix clockwise: newM[x][N-1-y] = m[y][x]. Spawn: piece at column Math.floor((COLS - size)/2), row = -1 or 0? Spawn at top: row 0 (or -1 for I?). Standard: spawn in rows 0-1. I'll spawn with matrix top at row 0 (for I, the filled row is index 1, so it appears at row 1... hmm). Let me spawn at y = 0 and check collision; if collision at spawn → game over. For I piece, matrix row 1 is filled, so it spawns visually at row 1. That's fine. Actually to be safe with "cannot enter" detection, spawn at y=0; if collides, try y=-1? Classic behavior: game over when new piece overlaps. I'll spawn at y=0 (I at y=-1 so its filled row is at 0? Let me keep uniform: y=0 for all; I's visible row is 1). If collision → game over. Hmm, but with y=0 and I matrix, the piece occupies row 1. There's an empty row 0 above. That's standard enough. 7-bag: ```js function refillBag(){ bag = shuffle(['I','J','L','O','S','T','Z']); } function nextPiece(){ if(bag.length===0) refillBag(); return bag.pop(); } ``` Next preview: peek at bag (refill if empty, then peek last without popping? Need to be careful: nextType = bag.length ? bag[bag.length-1] : (refill, bag[bag.length-1]). Gravity update: ```js gravityAcc += dt; const interval = currentInterval(); // ms if (softDropHeld) interval = Math.min(interval, 45); if (gravityAcc >= interval) { gravityAcc = 0; tryMove(0,1) or lock; } ``` Lock logic: when piece can't move down: - If not grounded before, start lockTimer. - lockTimer accumulates dt; if > 400ms → lock. - On successful horizontal move or rotate while grounded: reset lockTimer (and increment resetCount; if resetCount > 15, force lock? Keep simple: allow reset but cap total grounded time? Simplest robust: reset on move, but if piece has been grounded for > 1.5s total, force lock. I'll track groundedTime.) Actually simpler and still feels good: lock delay 350ms, resets on move/rotate, max 10 resets then force lock. Let me implement resetCount. Collision function: ```js function collides(matrix, px, py){ for each cell: if matrix[y][x] { bx=px+x, by=py+y; if bx<0||bx>=COLS||by>=ROWS|| (by>=0 && grid[by][bx]) return true; } return false; } ``` Allow by<0 (above top) — but spawn is at 0 so rarely needed; rotation near top might push up? With my kick set (horizontal + down?), I'll allow negative y in collision (treat as empty) but rendering clips. Wall kicks: try offsets [[0,0],[-1,0],[1,0],[-2,0],[2,0],[0,-1],[-1,-1],[1,-1]]. For O piece, rotation is identity (skip). Hard drop: while !collides(m, x, y+1) y++; then lock immediately, spawn particles at bottom cells, shake, score += 2*distance. Line clear: ```js function clearLines(){ const full = []; for y: if grid[y] all filled → full.push(y); if none: return; state = 'clearing'; clearRows = full; clearTimer = 0; score += [0,100,300,500,800][n] * (level+1); lines += n; level = floor(lines/10); if level changed → levelUp effect; spawn particles for those rows; } ``` During 'clearing' state (~300ms): render flash (white overlay on those rows with alpha oscillation, maybe rows shrink). After timer, remove rows: ```js full.sort((a,b)=>a-b); for each row in full: grid.splice(row,1); grid.unshift(emptyRow); ``` Then state back to 'playing', spawn next piece. Wait — should the next piece spawn after clear completes? Yes, spawn after removal. Rendering loop (rAF): - Update background particles (separate canvas, always running). - If playing: update input (DAS), gravity, lock timer, clear animation. - Draw board: clear, draw grid, draw placed blocks, draw ghost, draw current piece, draw clear flash, draw particles, apply shake translate. - Update HUD DOM (only on change to avoid layout thrash — or every frame is fine for text? Better update on change). DAS input: ```js const keys = {left:false,right:false,down:false}; let dasDir=0, dasTimer=0, arrTimer=0; onkeydown: if left: keys.left=true; move(-1,0); dasDir=-1; dasTimer=0; arrTimer=0; update(dt): if dasDir!==0 { dasTimer+=dt; if(dasTimer>160){ arrTimer+=dt; while(arrTimer>45){arrTimer-=45; move(dasDir,0);} } } onkeyup: if left && dasDir===-1: dasDir = keys.right? 1:0; dasTimer=0; arrTimer=0; (if other key held, resume) ``` This gives instant first move + auto-repeat. Soft drop: keys.down → in update, if held, gravity interval becomes 40ms, and each successful gravity step scores +1. Rotation: on ArrowUp keydown (not held-repeat — use e.repeat check? If user holds up, browser repeats keydown; that's OK, rotation repeats. But with my DAS only for left/right. For up, allow keydown repeat via browser or ignore repeats? I'll allow repeat but with a small cooldown (e.g., 80ms) to prevent too-fast spin. Actually browser key repeat is ~30/s after delay; rotation with kicks is fine. I'll just handle keydown and ignore e.repeat for rotation? Many players hold up to spin. I'll allow it but throttle: if now - lastRotate < 90ms skip.) Space: hard drop on keydown (ignore repeat). P: toggle pause on keydown (ignore repeat). Enter: on game over → restart. On start screen → start (any key starts anyway). M: toggle sound. Any key starts: on start screen, keydown (any) → startGame(). But careful: if they press P... it starts the game, then P also pauses? Order: in keydown handler, if state==='start' → startGame(); return (don't process further). Good. Click starts: click on document → if start → start; if gameover → restart; if paused → resume? "The player can start a new game with the Enter key or a click" (game over). For pause, click to resume is nice. For start, click starts. I'll wire: document click → if start: start; if gameover: restart; if paused: resume. But sound button click should not also trigger start/restart — stopPropagation on button. Also, clicking should not cause issues with focus. Fine. Prevent scroll: in keydown, if key is in [ArrowLeft, ArrowRight, ArrowUp, ArrowDown, ' ', 'Spacebar'] → e.preventDefault(). Also prevent default for Enter? Not needed. Also, to prevent space from triggering a focused button (sound button), blur active element after click, and in keydown for space, preventDefault stops button activation? Button activation via space happens on keyup/keydown default — preventDefault on keydown prevents it. Also call btn.blur() after click. Sound implementation: ```js let audioCtx=null, soundOn=false; function ensureAudio(){ if(!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)(); } function beep(freq, dur, type='square', vol=0.15, slide=0){ if(!soundOn||!audioCtx) return; ... } ``` Toggle: soundOn = !soundOn; if turning on, ensureAudio() and resume; update button text. Since sound must stay off until turned on: default soundOn=false; only create context on toggle (user gesture) — good for autoplay policies. Now the "visually impressive" part — let me really push the rendering: Block draw function: ```js function drawBlock(ctx, px, py, size, color, alpha=1){ // px,py in pixels ctx.globalAlpha = alpha; // base ctx.fillStyle = color; ctx.fillRect(px+1, py+1, size-2, size-2); // top highlight ctx.fillStyle = lighten(color, 0.45); ctx.fillRect(px+1, py+1, size-2, 3); ctx.fillRect(px+1, py+1, 3, size-2); // bottom shadow ctx.fillStyle = darken(color, 0.45); ctx.fillRect(px+1, py+size-4, size-2, 3); ctx.fillRect(px+size-4, py+1, 3, size-2); // inner glow / center ctx.fillStyle = lighten(color, 0.2); ctx.fillRect(px+size*0.3, py+size*0.3, size*0.4, size*0.4); // maybe too much ctx.globalAlpha = 1; } ``` Better: use gradients. For each block: linear gradient from lighten to base; plus bevel edges; plus a small specular square top-left. And a subtle outer glow via shadowBlur? shadowBlur is expensive per block; with up to 200 blocks it might be OK but risky for perf. I'll skip shadowBlur on blocks; use it only for frame/ghost. Color helpers: parse hex → rgb, mix with white/black. Ghost: draw with strokeRect and low-alpha fill. Clear flash: for each clearing row, draw white rect with alpha = sin(timer*...), plus maybe horizontal wipe. Also draw the blocks in those rows with white overlay. Particles: small squares with velocity, gravity, fade. Colors from cleared blocks. Background canvas: stars drifting downward slowly (like snow) with parallax (2 layers), plus occasional shooting star? Keep subtle: 70 dots, varying size/alpha, slow fall, wrap around. Also a faint large radial glow behind board. Board frame CSS: ```css #boardWrap { position:relative; padding:10px; background: linear-gradient(...); border-radius: 8px; box-shadow: 0 0 0 2px #1e3a5f, 0 0 40px rgba(0,229,255,.12), inset 0 0 20px rgba(0,0,0,.6); } ``` Add corner accents? Maybe small decorative screws/LEDs via pseudo-elements. Scanline overlay on board: CSS repeating-linear-gradient overlay div with pointer-events none, low opacity. Adds CRT feel. Header design: left-aligned title with big display font, right side maybe "MOSCOW 1984" badge. Title: "TETRA·84" with cyan/amber dual shadow (chromatic aberration effect — very retro arcade). Typography: - Display: font-family: 'Arial Black', 'Impact', sans-serif; letter-spacing. - Numbers/HUD: 'Courier New', monospace bold — gives retro terminal feel. - Body/labels: system-ui? Mix: labels in monospace uppercase small with letter-spacing; values in big monospace. Actually for distinctive pairing without webfonts: display = Impact/Arial Black (condensed, punchy), body = "Segoe UI"/system sans for controls text, numbers = Courier New bold. That works. HUD panel styling: dark panel with left border accent (cyan), label small uppercase gray, value big white with glow. Score value in amber? Let me do: SCORE big amber (#ffd54a), LINES cyan, LEVEL purple? Keep consistent: values white with colored glow per panel. Level progress: thin bar under level showing (lines%10)/10. Next piece panel: canvas 100×80? Draw 4×4 area with CELL=20. Center piece. Controls list (right panel or start overlay): - ← → Move - ↑ Rotate - ↓ Soft drop - SPACE Hard drop - P Pause - M Sound Show on start overlay prominently, and small reminder in right panel. Now, state machine: - 'start': show start overlay; board renders empty grid (maybe with demo? No—keep empty, or render a decorative pattern? Empty grid with subtle grid lines is fine; maybe animate a slow "attract" falling piece in background? That's extra complexity; skip — instead the starfield provides life). - 'playing' - 'paused' - 'clearing' (sub-state of playing; I'll use a flag) - 'gameover' Let me also add a "flash" on level up: board frame border color pulses, and a "LEVEL 2" toast appears briefly over board. Toast system: div positioned over board, animated with CSS class, removed after timeout. Edge cases: - Rotation of O: no-op (matrix is 2×2, rotation is same). - I piece rotation within 4×4 matrix works with my generic rotate. - Spawn overlap → game over: check collides at spawn position; if true → gameover state, show overlay. - Restart: reset grid, score, lines, level, bag, particles, spawn first piece. Score display update: function setScore(v) updates DOM and triggers bump animation (remove/add class with void offsetWidth reflow). Now let me think about the frame fit precisely: - html,body: 800×640, overflow hidden. - #app: width 800, height 640, display flex, flex-direction column, padding: 14px 20px. - header: height ~54px, flex row space-between, align center. - main: flex 1, display flex, justify-content center, align-items center, gap 26px. - boardWrap: 240+2*12 padding = 264 wide; 480+24 = 504 tall. Plus border 2px → ~268×508. - main height: 640 - 28 (padding) - 54 (header) - ~10 gap = ~548. Board 508 fits with ~20px margin. Good. - Side panels: width 190px, height ~500px, flex column, gap 14px. Left panel content: SCORE (label + big value), LINES, LEVEL + progress bar, maybe BEST (session best in memory — nice touch, "BEST" that persists only in memory during page life). Right panel: NEXT (canvas 120×90), CONTROLS mini-list, SOUND button. Let me write the controls list with keycap styling: small kbd elements with border and shadow — nice micro-detail. Start overlay content: - "TETRA·84" big - "A falling-block puzzle · Moscow, 1984" - Controls grid with keycaps - "CLICK OR PRESS ANY KEY TO START" pulsing Game over overlay: - "GAME OVER" big red/amber - Final score, lines, level - "PRESS ENTER OR CLICK TO PLAY AGAIN" Pause overlay: - "PAUSED" - "PRESS P TO RESUME" All overlays: position absolute inset 0 within boardWrap, display flex center, background rgba(6,10,18,0.88), border-radius matching, z-index above canvas. Now let me write the actual code carefully. Piece colors: ```js const COLORS = { I:'#00e5ff', J:'#4d7cff', L:'#ff8c1a', O:'#ffd500', S:'#3ddc55', T:'#c44dff', Z:'#ff4d5a' }; ``` Matrix rotation (clockwise): ```js function rotateMatrix(m){ const N = m.length; const r = Array.from({length:N},()=>Array(N).fill(0)); for(let y=0;yr.slice()); const size = m.length; const x = Math.floor((COLS-size)/2); const y = type==='I' ? -1 : 0; // I spawns one row higher so visible row is 0? ... } ``` Hmm, for I with matrix row 1 filled, y=-1 puts filled row at 0. For others, filled rows are 0-1, y=0. Let me do: y = 0 for all, but for I use y=-1. Then collision check: if collides(m,x,y) → game over. With y=-1 for I, cells at row 0 — fine. Actually wait: with y=0 for J/L/S/T/Z, top filled row is row 0. Good. O is 2×2 at rows 0-1. Good. Ghost: compute dropY by copying y and incrementing while !collides. Rendering current piece: for each filled cell, if by>=0 draw block at (x*CELL + bx*CELL...). Use px = (piece.x + cx)*CELL, py = (piece.y + cy)*CELL. Grid storage: grid[row][col] = color string or null. Lock: ```js function lockPiece(){ for cells: if by>=0 grid[by][bx]=color; else → piece locked above top? If any cell has by<0 → game over (top out). Actually if locked with cells above visible area, that's a top-out → game over. spawn lock particles; checkLines(); spawnNext(); } ``` Standard: if piece locks entirely/any above row 0 → game over. I'll check: if any locked cell has by<0 → gameOver. checkLines sets clearing state; spawnNext happens after clear completes (or immediately if no lines). Flow: ```js function lockPiece(){ ...place cells... if (topOut) { gameOver(); return; } const rows = findFullRows(); if (rows.length){ startClear(rows); } else { spawnNext(); } } function startClear(rows){ clearing = {rows, t:0, dur:320}; score += ...; lines += n; updateLevel(); spawn particles; play sound; shake if n>=4; } // in update: if clearing: clearing.t += dt; if t>=dur: finishClear(); function finishClear(){ for each row (sorted asc): grid.splice(row,1); grid.unshift(new Array(COLS).fill(null)); clearing=null; spawnNext(); } ``` During clearing, current piece is null; don't render piece. Game over check on spawn: ```js function spawnNext(){ current = makePiece(nextType); if (collides(current.matrix, current.x, current.y)) { gameOver(); } updateNextPreview(); } ``` Wait, next preview should show the piece after current. So bag logic: `queue` array. drawFromBag pops; preview peeks. Let me structure: ```js let bag=[]; function bagRefill(){ bag = shuffle(['I','J','L','O','S','T','Z']); } function drawType(){ if(!bag.length) bagRefill(); return bag.pop(); } function peekType(){ if(!bag.length) bagRefill(); return bag[bag.length-1]; } ``` On spawn: current.type = drawType(); preview shows peekType(). Level/speed: ```js function dropInterval(){ return Math.max(70, 750 * Math.pow(0.82, level)); } ``` Level 0: 750ms, L1: 615, L2: 504, L3: 413, L5: ~280, L8: ~160, L10: ~107, floor 70. Good progression. Soft drop interval: 40ms. Lock delay: 380ms, max 12 resets. Now input details: ```js document.addEventListener('keydown', e=>{ const k = e.key; if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' '].includes(k)) e.preventDefault(); if(e.repeat && (k===' '||k==='p'||k==='P'||k==='m'||k==='M'||k==='Enter')) return; // allow repeat for arrows? We handle DAS ourselves for left/right; browser repeat for left/right would double-move. So for ArrowLeft/Right/Down we should ignore e.repeat since we do our own DAS/soft-drop. For ArrowUp, allow repeat? We throttle rotation; but browser repeat + our throttle is OK. Actually simpler: ignore e.repeat for all arrows; our DAS handles left/right; soft drop handled by held state; rotation — if we ignore repeat, holding up rotates once. Many players tap. But holding to spin is nice... I'll allow ArrowUp repeat with throttle. if(state==='start'){ startGame(); return; } if(state==='gameover'){ if(k==='Enter'){ restart(); } return; } ... }); ``` Wait: "The game starts when the player clicks the page or presses any key." — any key, including P, M, arrows. So in start state, any keydown → startGame(). But should we also process that key's action? E.g., pressing Left to start also moves? No — just start, return. Game over: Enter or click restarts. Other keys do nothing (except maybe sound toggle? Keep: only Enter/click restart; but M for sound could work — harmless. I'll allow M anywhere.) Pause: P toggles between playing/paused (only in those states). Let me also handle: if state==='paused' and P pressed → resume. Click on page while paused → resume (nice). Key handling for movement only when state==='playing' && !clearing? Movement should be allowed during clearing? No — current piece is null during clearing, so guard with current!==null. DAS update in loop: ```js function updateInput(dt){ if(!current) return; if(dasDir!==0){ dasTimer+=dt; if(dasTimer>=DAS_DELAY){ arrTimer+=dt; while(arrTimer>=ARR){ arrTimer-=ARR; tryMove(dasDir,0); } } } } ``` tryMove returns bool; play move sound only on success (throttle sound? Move sound every 45ms is spammy — play at low volume, or only on first + every other? I'll play a very quiet tick; or skip sound for auto-repeat moves. Let me play sound only when not from ARR loop... simpler: play move sound with very low volume 0.05; it's fine.) Actually, to keep audio clean: play move blip on manual keydown move; for ARR repeats, play softer/quieter or skip. I'll pass a flag. Soft drop: in gravity update: ```js const interval = keys.down ? Math.min(dropInterval(), 40) : dropInterval(); ``` And when gravity step succeeds moving down due to soft drop, score += 1. Gravity step: ```js gravityAcc += dt; while(gravityAcc >= interval){ gravityAcc -= interval; if(tryMove(0,1)){ if(keys.down) addScore(1); groundedTime=0; lockResets=0; } else { // grounded break; } } ``` Hmm, mixing while loop with lock delay... Let me restructure: ```js function updatePlaying(dt){ updateInput(dt); if(clearing){ ... } else if(current){ // gravity const iv = keys.down ? 40 : dropInterval(); gravityAcc += dt; if(gravityAcc >= iv){ gravityAcc = 0; if(tryMove(0,1)){ if(keys.down) addScore(1); } } // lock delay if(collides(current.matrix, current.x, current.y+1)){ grounded += dt; if(grounded >= LOCK_DELAY || lockResets>=MAX_RESETS) lockPiece(); } else { grounded = 0; } } } ``` tryMove: ```js function tryMove(dx,dy){ if(!current) return false; if(!collides(current.matrix, current.x+dx, current.y+dy)){ current.x+=dx; current.y+=dy; if(dx!==0){ grounded=0; if(groundedWasActive) lockResets++; } // hmm return true; } return false; } ``` Lock reset logic: when grounded (can't move down) and player successfully moves/rotates, reset grounded timer but count resets. Let me track: `grounded` accumulates only when can't fall. On successful move/rotate: if wasGrounded (i.e., collides down is true now or was true) → grounded=0; lockResets++; else grounded=0 anyway. Simplify: ```js function onSuccessfulMove(){ if(collides(current.matrix,current.x,current.y+1)){ // still grounded after move if(grounded>0){ lockResets++; grounded=0; } } else grounded=0; } ``` And in update: if can't fall: grounded+=dt; if grounded>=LOCK_DELAY || lockResets>MAX → lock. MAX_RESETS = 15. Rotation: ```js function rotate(){ if(!current || current.type==='O') {play rotate?; return;} const r = rotateMatrix(current.matrix); const kicks = [[0,0],[-1,0],[1,0],[0,-1],[-1,-1],[1,-1],[-2,0],[2,0]]; for(const [kx,ky] of kicks){ if(!collides(r, current.x+kx, current.y+ky)){ current.matrix=r; current.x+=kx; current.y+=ky; onSuccessfulMove(); playSound('rotate'); return; } } // failed: small shake? play fail sound? } ``` Hard drop: ```js function hardDrop(){ if(!current) return; let d=0; while(!collides(current.matrix,current.x,current.y+1)){current.y++;d++;} addScore(d*2); shake = 6; // pixels spawnDropParticles(); playSound('harddrop'); lockPiece(); } ``` Now rendering with shake: ```js function drawBoard(){ ctx.save(); if(shake>0){ ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake); shake *= 0.85; if(shake<0.3) shake=0; } // bg ctx.fillStyle='#0a1120'; ctx.fillRect(-10,-10,W+20,H+20); // grid lines ... // blocks for y,x: if grid[y][x] drawBlock // clearing flash if(clearing){ for row: alpha = ...; ctx.fillStyle=`rgba(255,255,255,${a})`; fillRect(0,row*CELL,W,CELL); } // ghost if(current && !clearing){ compute gy; draw ghost } // current if(current && !clearing){ draw blocks } // particles ... ctx.restore(); } ``` Ghost draw: for each cell at drop position, stroke rect with color alpha 0.35, fill alpha 0.08. Particles update in main loop with dt. Background stars: separate canvas #bg, own update in same rAF. HUD updates: call updateHUD() on score/lines/level change. Score bump: ```js function bump(el){ el.classList.remove('bump'); void el.offsetWidth; el.classList.add('bump'); } ``` CSS: .bump { animation: bump 0.25s ease; } @keyframes bump { 0%{transform:scale(1)} 40%{transform:scale(1.18)} 100%{transform:scale(1)} } Level up toast: ```js function toast(msg){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.remove('show'); void t.offsetWidth; t.classList.add('show'); } ``` CSS animation: fade in, rise, fade out over 1.2s. Next preview render: ```js function drawNext(){ nctx.clearRect(...); const type = peekType(); const m = SHAPES[type]; // compute bounding box of filled cells to center ... draw each cell with CELL_N=20 using drawBlock } ``` Sound button: ```html ``` Click: toggleSound(); e.stopPropagation(). Also M key toggles. Now, "Show every message on the page itself" — start controls, pause, game over, all in overlays. Good. "No localStorage" — I'll keep a session best score in a JS variable only. Let me also double-check the banned word: "Tetris" — I must not write it in comments or strings. I'll use "Tetra-84" and "block puzzle". Also avoid "tetris" lowercase in code comments. Careful with words like "tetromino" — that contains "tet" but not "tetris". "Tetromino" is T-E-T-R-O-M-I-N-O — does not contain "TETRIS". Safe. But to be extra safe I'll call them "pieces" or "blocks". Now let me write the CSS with attention to the 800×640 fit and polish. Font stacks: - Display: `Impact, 'Arial Black', 'Franklin Gothic Bold', sans-serif` - Mono/numbers: `'Courier New', Courier, monospace` - UI text: `'Segoe UI', 'Helvetica Neue', Arial, sans-serif` Header: ```html
MOSCOW · 1984 · BLOCK PUZZLE
``` Logo styling: font-size 34px, letter-spacing 2px, color #fff, text-shadow: 3px 3px 0 #ff4d5a? Chromatic: text-shadow: -2px 0 rgba(0,229,255,.8), 2px 0 rgba(255,77,90,.8), 0 0 18px rgba(0,229,255,.4). The "·84" in amber. Panels: ```css .panel { background: rgba(13,22,38,0.85); border:1px solid #1e3350; border-left:3px solid var(--accent); border-radius:6px; padding:12px 14px; box-shadow: 0 4px 18px rgba(0,0,0,.4); } ``` Label: font 10px, letter-spacing 2px, color #6b87a8, uppercase. Value: font 'Courier New' bold 28px, color #fff, text-shadow glow. Board frame: ```css #boardWrap{ position:relative; padding:10px; background:#0d1526; border-radius:10px; border:2px solid #23405f; box-shadow: 0 0 0 1px #0a1120, 0 0 34px rgba(0,229,255,.14), 0 10px 40px rgba(0,0,0,.5), inset 0 0 24px rgba(0,0,0,.55); } ``` Add scanlines: ```css #boardWrap::after{ content:''; position:absolute; inset:10px; pointer-events:none; background: repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 3px); border-radius:4px; } ``` And a subtle vignette. Corner LEDs: maybe ::before with small dots? Could add 4 corner accents via extra divs... keep with box-shadow glow, plus animated pulse on level up via class. Overlay: ```css .overlay{ position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; background:rgba(6,10,20,.9); border-radius:8px; z-index:5; text-align:center; } .overlay.hidden{ display:none; } ``` Start overlay has controls grid: ```html
←→ Move
↑ Rotate
↓ Soft drop
SPACE Hard drop
P Pause
M Sound
``` kbd styling: inline-block, min-width 22px, padding 3px 6px, background #16233a, border 1px solid #2c4a72, border-bottom-width 3px, border-radius 4px, font mono 11px, color #cfe3ff. "Press any key" pulsing: animation pulse 1.4s infinite (opacity .5→1). Game over overlay: big "GAME OVER" in red with glow, stats row, restart hint pulsing. Now the JS in full. Let me write it out mentally and check for bugs. ```js const COLS=10, ROWS=20, CELL=24; const boardCanvas=document.getElementById('board'); const ctx=boardCanvas.getContext('2d'); boardCanvas.width=COLS*CELL; boardCanvas.height=ROWS*CELL; ``` State variables: ```js let grid, current, bag, score, lines, level, best=0; let state='start'; // start, playing, paused, gameover let clearing=null; // {rows,t,dur} let gravityAcc=0, grounded=0, lockResets=0; let shake=0; let particles=[]; let stars=[]; let soundOn=false, audioCtx=null; ``` Init grid: `Array.from({length:ROWS},()=>Array(COLS).fill(null))`. Main loop: ```js let last=performance.now(); function loop(now){ let dt=now-last; last=now; if(dt>100) dt=100; updateBg(dt); if(state==='playing') updatePlaying(dt); drawBoard(); drawBg(); requestAnimationFrame(loop); } requestAnimationFrame(loop); ``` Note: drawBoard always runs (shows empty grid behind start overlay — nice, overlay is semi-transparent so grid shows through). updatePlaying: ```js function updatePlaying(dt){ updateInput(dt); if(clearing){ clearing.t+=dt; if(clearing.t>=clearing.dur) finishClear(); updateParticles(dt); return; } if(!current) return; const iv = keys.down ? 40 : dropInterval(); gravityAcc+=dt; if(gravityAcc>=iv){ gravityAcc=0; if(tryMove(0,1)){ if(keys.down) addScore(1,false); } } if(collides(current.matrix,current.x,current.y+1)){ grounded+=dt; if(grounded>=LOCK_DELAY || lockResets>=MAX_RESETS) lockPiece(); } else grounded=0; updateParticles(dt); } ``` Wait — if tryMove(0,1) succeeds, grounded should reset (it does via else branch next frame since now can fall... actually after moving down, check happens same frame? The check is after; if moved down and still can't fall (landed), grounded starts accumulating. Good.) updateInput with DAS: ```js const keys={left:false,right:false,down:false}; let dasDir=0, dasTimer=0, arrTimer=0; const DAS=150, ARR=45; function updateInput(dt){ if(dasDir!==0){ dasTimer+=dt; if(dasTimer>=DAS){ arrTimer+=dt; while(arrTimer>=ARR){ arrTimer-=ARR; tryMove(dasDir,0); } } } } ``` Keydown: ```js window.addEventListener('keydown', e=>{ const k=e.key; if(k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'||k==='ArrowDown'||k===' ') e.preventDefault(); if(k==='m'||k==='M'){ toggleSound(); return; } if(state==='start'){ startGame(); return; } if(state==='gameover'){ if(k==='Enter') restart(); return; } if(k==='p'||k==='P'){ togglePause(); return; } if(state!=='playing') return; if(e.repeat && k!=='ArrowUp') return; // we handle L/R/D ourselves; allow up repeat switch(k){ case 'ArrowLeft': if(!e.repeat){ keys.left=true; dasDir=-1; dasTimer=0; arrTimer=0; tryMove(-1,0);} break; case 'ArrowRight': if(!e.repeat){ keys.right=true; dasDir=1; dasTimer=0; arrTimer=0; tryMove(1,0);} break; case 'ArrowDown': keys.down=true; break; case 'ArrowUp': rotatePiece(); break; case ' ': hardDrop(); break; } }); ``` Wait: for ArrowLeft with e.repeat — I return early for repeats except ArrowUp, so the `if(!e.repeat)` is redundant but safe. Hmm, one issue: if user holds Left then presses Right: keydown Right sets dasDir=1. Good. On keyup Left: if dasDir===-1 and keys.right → dasDir=1, reset timers. Keyup: ```js window.addEventListener('keyup', e=>{ switch(e.key){ case 'ArrowLeft': keys.left=false; if(dasDir===-1){ dasDir=keys.right?1:0; dasTimer=0; arrTimer=0;} break; case 'ArrowRight': keys.right=false; if(dasDir===1){ dasDir=keys.left?-1:0; dasTimer=0; arrTimer=0;} break; case 'ArrowDown': keys.down=false; break; } }); ``` Rotation throttle: ```js let lastRotate=0; function rotatePiece(){ const now=performance.now(); if(now-lastRotate<90) return; lastRotate=now; ... } ``` Click handling: ```js document.addEventListener('click', e=>{ if(e.target.closest('#soundBtn')) return; if(state==='start') startGame(); else if(state==='gameover') restart(); else if(state==='paused') togglePause(); }); ``` startGame / restart: ```js function startGame(){ resetGame(); state='playing'; hideOverlays(); playSound('start'); } function resetGame(){ grid=emptyGrid(); bag=[]; score=0; lines=0; level=0; clearing=null; particles=[]; shake=0; gravityAcc=0; grounded=0; lockResets=0; spawnNext(); updateHUD(); drawNext(); } ``` restart = startGame essentially (from gameover). gameOver: ```js function gameOver(){ state='gameover'; if(score>best) best=score; showOverlay('over'); updateHUD(); playSound('gameover'); // maybe explosion particles across board? } ``` Pause: ```js function togglePause(){ if(state==='playing'){ state='paused'; showOverlay('pause'); } else if(state==='paused'){ state='playing'; hideOverlays(); } } ``` Overlays: three divs #ovStart, #ovPause, #ovOver; showOverlay(name) hides others and shows one; hideOverlays hides all. Score: ```js function addScore(n, bumpIt=true){ score+=n; scoreEl.textContent=score; if(bumpIt) bump(scoreEl); } ``` Actually bump on every soft-drop point is too much; only bump on line clears and hard drops. I'll pass flag. Lines/level: ```js function addLines(n){ lines+=n; const newLevel=Math.floor(lines/10); linesEl.textContent=lines; bump(linesEl); if(newLevel>level){ level=newLevel; levelEl.textContent=level; bump(levelEl); toast('LEVEL '+level); playSound('levelup'); framePulse(); } updateProgress(); } ``` Progress bar: width = (lines%10)*10 + '%'. Now drawBlock with color shading: ```js function shade(hex, amt){ // amt -1..1 const n=parseInt(hex.slice(1),16); let r=(n>>16)&255,g=(n>>8)&255,b=n&255; if(amt>=0){ r+= (255-r)*amt; g+=(255-g)*amt; b+=(255-b)*amt; } else { r*=1+amt; g*=1+amt; b*=1+amt; } return `rgb(${r|0},${g|0},${b|0})`; } function drawBlock(c, px, py, s, color, alpha=1){ c.globalAlpha=alpha; // body gradient const g=c.createLinearGradient(px,py,px,py+s); g.addColorStop(0, shade(color,0.25)); g.addColorStop(1, shade(color,-0.15)); c.fillStyle=g; c.fillRect(px+1,py+1,s-2,s-2); // bevel top/left c.fillStyle=shade(color,0.55); c.fillRect(px+1,py+1,s-2,2); c.fillRect(px+1,py+1,2,s-2); // bevel bottom/right c.fillStyle=shade(color,-0.45); c.fillRect(px+1,py+s-3,s-2,2); c.fillRect(px+s-3,py+1,2,s-2); // specular c.fillStyle='rgba(255,255,255,0.35)'; c.fillRect(px+4,py+4,Math.max(2,s*0.18),Math.max(2,s*0.18)); c.globalAlpha=1; } ``` That should look chunky and nice at 24px. Ghost: ```js function drawGhost(){ let gy=current.y; while(!collides(current.matrix,current.x,gy+1)) gy++; if(gy===current.y) return; // no drop distance? still draw? skip if same const color=COLORS[current.type]; for cells: c.strokeStyle=color; c.globalAlpha=0.4; c.strokeRect(px+2,py+2,s-4,s-4); c.globalAlpha=0.08; c.fillStyle=color; c.fillRect(px+2,py+2,s-4,s-4); } ``` Clear flash rendering: during clearing, for each row in clearing.rows: ```js const p = clearing.t/clearing.dur; const a = Math.sin(p*Math.PI); // fade in/out ctx.fillStyle=`rgba(255,255,255,${0.85*a})`; ctx.fillRect(0,row*CELL,W,CELL); ``` Also maybe draw expanding horizontal lines. And the blocks in those rows are still drawn (they're in grid) — the white flash covers them. Good. Particles: ```js function spawnParticles(x,y,color,n,spread){ for(ip.life>0); } function drawParticles(){ for(p){ ctx.globalAlpha=Math.max(0,p.life); ctx.fillStyle=p.color; ctx.fillRect(p.x,p.y,p.size,p.size); } ctx.globalAlpha=1; } ``` Line clear particles: for each cleared row, sample block colors across columns, spawn a few per column. Hard drop particles: at each bottom cell of piece, spawn small dust in piece color. Background stars: ```js function initStars(){ stars=[]; for(i<80){ stars.push({x:Math.random()*800,y:Math.random()*640,r:Math.random()*1.5+0.4,s:Math.random()*0.3+0.08,a:Math.random()*0.5+0.2, hue: Math.random()<0.3?'#ffd54a':'#7fd4ff'}); } } function updateBg(dt){ const f=dt/16.67; for(s of stars){ s.y+=s.s*f; if(s.y>640){s.y=-2;s.x=Math.random()*800;} } } function drawBg(){ bctx.clearRect(0,0,800,640); for(s){ bctx.globalAlpha=s.a; bctx.fillStyle=s.hue; bctx.fillRect(s.x,s.y,s.r,s.r*2); } bctx.globalAlpha=1; } ``` Also draw a big soft radial glow behind board center on bg canvas: ```js const rg=bctx.createRadialGradient(400,330,50,400,330,420); rg.addColorStop(0,'rgba(0,120,180,0.10)'); rg.addColorStop(1,'rgba(0,0,0,0)'); ``` Draw once per frame (cheap). Sound synth: ```js function toggleSound(){ soundOn=!soundOn; soundBtn.textContent='SOUND: '+(soundOn?'ON':'OFF'); soundBtn.classList.toggle('on',soundOn); if(soundOn){ ensureAudio(); playSound('toggle'); } } function ensureAudio(){ if(!audioCtx){ try{ audioCtx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(audioCtx&&audioCtx.state==='suspended') audioCtx.resume(); } function tone(freq,dur,type,vol,delay=0,slideTo=null){ if(!soundOn||!audioCtx) return; const t=audioCtx.currentTime+delay; const o=audioCtx.createOscillator(), g=audioCtx.createGain(); o.type=type||'square'; o.frequency.setValueAtTime(freq,t); if(slideTo) o.frequency.exponentialRampToValueAtTime(slideTo,t+dur); g.gain.setValueAtTime(vol||0.12,t); g.gain.exponentialRampToValueAtTime(0.001,t+dur); o.connect(g); g.connect(audioCtx.destination); o.start(t); o.stop(t+dur+0.02); } function playSound(name){ switch(name){ case 'move': tone(220,0.04,'square',0.05); break; case 'rotate': tone(330,0.06,'square',0.08); break; case 'harddrop': tone(90,0.12,'triangle',0.25,0,40); break; case 'lock': tone(140,0.08,'triangle',0.15); break; case 'clear': tone(523,0.09,'square',0.12); tone(659,0.09,'square',0.12,0.07); tone(784,0.12,'square',0.12,0.14); break; case 'tetris': [523,659,784,1047,1319].forEach((f,i)=>tone(f,0.12,'square',0.14,i*0.07)); break; case 'levelup': tone(400,0.25,'sawtooth',0.1,0,900); break; case 'gameover': [392,330,262,196].forEach((f,i)=>tone(f,0.22,'triangle',0.16,i*0.18)); break; case 'start': tone(440,0.08,'square',0.1); tone(660,0.1,'square',0.1,0.08); break; case 'toggle': tone(880,0.05,'square',0.08); break; } } ``` Move sound on ARR repeats: I'll call playSound('move') inside tryMove only when manual. Let me have tryMove(dx,dy,silent). Manual calls pass silent=false. Actually simpler: tryMove returns bool; caller plays sound. In updateInput ARR loop, call tryMove without sound. Now HUD DOM structure: Left panel: ```html ``` Level display: show level+1? Level starts at 0 internally; display level+1? Classic shows level 1 at start. I'll display level (0-based) + 1? Hmm, "speed up as player clears more lines" — display "Level" as floor(lines/10)+1 so it starts at 1. Internally use lvl for speed. Let me keep `level` 0-based for speed calc, display level+1. Right panel: ```html ``` Next canvas: 120×80, draw piece centered with cell 22. drawNext: ```js function drawNext(){ const c=nctx; c.clearRect(0,0,120,80); const type=peekType(); const m=SHAPES[type]; // bounding box let minx=9,maxx=-1,miny=9,maxy=-1; for(y..)for(x..) if(m[y][x]){...} const w=(maxx-minx+1)*22, h=(maxy-miny+1)*22; const ox=(120-w)/2 - minx*22, oy=(80-h)/2 - miny*22; for cells: drawBlock(c, ox+x*22, oy+y*22, 22, COLORS[type]); } ``` Board draw order and grid lines: ```js ctx.strokeStyle='rgba(70,110,160,0.12)'; for x 1..9: line; for y 1..19: line; ``` Also draw subtle column highlight? Skip. Frame pulse on level up: ```css #boardWrap.pulse{ animation: framePulse 0.6s ease; } @keyframes framePulse{ 0%{box-shadow: ...normal} 30%{ box-shadow: 0 0 60px rgba(255,213,74,.5), ...} 100%{normal} } ``` Simpler: add class, remove on animationend. Toast: ```css #toast{ position:absolute; top:38%; left:50%; transform:translate(-50%,-50%); font-family:Impact...; font-size:30px; color:#ffd54a; text-shadow:0 0 20px rgba(255,213,74,.8); opacity:0; pointer-events:none; z-index:6; } #toast.show{ animation: toastAnim 1.3s ease forwards; } @keyframes toastAnim{ 0%{opacity:0; transform:translate(-50%,-30%) scale(.7)} 20%{opacity:1; transform:translate(-50%,-50%) scale(1.1)} 40%{transform:translate(-50%,-50%) scale(1)} 80%{opacity:1} 100%{opacity:0; transform:translate(-50%,-70%) scale(1)} } ``` Game over overlay stats: show final score and lines with ids updated in gameOver. Now, checking "Pieces fall on their own and speed up as the player clears more lines" — yes, dropInterval depends on level from lines. "Show the score, the number of cleared lines and the next piece" — yes. "Before the game starts, show the controls on the page" — start overlay lists them; also right panel always shows. "Arrow keys and space must not scroll" — preventDefault. Also body overflow hidden. "Full row clears, rows above move down" — yes. "Game over when new piece cannot enter" — spawn collision check. "Enter or click to restart" — yes. "Sound off until turned on" — default off, button + M. "No storage" — correct. "800×640 frame" — body fixed size, overflow hidden. "All inline, no external" — yes. "No word Tetris" — I'll grep mentally: title "TETRA·84", comments avoid it. One more consideration: the page might be loaded in an iframe; keyboard events need focus. Clicking the page gives focus to body/document — keydown on window works if iframe is focused. Since start requires click or key, and click focuses, that's fine. I'll also add `tabindex`? Not needed; window listener catches if document has focus. To be safe, on click I can call `document.body.focus()`? Body isn't focusable by default but window keydown works regardless of focus within the frame as long as the frame itself is focused. Clicking inside the frame focuses it. Good. Also prevent context menu? Not necessary. Let me also handle the case where the user presses a key while start overlay is showing — startGame is called; but that same keydown might also be in the "playing" branch? No — I return after startGame. Edge: pressing Enter on start screen starts game (any key). Fine. Now, potential bug: in keydown, I check `if(state==='start'){ startGame(); return; }` before checking P/M. But M should toggle sound even on start screen? I put M check before state checks — good, sound toggle works everywhere. Also 'Enter' on start: starts game (any key). Good. Let me also make sure hard drop during clearing does nothing (current is null → guard). Lock piece top-out check: ```js function lockPiece(){ let topOut=false; const m=current.matrix; for(y..)for(x..) if(m[y][x]){ const by=current.y+y, bx=current.x+x; if(by<0){ topOut=true; } else grid[by][bx]=COLORS[current.type]; } playSound('lock'); if(topOut){ current=null; gameOver(); return; } // particles at locked cells (small) ... const rows=findFull(); current=null; if(rows.length) startClear(rows); else spawnNext(); } ``` findFull: ```js function findFull(){ const r=[]; for(let y=0;y{ for(let x=0;x=4){ shake=10; playSound('tetris'); toast(n===4?'TETRIS!':'AMAZING'); } else { shake=4; playSound('clear'); } } ``` Wait — "TETRIS!" contains the banned word! I must not use it. Use "PERFECT!" or "4 LINES!" or "QUAD!". I'll use "PERFECT!". Also careful: the word must not appear "anywhere in the file" — including in that toast. Use "PERFECT!". finishClear: ```js function finishClear(){ const rows=clearing.rows.slice().sort((a,b)=>a-b); rows.forEach(r=>{ grid.splice(r,1); grid.unshift(Array(COLS).fill(null)); }); clearing=null; spawnNext(); } ``` spawnNext: ```js function spawnNext(){ const type=drawType(); const m=SHAPES[type].map(r=>r.slice()); const size=m.length; const x=Math.floor((COLS-size)/2); const y=(type==='I')?-1:0; current={type,matrix:m,x,y}; gravityAcc=0; grounded=0; lockResets=0; if(collides(m,x,y)){ current=null; gameOver(); return; } drawNext(); } ``` Hmm wait: for I with y=-1, the filled row (index 1) is at row 0. Collision check with y=-1: cells at row 0 — if occupied → game over. Good. But rendering: piece at y=-1 draws cells at row 0 (visible) — fine, cells with by<0 are skipped in render. collides allows by<0 as empty: ```js function collides(m,px,py){ for(let y=0;y=COLS||by>=ROWS) return true; if(by>=0 && grid[by][bx]) return true; } return false; } ``` Ghost with y=-1 start: gy starts at current.y (-1), increments... fine. Now updateHUD: ```js function updateHUD(){ scoreEl.textContent=score; linesEl.textContent=lines; levelEl.textContent=level+1; bestEl.textContent=best; progEl.style.width=((lines%10)*10)+'%'; } ``` Call on changes; also update best display on game over. Actually addScore updates scoreEl directly; updateHUD for full sync. Let me also add a subtle "danger" indicator: when stack is high (within 3 rows of top), board frame glows red? Nice polish: ```js // in drawBoard or update: check max height ``` Compute topmost filled row; if <=2, add class 'danger' to boardWrap → red glow pulse. Cheap and adds tension. I'll compute in updatePlaying and toggle class. Now let me also think about the start overlay design — it should be impressive. Big title with chromatic shadow, a row of 7 colored blocks animating (CSS squares bouncing?), controls grid, pulsing prompt. I can render 7 mini blocks as divs with the piece colors, animated with staggered bounce. That's a nice living element. ```html
... 7 spans
``` CSS: span { width:18px;height:18px;background:var(--c); box-shadow:0 0 12px var(--c); animation: bounce 1.2s infinite; animation-delay: calc(var(--i)*0.1s); } Set --i inline. Also the board behind the overlay shows the empty grid — with semi-transparent overlay (0.88) it's mostly hidden; maybe make overlay 0.82 so grid faintly shows. Fine. Pause overlay: big "PAUSED" with letter-spacing, subtle. Let me now also verify vertical space for side panels: 4 panels left (~70px each + gaps) ≈ 320px — fits in 500. Right: next (110) + controls (~150) + button (~40) ≈ 320. Good. Header height: logo 34px + padding. Let me set header padding 10px 0. Total check: body padding 12px top/bottom → 616 available. Header ~52. Main ~550. Board wrap 508. OK, tight but fits. Let me reduce board frame padding to 8px: 240+16=256 wide, 480+16=496 tall + border 4 = 500. Safer. Actually let me compute exactly: - body: 800×640, box-sizing border-box, padding: 10px 16px. - #app: height 100%, display flex column. - header: flex none, height 48px, display flex, align-items center, justify-content space-between. - main: flex 1, display flex, align-items center, justify-content center, gap 22px. - boardWrap: padding 8px, border 2px → canvas 240×480 + 16 + 4 = 260×500. - main available height: 640 - 20 (body padding) - 48 (header) - ~8 (margin) = ~564. Board 500 fits with 32px spare. Side panels width: 185px each. Total: 260 + 185*2 + 22*2 = 674 < 768 (800-32). Good, centered. Now let me write the final HTML. I'll be careful with: - No "Tetris" string. - All ids consistent. - Test logic mentally. One more mechanic detail: soft drop scoring — +1 per cell. Hard drop +2 per cell. Line scores ×(level+1). Also "speed up as player clears more lines" — level = floor(lines/10). Let me write the rotation kick list including [0,-1] for floor kicks? [0,-1] is up kick (wall kick up). Include [0,1]? Down kick rarely needed. My list: [[0,0],[-1,0],[1,0],[0,-1],[-2,0],[2,0],[-1,-1],[1,-1]]. Good enough. For I piece (4×4), kicks of ±2 help. Now, DAS: initial move happens on keydown immediately. Good feel. One concern: `e.repeat` for ArrowUp — I allow it (don't return). But I also have throttle 90ms. Browser repeat rate is usually ~33ms after 500ms delay, so throttle makes it ~11 rotations/sec — fast but OK. Actually maybe 100ms throttle. Also, when state is 'paused', arrow keys should do nothing — my handler returns after pause check? Let me trace: keydown → preventDefault for arrows/space (always, good) → M check → start check → gameover check → P check → `if(state!=='playing') return;` → then switch. So in paused state, arrows hit `state!=='playing'` and return. Good. But wait, P check comes before that, so P works in paused. Good. Space in paused: hits state!=='playing' return — but preventDefault already done. Good. Now the click handler: clicking sound button — I check closest('#soundBtn') and return. But the button click also bubbles to document — I return early so no start/restart. Good. Also after clicking button, blur it so space doesn't re-trigger: in toggleSound, `soundBtn.blur()`. Also, clicking anywhere on start overlay starts — yes, document click. Let me also ensure that when game over happens, the overlay shows and Enter works. In keydown: state==='gameover' → if Enter restart. Click → restart. Good. Restart should also work from gameover via any key? Requirement says Enter or click. I'll stick to Enter/click only (plus M for sound). Now writing drawBoard fully: ```js function drawBoard(){ const W=COLS*CELL, H=ROWS*CELL; ctx.save(); if(shake>0.3){ ctx.translate((Math.random()-0.5)*shake,(Math.random()-0.5)*shake); shake*=0.86; } else shake=0; // background ctx.fillStyle='#0a1322'; ctx.fillRect(-8,-8,W+16,H+16); // subtle vertical gradient const bg=ctx.createLinearGradient(0,0,0,H); bg.addColorStop(0,'rgba(20,40,70,0.25)'); bg.addColorStop(1,'rgba(0,0,0,0)'); ctx.fillStyle=bg; ctx.fillRect(0,0,W,H); // grid ctx.strokeStyle='rgba(90,140,200,0.10)'; ctx.lineWidth=1; ctx.beginPath(); for(let x=1;x{ ctx.fillStyle=`rgba(255,255,255,${0.9*a})`; ctx.fillRect(0,r*CELL,W,CELL); // edge glow lines ctx.fillStyle=`rgba(255,255,255,${0.5*a})`; ctx.fillRect(0,r*CELL-2,W,2); ctx.fillRect(0,(r+1)*CELL,W,2); }); } // ghost + current if(current && !clearing){ drawGhost(); const col=COLORS[current.type]; for(let y=0;y
TETRA·84
A FALLING-BLOCK PUZZLE · MOSCOW 1984
←→Move
↑Rotate
↓Soft drop
SPACEHard drop
PPause
MSound
CLICK OR PRESS ANY KEY TO START
``` Game over: ```html ``` Pause: ```html ``` CSS for overlay title: font Impact 40px, letter-spacing 4px. Now, one subtle thing: the start overlay covers the board; the board canvas behind shows empty grid. The overlay background rgba(6,10,20,0.88) — grid barely visible. That's fine. Also I want the board frame to have small "screws" or corner brackets for arcade feel. I can add via CSS pseudo-elements on boardWrap: two corners with borders. Let me add corner brackets: ```css #boardWrap::before{ content:''; position:absolute; top:-2px; left:-2px; width:18px; height:18px; border-top:3px solid #00e5ff; border-left:3px solid #00e5ff; border-radius:8px 0 0 0; } ``` And ::after for bottom-right? But I used ::after for scanlines. I can put scanlines on a separate div inside boardWrap. Let me restructure: boardWrap contains canvas, scanlines div, toast, overlays. Use ::before/::after for corner brackets (top-left cyan, bottom-right amber). ```css #boardWrap::before{ /* top-left */ } #boardWrap::after{ /* bottom-right */ } .scanlines{ position:absolute; inset:8px; pointer-events:none; background:repeating-linear-gradient(...); z-index:2; } ``` Overlays z-index 5, toast 6. Good. Now let me also double-check the "frame 800px wide and 640px high" — the whole page must fit. I'll set: ```css html,body{ width:800px; height:640px; margin:0; padding:0; overflow:hidden; } ``` If the judging frame is exactly 800×640, perfect. If larger, content stays 800×640 top-left? Better center: body display flex? If body is exactly 800×640 and frame is same, no issue. I'll keep body exactly sized; background canvas fixed 800×640. Actually, to be safe if frame is slightly different, I could center with margin auto, but fixed size is what's asked. Keep fixed. Font sizes: ensure everything fits. Let me now also think about performance: drawing ~200 blocks with gradients each frame — createLinearGradient per block per frame is somewhat expensive but 200 is fine on modern machines. To optimize, I could pre-render each color block to an offscreen canvas (7 colors × 1 size) and drawImage. That's cleaner and faster. Let me do that: ```js const blockCache={}; function getBlockSprite(color){ if(blockCache[color]) return blockCache[color]; const cv=document.createElement('canvas'); cv.width=CELL; cv.height=CELL; const c=cv.getContext('2d'); drawBlock(c,0,0,CELL,color); blockCache[color]=cv; return cv; } ``` Then in drawBoard: `ctx.drawImage(getBlockSprite(c), x*CELL, y*CELL);` But next preview uses CELL=22 — different size. I can scale drawImage: drawImage(sprite, px, py, 22, 22). Scaling 24→22 is fine (slight blur acceptable) or render at 22 separately. I'll just call drawBlock directly for next preview (only 4 blocks). For ghost, use stroke/fill directly. This caching makes the main loop fast. Particles drawn as fillRect — fine. Now, let me also add a subtle "landing flash" on the piece when it locks: brief white overlay on locked cells? The particles cover that. Also add column guide? No, keep clean. Let me write the sound toggle button styling: ```css #soundBtn{ font-family:'Courier New',monospace; font-weight:bold; font-size:12px; letter-spacing:2px; padding:10px 14px; background:#101c30; color:#6b87a8; border:1px solid #23405f; border-radius:6px; cursor:pointer; transition:all .2s; } #soundBtn:hover{ color:#cfe3ff; border-color:#3d6ea5; box-shadow:0 0 14px rgba(0,229,255,.2); } #soundBtn.on{ color:#3ddc55; border-color:#3ddc55; box-shadow:0 0 14px rgba(61,220,85,.3); } ``` Now let me assemble everything and double-check the JS for errors. Full JS outline: ```js (function(){ 'use strict'; // constants const COLS=10, ROWS=20, CELL=24, W=COLS*CELL, H=ROWS*CELL; const DAS=150, ARR=45, LOCK_DELAY=380, MAX_RESETS=15; const SHAPES={...}; const COLORS={...}; // dom const boardCanvas=..., ctx=...; const bgCanvas=..., bctx=...; const nextCanvas=..., nctx=...; const scoreEl=..., linesEl=..., levelEl=..., bestEl=..., progEl=...; const boardWrap=...; const soundBtn=...; const ovStart=..., ovPause=..., ovOver=...; const toastEl=...; const finalScore=..., finalLines=..., finalLevel=...; // state let grid, current=null, bag=[], score=0, lines=0, level=0, best=0; let state='start'; let clearing=null; let gravityAcc=0, grounded=0, lockResets=0, shake=0; let particles=[]; let stars=[]; let soundOn=false, audioCtx=null; let lastRotate=0; const keys={left:false,right:false,down:false}; let dasDir=0, dasTimer=0, arrTimer=0; // init function emptyGrid(){...} grid=emptyGrid(); initStars(); updateHUD(); drawNext(); // shows first peek? bag empty → refill → shows random. Fine. // bag function shuffle(a){ for(let i=a.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]]; } return a; } function drawType(){ if(!bag.length) bag=shuffle(['I','J','L','O','S','T','Z']); return bag.pop(); } function peekType(){ if(!bag.length) bag=shuffle(['I','J','L','O','S','T','Z']); return bag[bag.length-1]; } ``` Wait — peekType refills bag if empty, which is correct for preview continuity. But careful: drawType pops; if bag had 1 left, pop makes it empty; next peek refills. Correct 7-bag behavior. ```js // collision, move, rotate, drop, lock, clear, spawn, gameover, pause, start // rendering: sprites, drawBoard, drawNext, particles, bg // input handlers // loop })(); ``` Let me write drawNext with bounding box centering. Also initial drawNext before game start: bag is empty → peekType refills and shows a piece. That's fine — it's the actual first piece? No! peekType peeks the last of bag; drawType pops the last. So preview shows bag[last], and spawn pops bag[last] — consistent! Good: preview always matches next spawn. But wait: on resetGame I call spawnNext() which draws current from bag, then drawNext() shows the following. Correct. Initial page load (start state): I call drawNext() once to populate preview — it refills bag and shows bag[last]. When game starts, resetGame() resets bag=[] and spawns fresh — preview updates. Slight inconsistency (preview on start screen isn't the actual first piece) but harmless; actually resetGame clears bag so it's fresh. Fine. Now updateHUD initial: score 0, lines 0, level 1, best 0, prog 0%. Let me write the loop with bg: ```js const bgCanvas=document.getElementById('bg'); bgCanvas.width=800; bgCanvas.height=640; ``` Stars init 90. Draw bg each frame: clear, radial glow, stars. Now, one more polish: when a piece locks, play 'lock' sound; when hard drop, 'harddrop' then lock sound might double — I'll skip lock sound if hard drop just played? Minor; I'll play lock sound only in lockPiece, and hardDrop plays 'harddrop' — both play, slight overlap is OK, or I can pass a flag. Let me have lockPiece(play=true) and hardDrop calls lockPiece(false)... actually simpler: hardDrop plays 'harddrop'; lockPiece plays 'lock' — two sounds 0ms apart. To avoid clutter, in lockPiece check `if(!justHardDropped)`. I'll add param. Let me finalize scoring display bump: bump on line clear and hard drop. Also "Best" updates live? Update best display when score exceeds best during play? Classic shows best separately; I'll update bestEl live when score>best (in-memory session best). Actually keep best as "previous best" and update on game over? Live update is nicer: in addScore, if(score>best){best=score; bestEl.textContent=best;}. Now let me write the CSS fully. ```css *{ box-sizing:border-box; margin:0; padding:0; } html,body{ width:800px; height:640px; overflow:hidden; background:#05080f; } body{ font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif; color:#cfe3ff; position:relative; } #bg{ position:absolute; top:0; left:0; z-index:0; } #app{ position:relative; z-index:1; width:800px; height:640px; display:flex; flex-direction:column; padding:10px 18px 12px; } header{ height:46px; display:flex; align-items:center; justify-content:space-between; flex:none; } .logo{ font-family:Impact,'Arial Black',sans-serif; font-size:32px; letter-spacing:3px; color:#fff; text-shadow:-2px 0 rgba(0,229,255,.7), 2px 0 rgba(255,77,90,.7), 0 0 22px rgba(0,229,255,.35); } .logo span{ color:#ffd54a; text-shadow:-2px 0 rgba(0,229,255,.5), 2px 0 rgba(255,77,90,.5), 0 0 18px rgba(255,213,74,.5); } .tag{ font-family:'Courier New',monospace; font-size:10px; letter-spacing:3px; color:#5f7fa3; } main{ flex:1; display:flex; align-items:center; justify-content:center; gap:22px; min-height:0; } .side{ width:186px; display:flex; flex-direction:column; gap:12px; } .panel{ background:rgba(12,20,36,.85); border:1px solid #1c3050; border-left:3px solid var(--ac,#00e5ff); border-radius:6px; padding:10px 12px; box-shadow:0 4px 16px rgba(0,0,0,.35); } .label{ font-size:9px; letter-spacing:2.5px; color:#5f7fa3; text-transform:uppercase; font-weight:600; margin-bottom:4px; } .value{ font-family:'Courier New',monospace; font-weight:bold; font-size:26px; color:#fff; text-shadow:0 0 12px rgba(0,229,255,.35); line-height:1; } .value.small{ font-size:20px; } #score{ color:#ffd54a; text-shadow:0 0 14px rgba(255,213,74,.45); } #lines{ color:#00e5ff; text-shadow:0 0 14px rgba(0,229,255,.45); } #level{ color:#c44dff; text-shadow:0 0 14px rgba(196,77,255,.45); } .bar{ height:5px; background:#0a1322; border-radius:3px; margin-top:8px; overflow:hidden; } .fill{ height:100%; width:0%; background:linear-gradient(90deg,#c44dff,#00e5ff); border-radius:3px; transition:width .3s ease; } .bump{ animation:bump .28s ease; } @keyframes bump{ 0%{transform:scale(1)} 40%{transform:scale(1.22)} 100%{transform:scale(1)} } ``` Board: ```css #boardWrap{ position:relative; padding:8px; background:#0c1526; border:2px solid #24466b; border-radius:10px; box-shadow:0 0 0 1px #060b14, 0 0 36px rgba(0,229,255,.13), 0 12px 40px rgba(0,0,0,.55), inset 0 0 26px rgba(0,0,0,.6); } #boardWrap::before{ content:''; position:absolute; top:-3px; left:-3px; width:20px; height:20px; border-top:3px solid #00e5ff; border-left:3px solid #00e5ff; border-radius:10px 0 0 0; } #boardWrap::after{ content:''; position:absolute; bottom:-3px; right:-3px; width:20px; height:20px; border-bottom:3px solid #ffd54a; border-right:3px solid #ffd54a; border-radius:0 0 10px 0; } #boardWrap.danger{ border-color:#ff4d5a; animation:dangerPulse .9s ease-in-out infinite; } @keyframes dangerPulse{ 0%,100%{ box-shadow:0 0 0 1px #060b14, 0 0 30px rgba(255,77,90,.25), inset 0 0 26px rgba(0,0,0,.6);} 50%{ box-shadow:0 0 0 1px #060b14, 0 0 55px rgba(255,77,90,.55), inset 0 0 26px rgba(0,0,0,.6);} } #boardWrap.pulse{ animation:framePulse .7s ease; } @keyframes framePulse{ 0%{ filter:brightness(1);} 30%{ filter:brightness(1.6) drop-shadow(0 0 24px rgba(255,213,74,.8)); } 100%{ filter:brightness(1);} } .scanlines{ position:absolute; inset:8px; pointer-events:none; z-index:2; background:repeating-linear-gradient(0deg, rgba(255,255,255,.028) 0px, rgba(255,255,255,.028) 1px, transparent 1px, transparent 3px); border-radius:4px; } canvas#board{ display:block; border-radius:4px; } ``` Overlays: ```css .overlay{ position:absolute; inset:0; z-index:5; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; background:rgba(5,9,18,.9); border-radius:8px; text-align:center; animation:ovIn .35s ease; } .overlay.hidden{ display:none; } @keyframes ovIn{ from{ opacity:0; transform:scale(.96);} to{ opacity:1; transform:scale(1);} } .ov-title{ font-family:Impact,'Arial Black',sans-serif; font-size:42px; letter-spacing:5px; color:#fff; text-shadow:-3px 0 rgba(0,229,255,.8), 3px 0 rgba(255,77,90,.8), 0 0 30px rgba(0,229,255,.5); } .ov-title span{ color:#ffd54a; } .ov-title.red{ color:#ff4d5a; text-shadow:0 0 26px rgba(255,77,90,.7), -2px 0 rgba(255,213,74,.5); font-size:38px; } .ov-title.cyan{ color:#00e5ff; text-shadow:0 0 26px rgba(0,229,255,.7); font-size:38px; } .ov-sub{ font-family:'Courier New',monospace; font-size:10px; letter-spacing:3px; color:#5f7fa3; } .mini-row{ display:flex; gap:8px; margin:4px 0; } .mini-row span{ width:16px; height:16px; background:var(--c); box-shadow:0 0 12px var(--c), inset -3px -3px 0 rgba(0,0,0,.3), inset 3px 3px 0 rgba(255,255,255,.3); animation:bounce 1.3s ease-in-out infinite; animation-delay:calc(var(--i)*.12s); } @keyframes bounce{ 0%,100%{ transform:translateY(0);} 50%{ transform:translateY(-9px);} } .ctrl-grid{ display:grid; grid-template-columns:auto auto; gap:7px 26px; font-size:12px; color:#9db8d8; } .ctrl-grid .ks{ display:inline-block; min-width:74px; text-align:right; margin-right:8px; } kbd{ display:inline-block; min-width:20px; padding:2px 6px; margin:0 1px; background:#152238; border:1px solid #2c4a72; border-bottom:3px solid #2c4a72; border-radius:4px; font-family:'Courier New',monospace; font-size:10px; font-weight:bold; color:#cfe3ff; text-align:center; } .prompt{ font-family:'Courier New',monospace; font-size:12px; letter-spacing:2px; color:#ffd54a; animation:pulse 1.4s ease-in-out infinite; margin-top:6px; } @keyframes pulse{ 0%,100%{ opacity:.45;} 50%{ opacity:1;} } .stats{ display:flex; gap:26px; } .stats .label{ margin-bottom:2px; } .stat-val{ font-family:'Courier New',monospace; font-weight:bold; font-size:24px; color:#fff; display:block; } ``` Toast: ```css #toast{ position:absolute; top:34%; left:50%; transform:translate(-50%,-50%); z-index:6; font-family:Impact,'Arial Black',sans-serif; font-size:30px; letter-spacing:3px; color:#ffd54a; text-shadow:0 0 22px rgba(255,213,74,.9), 0 2px 0 rgba(0,0,0,.5); opacity:0; pointer-events:none; white-space:nowrap; } #toast.show{ animation:toastAnim 1.4s ease forwards; } @keyframes toastAnim{ 0%{ opacity:0; transform:translate(-50%,-20%) scale(.6);} 18%{ opacity:1; transform:translate(-50%,-50%) scale(1.15);} 32%{ transform:translate(-50%,-50%) scale(1);} 75%{ opacity:1;} 100%{ opacity:0; transform:translate(-50%,-80%) scale(1);} } ``` Controls list in right panel: ```css .ctrls{ list-style:none; font-size:11px; color:#9db8d8; display:flex; flex-direction:column; gap:6px; } .ctrls kbd{ font-size:9px; min-width:16px; padding:1px 4px; } ``` Next canvas centered: `#next{ display:block; margin:4px auto 0; }` Now the JS — let me write it completely and check each function. Also: `drawBlock` used for sprite cache at CELL size; for next preview I'll call drawBlock directly with size 22. Sprite cache keyed by color. Let me write drawGhost carefully: ```js function drawGhost(){ let gy=current.y; while(!collides(current.matrix,current.x,gy+1)) gy++; if(gy<=current.y) return; const col=COLORS[current.type]; ctx.save(); for(let y=0;yp.life>0); } function drawParticles(){ for(const p of particles){ ctx.globalAlpha=Math.max(0,Math.min(1,p.life)); ctx.fillStyle=p.color; ctx.fillRect(p.x-p.size/2,p.y-p.size/2,p.size,p.size); } ctx.globalAlpha=1; } ``` Hard drop particles: for each filled cell of current at final position, burst small at cell center with power 2.5, n=2. Lock particles: similar but smaller. Clear particles: per row per column burst 2 with power 5. Now updatePlaying danger check — do it every frame, cheap (max 200 checks). Frame pulse: ```js function framePulse(){ boardWrap.classList.remove('pulse'); void boardWrap.offsetWidth; boardWrap.classList.add('pulse'); } ``` Toast: ```js function toast(msg){ toastEl.textContent=msg; toastEl.classList.remove('show'); void toastEl.offsetWidth; toastEl.classList.add('show'); } ``` Sound: as planned. Now the keydown handler — one more check: `e.key` for space is ' '. For Enter it's 'Enter'. Good. Also I should preventDefault for 'Enter'? Not necessary. Also, if user holds Space (hard drop) — e.repeat for ' ' — I return early for repeats on space? I have `if(e.repeat && (k===' '||...)) return;` — wait, I need to decide: for space, ignore repeats (one hard drop per press). Yes. Let me restructure keydown cleanly: ```js window.addEventListener('keydown', function(e){ const k=e.key; if(k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'||k==='ArrowDown'||k===' ') e.preventDefault(); if(k==='m'||k==='M'){ toggleSound(); return; } if(state==='start'){ startGame(); return; } if(state==='gameover'){ if(k==='Enter') startGame(); return; } if(k==='p'||k==='P'){ togglePause(); return; } if(state!=='playing') return; if(e.repeat){ if(k!=='ArrowUp') return; } if(k==='ArrowLeft'){ if(!e.repeat){ keys.left=true; dasDir=-1; dasTimer=0; arrTimer=0; if(tryMove(-1,0)) playSound('move'); } } else if(k==='ArrowRight'){ if(!e.repeat){ keys.right=true; dasDir=1; dasTimer=0; arrTimer=0; if(tryMove(1,0)) playSound('move'); } } else if(k==='ArrowDown'){ if(!e.repeat) keys.down=true; } else if(k==='ArrowUp'){ rotatePiece(); } else if(k===' '){ hardDrop(); } }); ``` Wait, `if(e.repeat){ if(k!=='ArrowUp') return; }` — for ArrowUp repeat, continue to rotatePiece (throttled). Good. tryMove with sound: I'll make tryMove pure (no sound) and play in caller. ```js function tryMove(dx,dy){ if(!current) return false; if(collides(current.matrix,current.x+dx,current.y+dy)) return false; current.x+=dx; current.y+=dy; if(dx!==0 || dy!==0){ if(collides(current.matrix,current.x,current.y+1)){ if(grounded>0) lockResets++; grounded=0; } else grounded=0; } return true; } ``` Hmm, for dy=1 (falling), if it lands, grounded should start fresh — grounded=0 then accumulates next frame. The `if(grounded>0) lockResets++` only counts resets when we were already grounded. For falling move, grounded was 0 anyway. OK. But wait: for horizontal move while grounded: grounded>0 → lockResets++, grounded=0. Good. If lockResets>=MAX_RESETS, update loop forces lock. Good. rotatePiece: ```js function rotatePiece(){ if(!current || clearing) return; const now=performance.now(); if(now-lastRotate<90) return; lastRotate=now; if(current.type==='O'){ playSound('rotate'); return; } const r=rotateMatrix(current.matrix); const kicks=[[0,0],[-1,0],[1,0],[0,-1],[-2,0],[2,0],[-1,-1],[1,-1]]; for(const kk of kicks){ if(!collides(r,current.x+kk[0],current.y+kk[1])){ current.matrix=r; current.x+=kk[0]; current.y+=kk[1]; if(collides(current.matrix,current.x,current.y+1)){ if(grounded>0) lockResets++; grounded=0; } else grounded=0; playSound('rotate'); return; } } } ``` hardDrop: ```js function hardDrop(){ if(!current || clearing) return; let d=0; while(!collides(current.matrix,current.x,current.y+1)){ current.y++; d++; } if(d>0) addScore(d*2,false); shake=7; // particles const col=COLORS[current.type]; for(let y=0;y=0) burst((current.x+x)*CELL+CELL/2, by*CELL+CELL-4, col, 2, 3); } } playSound('harddrop'); lockPiece(false); } ``` lockPiece: ```js function lockPiece(sound=true){ const m=current.matrix, col=COLORS[current.type]; let topOut=false; for(let y=0;ybest){ best=score; } finalScore.textContent=score; finalLines.textContent=lines; finalLevel.textContent=level+1; updateHUD(); showOverlay('over'); playSound('gameover'); boardWrap.classList.remove('danger'); } ``` startGame: ```js function startGame(){ grid=emptyGrid(); bag=[]; score=0; lines=0; level=0; clearing=null; particles=[]; shake=0; gravityAcc=0; grounded=0; lockResets=0; keys.left=keys.right=keys.down=false; dasDir=0; hideOverlays(); state='playing'; spawnNext(); updateHUD(); playSound('start'); } ``` togglePause: ```js function togglePause(){ if(state==='playing'){ state='paused'; showOverlay('pause'); } else if(state==='paused'){ state='playing'; hideOverlays(); } } ``` showOverlay/hideOverlays: ```js function showOverlay(name){ ovStart.classList.add('hidden'); ovPause.classList.add('hidden'); ovOver.classList.add('hidden'); ({start:ovStart,pause:ovPause,over:ovOver})[name].classList.remove('hidden'); } function hideOverlays(){ ovStart.classList.add('hidden'); ovPause.classList.add('hidden'); ovOver.classList.add('hidden'); } ``` updateHUD: ```js function updateHUD(){ scoreEl.textContent=score; linesEl.textContent=lines; levelEl.textContent=level+1; bestEl.textContent=best; progEl.style.width=((lines%10)*10)+'%'; } ``` addScore: ```js function addScore(n,bumpIt){ score+=n; if(score>best){ best=score; bestEl.textContent=best; } scoreEl.textContent=score; if(bumpIt!==false) bump(scoreEl); } ``` Hmm, soft drop calls addScore(1,false) — no bump. Line clear calls with bump. addLines: ```js function addLines(n){ lines+=n; linesEl.textContent=lines; bump(linesEl); const nl=Math.floor(lines/10); if(nl>level){ level=nl; levelEl.textContent=level+1; bump(levelEl); toast('LEVEL '+(level+1)); framePulse(); playSound('levelup'); } progEl.style.width=((lines%10)*10)+'%'; } ``` bump: ```js function bump(el){ el.classList.remove('bump'); void el.offsetWidth; el.classList.add('bump'); } ``` Now the loop and bg: ```js function initStars(){ stars=[]; for(let i=0;i<90;i++){ stars.push({ x:Math.random()*800, y:Math.random()*640, r:Math.random()*1.6+0.5, s:Math.random()*0.35+0.06, a:Math.random()*0.45+0.15, c:Math.random()<0.25?'#ffd54a':(Math.random()<0.5?'#7fd4ff':'#ffffff') }); } } function updateBg(dt){ const f=dt/16.67; for(const s of stars){ s.y+=s.s*f; if(s.y>642){ s.y=-3; s.x=Math.random()*800; } } } function drawBg(){ bctx.clearRect(0,0,800,640); const rg=bctx.createRadialGradient(400,320,40,400,320,430); rg.addColorStop(0,'rgba(0,110,170,0.12)'); rg.addColorStop(0.6,'rgba(20,40,90,0.05)'); rg.addColorStop(1,'rgba(0,0,0,0)'); bctx.fillStyle=rg; bctx.fillRect(0,0,800,640); for(const s of stars){ bctx.globalAlpha=s.a; bctx.fillStyle=s.c; bctx.fillRect(s.x,s.y,s.r,s.r*2.2); } bctx.globalAlpha=1; } ``` Main loop: ```js let lastT=performance.now(); function loop(now){ let dt=now-lastT; lastT=now; if(dt>100) dt=100; updateBg(dt); if(state==='playing') updatePlaying(dt); drawBg(); drawBoard(); requestAnimationFrame(loop); } requestAnimationFrame(loop); ``` updatePlaying: ```js function updatePlaying(dt){ // DAS if(dasDir!==0 && current && !clearing){ dasTimer+=dt; if(dasTimer>=DAS){ arrTimer+=dt; while(arrTimer>=ARR){ arrTimer-=ARR; tryMove(dasDir,0); } } } if(clearing){ clearing.t+=dt; if(clearing.t>=clearing.dur) finishClear(); updateParticles(dt); return; } if(!current) return; const iv=keys.down?40:dropInterval(); gravityAcc+=dt; if(gravityAcc>=iv){ gravityAcc=0; if(tryMove(0,1)){ if(keys.down) addScore(1,false); } } if(collides(current.matrix,current.x,current.y+1)){ grounded+=dt; if(grounded>=LOCK_DELAY||lockResets>=MAX_RESETS) lockPiece(); } else grounded=0; // danger let danger=false; outer: for(let y=0;y<4;y++){ for(let x=0;x>16)&255,g=(n>>8)&255,b=n&255; if(amt>=0){ r+=(255-r)*amt; g+=(255-g)*amt; b+=(255-b)*amt; } else { const f=1+amt; r*=f; g*=f; b*=f; } return 'rgb('+(r|0)+','+(g|0)+','+(b|0)+')'; } ``` drawNext: ```js function drawNext(){ nctx.clearRect(0,0,120,80); const type=peekType(); const m=SHAPES[type]; let minx=99,maxx=-1,miny=99,maxy=-1; for(let y=0;ymaxx)maxx=x; if(ymaxy)maxy=y; } const cs=22; const w=(maxx-minx+1)*cs, h=(maxy-miny+1)*cs; const ox=Math.round((120-w)/2)-minx*cs, oy=Math.round((80-h)/2)-miny*cs; for(let y=0;y