Skip to content

Instantly share code, notes, and snippets.

@sionleroux
Created April 28, 2026 12:36
Show Gist options
  • Select an option

  • Save sionleroux/80d349a9b261d16b743f177556972e13 to your computer and use it in GitHub Desktop.

Select an option

Save sionleroux/80d349a9b261d16b743f177556972e13 to your computer and use it in GitHub Desktop.
Teletris
<!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