A Pen by Siôn le Roux on CodePen.
Created
April 28, 2026 12:36
-
-
Save sionleroux/80d349a9b261d16b743f177556972e13 to your computer and use it in GitHub Desktop.
Teletris
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>Magenta Teletris MVP</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| body { | |
| background-color: #111; | |
| color: #E20074; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | |
| touch-action: none; /* Prevent scroll on touch devices */ | |
| position: relative; | |
| } | |
| /* CRT Scanlines and RGB split */ | |
| body::before { | |
| content: " "; | |
| display: block; | |
| position: absolute; | |
| top: 0; left: 0; bottom: 0; right: 0; | |
| background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), | |
| linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06)); | |
| z-index: 50; | |
| background-size: 100% 3px, 6px 100%; | |
| pointer-events: none; | |
| } | |
| /* Subtle CRT Flicker and Vignette */ | |
| @keyframes flicker { | |
| 0%, 19.9%, 22%, 62.9%, 64%, 64.9%, 70%, 100% { opacity: 0.99; } | |
| 20%, 21.9%, 63%, 63.9%, 65%, 69.9% { opacity: 0.85; } | |
| } | |
| body::after { | |
| content: " "; | |
| display: block; | |
| position: absolute; | |
| top: 0; left: 0; bottom: 0; right: 0; | |
| background: radial-gradient(circle, rgba(0,0,0,0) 50%, rgba(0,0,0,0.5) 100%); | |
| z-index: 51; | |
| pointer-events: none; | |
| animation: flicker 5s infinite; | |
| } | |
| canvas { | |
| background-color: #000; | |
| border: 2px solid #E20074; | |
| box-shadow: 0 0 20px rgba(226, 0, 116, 0.5), inset 0 0 10px rgba(226, 0, 116, 0.2); | |
| image-rendering: pixelated; | |
| } | |
| .btn-control { | |
| background-color: transparent; | |
| border: 2px solid #E20074; | |
| color: #E20074; | |
| border-radius: 0.5rem; | |
| padding: 1rem; | |
| font-size: 1.5rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| user-select: none; | |
| transition: all 0.1s; | |
| } | |
| .btn-control:active { | |
| background-color: #E20074; | |
| color: #000; | |
| } | |
| </style> | |
| </head> | |
| <body class="h-screen w-screen flex flex-col items-center justify-center overflow-hidden"> | |
| <div class="flex flex-col items-center w-full max-w-md p-4"> | |
| <!-- Header / Scoreboard --> | |
| <div class="w-full flex justify-between items-end mb-2 px-2" style="max-width: 240px;"> | |
| <div> | |
| <h1 class="text-2xl font-bold tracking-widest uppercase">Teletris</h1> | |
| <p class="text-xs opacity-80">Telekom Edition</p> | |
| </div> | |
| <div class="text-right"> | |
| <div class="text-sm">Score: <span id="score" class="font-bold">0</span></div> | |
| <div class="text-sm">Level: <span id="level" class="font-bold">1</span></div> | |
| </div> | |
| </div> | |
| <!-- Game Canvas --> | |
| <!-- Standard Tetris grid is 10x20. Using 24px blocks = 240x480 --> | |
| <canvas id="tetris" width="240" height="480" class="rounded-sm"></canvas> | |
| <!-- Mobile Controls --> | |
| <div class="w-full grid grid-cols-4 gap-2 mt-6" style="max-width: 320px;"> | |
| <button id="btn-left" class="btn-control">◀</button> | |
| <button id="btn-down" class="btn-control">▼</button> | |
| <button id="btn-rotate" class="btn-control">↻</button> | |
| <button id="btn-right" class="btn-control">▶</button> | |
| </div> | |
| <button id="btn-drop" class="btn-control w-full mt-2 py-2 text-lg" style="max-width: 320px;">HARD DROP</button> | |
| <div id="game-over" class="hidden absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black border-2 border-[#E20074] p-6 text-center z-10 shadow-2xl"> | |
| <h2 class="text-3xl font-bold mb-4">GAME OVER</h2> | |
| <p class="mb-4">Final Score: <span id="final-score">0</span></p> | |
| <button onclick="startGame()" class="bg-[#E20074] text-black px-4 py-2 font-bold uppercase hover:bg-white hover:text-[#E20074] transition-colors">Play Again</button> | |
| </div> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('tetris'); | |
| const context = canvas.getContext('2d'); | |
| const scoreElement = document.getElementById('score'); | |
| const levelElement = document.getElementById('level'); | |
| const gameOverScreen = document.getElementById('game-over'); | |
| const finalScoreElement = document.getElementById('final-score'); | |
| // Scale context so everything is drawn in blocks of 24x24 pixels | |
| context.scale(24, 24); | |
| // Telekom Magenta | |
| const MAGENTA = '#E20074'; | |
| const MAGENTA_DARK = '#8B0047'; | |
| const MAGENTA_LIGHT = '#FF4D9B'; | |
| // Complementary Light Green | |
| const LIME_GREEN = '#A2E200'; | |
| const LIME_DARK = '#6A9400'; | |
| const LIME_LIGHT = '#D5FF4D'; | |
| // Tetris pieces (A chaotic mix of T's and dots) | |
| function createPiece(type) { | |
| if (type === 'HUGE') { | |
| return [ | |
| [1, 1, 1, 0], | |
| [0, 1, 0, 0], | |
| [0, 1, 0, 0], | |
| [0, 1, 0, 0] | |
| ]; | |
| } else if (type === 'MED') { | |
| return [ | |
| [1, 1, 1], | |
| [0, 1, 0], | |
| [0, 1, 0] | |
| ]; | |
| } else if (type === 'STD') { | |
| return [ | |
| [0, 1, 0], | |
| [1, 1, 1], | |
| [0, 0, 0] | |
| ]; | |
| } else if (type === 'DOT') { | |
| return [ | |
| [1] | |
| ]; | |
| } | |
| } | |
| // Create the game arena (10 columns, 20 rows) | |
| function createMatrix(w, h) { | |
| const matrix = []; | |
| while (h--) { | |
| matrix.push(new Array(w).fill(0)); | |
| } | |
| return matrix; | |
| } | |
| const arena = createMatrix(10, 20); | |
| const player = { | |
| pos: {x: 0, y: 0}, | |
| matrix: null, | |
| score: 0, | |
| lines: 0, | |
| level: 1 | |
| }; | |
| let particles = []; | |
| let ghostTrails = []; | |
| let shakeFrames = 0; | |
| // --- Web Audio API Synth --- | |
| const AudioContext = window.AudioContext || window.webkitAudioContext; | |
| const audioCtx = new AudioContext(); | |
| function playNote(freq, startTime, duration) { | |
| // Browsers suspend audio until user interaction, so we resume it on demand | |
| if (audioCtx.state === 'suspended') audioCtx.resume(); | |
| const osc = audioCtx.createOscillator(); | |
| const gainNode = audioCtx.createGain(); | |
| osc.type = 'triangle'; // Gives it that 8-bit melodic retro feel | |
| osc.frequency.value = freq; | |
| // Volume envelope so it doesn't just click aggressively | |
| gainNode.gain.setValueAtTime(0, audioCtx.currentTime + startTime); | |
| gainNode.gain.linearRampToValueAtTime(0.3, audioCtx.currentTime + startTime + 0.05); | |
| gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + startTime + duration); | |
| osc.connect(gainNode); | |
| gainNode.connect(audioCtx.destination); | |
| osc.start(audioCtx.currentTime + startTime); | |
| osc.stop(audioCtx.currentTime + startTime + duration); | |
| } | |
| const NOTE_C5 = 523.25; | |
| const NOTE_E5 = 659.25; | |
| function playDropSound() { | |
| playNote(NOTE_C5, 0, 0.15); // A single crisp C | |
| } | |
| function playJingle() { | |
| // Telekom jingle rhythm: equal length notes | |
| const dur = 0.1; | |
| const gap = 0.15; | |
| playNote(NOTE_C5, 0, dur); | |
| playNote(NOTE_C5, gap, dur); | |
| playNote(NOTE_C5, gap * 2, dur); | |
| playNote(NOTE_E5, gap * 3, dur); | |
| playNote(NOTE_C5, gap * 4, dur); | |
| } | |
| function createParticles(rowY) { | |
| for (let x = 0; x < 10; x++) { | |
| // Create 4 particles per block | |
| for(let i=0; i<4; i++) { | |
| particles.push({ | |
| x: x + Math.random(), | |
| y: rowY + Math.random(), | |
| vx: (Math.random() - 0.5) * 0.4, | |
| vy: (Math.random() - 0.5) * 0.4 - 0.2, // slight upward bias | |
| life: 1.0, | |
| decay: 0.02 + Math.random() * 0.03, | |
| // Mostly magenta, occasionally pop the green complement | |
| color: [MAGENTA, MAGENTA_LIGHT, '#ffffff', LIME_GREEN][Math.floor(Math.random() * 4)] | |
| }); | |
| } | |
| } | |
| } | |
| function createRotationParticles(px, py, matrix) { | |
| let cx = px + matrix.length / 2; | |
| let cy = py + matrix.length / 2; | |
| for (let i = 0; i < 12; i++) { | |
| let angle = (Math.PI * 2 / 12) * i; | |
| let speed = 0.3; | |
| particles.push({ | |
| x: cx, | |
| y: cy, | |
| vx: Math.cos(angle) * speed, | |
| vy: Math.sin(angle) * speed, | |
| life: 1.0, | |
| decay: 0.1, // fast fade | |
| color: LIME_GREEN // flash the complementary colour | |
| }); | |
| } | |
| } | |
| // Draw a single block with a bevel effect so pieces are distinguishable | |
| function drawBlock(x, y, alpha = 1.0, useGreen = false) { | |
| context.globalAlpha = alpha; | |
| let baseC = useGreen ? LIME_GREEN : MAGENTA; | |
| let lightC = useGreen ? LIME_LIGHT : MAGENTA_LIGHT; | |
| let darkC = useGreen ? LIME_DARK : MAGENTA_DARK; | |
| // Base block color | |
| context.fillStyle = baseC; | |
| context.fillRect(x, y, 1, 1); | |
| // Highlight (top and left) | |
| context.fillStyle = lightC; | |
| context.fillRect(x, y, 1, 0.1); | |
| context.fillRect(x, y, 0.1, 1); | |
| // Shadow (bottom and right) | |
| context.fillStyle = darkC; | |
| context.fillRect(x, y + 0.9, 1, 0.1); | |
| context.fillRect(x + 0.9, y, 0.1, 1); | |
| // Inner dark inset border for extra clarity | |
| context.strokeStyle = `rgba(0, 0, 0, ${0.4 * alpha})`; | |
| context.lineWidth = 0.05; | |
| context.strokeRect(x, y, 1, 1); | |
| context.globalAlpha = 1.0; | |
| } | |
| function drawMatrix(matrix, offset, alpha = 1.0, useGreen = false) { | |
| matrix.forEach((row, y) => { | |
| row.forEach((value, x) => { | |
| if (value !== 0) { | |
| drawBlock(x + offset.x, y + offset.y, alpha, useGreen); | |
| } | |
| }); | |
| }); | |
| } | |
| function draw() { | |
| // Clear canvas | |
| context.fillStyle = '#000'; | |
| context.fillRect(0, 0, canvas.width, canvas.height); | |
| context.save(); | |
| // Apply camera shake | |
| if (shakeFrames > 0) { | |
| const magnitude = 0.3; | |
| const dx = (Math.random() - 0.5) * magnitude; | |
| const dy = (Math.random() - 0.5) * magnitude; | |
| context.translate(dx, dy); | |
| shakeFrames--; | |
| } | |
| // Draw locked pieces in arena | |
| drawMatrix(arena, {x: 0, y: 0}); | |
| // Draw fading ghost trails behind the block | |
| ghostTrails.forEach(t => { | |
| drawMatrix(t.matrix, {x: t.x, y: t.y}, t.alpha, t.useGreen); | |
| }); | |
| // Draw active dropping piece | |
| if(player.matrix) { | |
| drawMatrix(player.matrix, player.pos); | |
| } | |
| // Draw particles for explosions | |
| particles.forEach(p => { | |
| context.globalAlpha = p.life; | |
| context.fillStyle = p.color; | |
| context.fillRect(p.x, p.y, 0.3, 0.3); // Small square particles | |
| }); | |
| context.globalAlpha = 1.0; // Reset alpha | |
| context.restore(); | |
| } | |
| // Copy player's piece into the arena | |
| function merge(arena, player) { | |
| player.matrix.forEach((row, y) => { | |
| row.forEach((value, x) => { | |
| if (value !== 0) { | |
| arena[y + player.pos.y][x + player.pos.x] = value; | |
| } | |
| }); | |
| }); | |
| } | |
| // Check for collisions with walls or locked blocks | |
| function collide(arena, player) { | |
| const m = player.matrix; | |
| const o = player.pos; | |
| for (let y = 0; y < m.length; ++y) { | |
| for (let x = 0; x < m[y].length; ++x) { | |
| if (m[y][x] !== 0 && | |
| (arena[y + o.y] && arena[y + o.y][x + o.x]) !== 0) { | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| // Move piece horizontally | |
| function playerMove(offset) { | |
| player.pos.x += offset; | |
| if (collide(arena, player)) { | |
| player.pos.x -= offset; // Revert if collision | |
| } | |
| } | |
| // Soft drop piece | |
| function playerDrop() { | |
| let startY = player.pos.y; | |
| player.pos.y++; | |
| if (collide(arena, player)) { | |
| player.pos.y--; | |
| merge(arena, player); | |
| playerReset(); | |
| let cleared = arenaSweep(); | |
| if (cleared > 0) { | |
| playJingle(); | |
| } else { | |
| playDropSound(); | |
| } | |
| updateScore(); | |
| } else { | |
| // Add soft trail trace | |
| ghostTrails.push({ | |
| matrix: JSON.parse(JSON.stringify(player.matrix)), | |
| x: player.pos.x, | |
| y: startY, | |
| alpha: 0.2, | |
| useGreen: false | |
| }); | |
| } | |
| dropCounter = 0; | |
| } | |
| // Hard drop piece | |
| function playerHardDrop() { | |
| let startY = player.pos.y; | |
| while(!collide(arena, player)) { | |
| player.pos.y++; | |
| } | |
| player.pos.y--; | |
| let endY = player.pos.y; | |
| // Add intense green hard drop trail | |
| for (let y = startY; y < endY; y += 0.5) { | |
| ghostTrails.push({ | |
| matrix: JSON.parse(JSON.stringify(player.matrix)), | |
| x: player.pos.x, | |
| y: y, | |
| alpha: 0.5, | |
| useGreen: true | |
| }); | |
| } | |
| shakeFrames = 15; // Bigger camera shake! | |
| merge(arena, player); | |
| playerReset(); | |
| let cleared = arenaSweep(); | |
| if (cleared > 0) { | |
| playJingle(); | |
| } else { | |
| playDropSound(); | |
| } | |
| updateScore(); | |
| dropCounter = 0; | |
| } | |
| // Rotate piece matrix | |
| function playerRotate(dir) { | |
| const pos = player.pos.x; | |
| let offset = 1; | |
| rotate(player.matrix, dir); | |
| // Wall kicks | |
| while (collide(arena, player)) { | |
| player.pos.x += offset; | |
| offset = -(offset + (offset > 0 ? 1 : -1)); | |
| if (offset > player.matrix[0].length) { | |
| rotate(player.matrix, -dir); // Revert rotation if wall kick fails | |
| player.pos.x = pos; | |
| return; | |
| } | |
| } | |
| // Successful rotation - blast some green particles! | |
| createRotationParticles(player.pos.x, player.pos.y, player.matrix); | |
| } | |
| function rotate(matrix, dir) { | |
| // Transpose | |
| for (let y = 0; y < matrix.length; ++y) { | |
| for (let x = 0; x < y; ++x) { | |
| [matrix[x][y], matrix[y][x]] = [matrix[y][x], matrix[x][y]]; | |
| } | |
| } | |
| // Reverse rows | |
| if (dir > 0) { | |
| matrix.forEach(row => row.reverse()); | |
| } else { | |
| matrix.reverse(); | |
| } | |
| } | |
| // Clear completed lines | |
| function arenaSweep() { | |
| let rowCount = 1; | |
| let linesCleared = 0; | |
| outer: for (let y = arena.length - 1; y >= 0; --y) { | |
| for (let x = 0; x < arena[y].length; ++x) { | |
| if (arena[y][x] === 0) { | |
| continue outer; | |
| } | |
| } | |
| createParticles(y); // Trigger explosions | |
| // Line is full, remove it and add empty line at top | |
| const row = arena.splice(y, 1)[0].fill(0); | |
| arena.unshift(row); | |
| ++y; | |
| linesCleared++; | |
| // Standard Tetris scoring multiplier | |
| player.score += rowCount * 100 * player.level; | |
| player.lines++; | |
| rowCount *= 2; | |
| } | |
| // Level up every 10 lines | |
| player.level = Math.floor(player.lines / 10) + 1; | |
| return linesCleared; | |
| } | |
| // Spawn new piece | |
| function playerReset() { | |
| const types = ['HUGE', 'MED', 'STD', 'DOT']; | |
| // Pick a random block shape from the armoury | |
| const type = types[Math.floor(Math.random() * types.length)]; | |
| player.matrix = createPiece(type); | |
| player.pos.y = 0; | |
| player.pos.x = (arena[0].length / 2 | 0) - (Math.floor(player.matrix[0].length / 2)); | |
| // Game Over check | |
| if (collide(arena, player)) { | |
| gameOver = true; | |
| gameOverScreen.classList.remove('hidden'); | |
| finalScoreElement.innerText = player.score; | |
| } | |
| } | |
| function updateScore() { | |
| scoreElement.innerText = player.score; | |
| levelElement.innerText = player.level; | |
| } | |
| // Game Loop Variables | |
| let dropCounter = 0; | |
| let dropInterval = 1000; | |
| let lastTime = 0; | |
| let gameOver = false; | |
| let animationId; | |
| function update(time = 0) { | |
| if (gameOver) return; | |
| const deltaTime = time - lastTime; | |
| lastTime = time; | |
| dropCounter += deltaTime; | |
| // Speed increases with level | |
| dropInterval = Math.max(100, 1000 - (player.level - 1) * 100); | |
| if (dropCounter > dropInterval) { | |
| playerDrop(); | |
| } | |
| // Update particle physics | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| let p = particles[i]; | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| p.vy += 0.01; // Gravity | |
| p.life -= p.decay; | |
| if (p.life <= 0) { | |
| particles.splice(i, 1); | |
| } | |
| } | |
| // Update ghost trails decay | |
| ghostTrails.forEach(t => t.alpha -= (t.useGreen ? 0.03 : 0.05)); | |
| ghostTrails = ghostTrails.filter(t => t.alpha > 0); | |
| draw(); | |
| animationId = requestAnimationFrame(update); | |
| } | |
| function startGame() { | |
| // Clear arena | |
| arena.forEach(row => row.fill(0)); | |
| particles = []; // Clear particles on restart | |
| ghostTrails = []; // Clear trails on restart | |
| player.score = 0; | |
| player.lines = 0; | |
| player.level = 1; | |
| gameOver = false; | |
| gameOverScreen.classList.add('hidden'); | |
| updateScore(); | |
| playerReset(); | |
| lastTime = performance.now(); | |
| if (animationId) cancelAnimationFrame(animationId); | |
| update(); | |
| } | |
| // --- Controls --- | |
| // Keyboard mapping | |
| document.addEventListener('keydown', event => { | |
| if (gameOver) return; | |
| switch(event.keyCode) { | |
| case 37: // Left | |
| playerMove(-1); | |
| break; | |
| case 39: // Right | |
| playerMove(1); | |
| break; | |
| case 40: // Down | |
| playerDrop(); | |
| break; | |
| case 38: // Up / Rotate | |
| playerRotate(1); | |
| break; | |
| case 32: // Space / Hard Drop | |
| playerHardDrop(); | |
| break; | |
| } | |
| }); | |
| // Touch / Mobile Controls | |
| function bindTouch(id, action, isContinuous = false) { | |
| const el = document.getElementById(id); | |
| let interval; | |
| const startAction = (e) => { | |
| e.preventDefault(); | |
| if(gameOver) return; | |
| action(); | |
| if (isContinuous) { | |
| interval = setInterval(action, 100); // Repeat while holding | |
| } | |
| }; | |
| const stopAction = (e) => { | |
| e.preventDefault(); | |
| if(interval) clearInterval(interval); | |
| }; | |
| el.addEventListener('touchstart', startAction, {passive: false}); | |
| el.addEventListener('mousedown', startAction); | |
| window.addEventListener('touchend', stopAction); | |
| window.addEventListener('mouseup', stopAction); | |
| } | |
| bindTouch('btn-left', () => playerMove(-1), true); | |
| bindTouch('btn-right', () => playerMove(1), true); | |
| bindTouch('btn-down', () => playerDrop(), true); | |
| bindTouch('btn-rotate', () => playerRotate(1)); | |
| bindTouch('btn-drop', () => playerHardDrop()); | |
| // Kick it off | |
| startGame(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment