Skip to content

Instantly share code, notes, and snippets.

@mgroves
Created March 22, 2025 18:12
Show Gist options
  • Save mgroves/0d6db1d8665a11e5f28399933d4ea7ff to your computer and use it in GitHub Desktop.
Save mgroves/0d6db1d8665a11e5f28399933d4ea7ff to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Space Invaders Emoji</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
html, body {
height: 100%;
background: black;
color: white;
font-family: sans-serif;
display: flex;
flex-direction: column;
overflow: hidden;
}
#game {
flex: 1;
display: block;
background: black;
touch-action: none;
}
#controls {
height: 80px;
display: flex;
justify-content: space-around;
align-items: center;
background: #111;
touch-action: manipulation;
}
button {
font-size: 24px;
padding: 16px;
border-radius: 10px;
background: #444;
color: white;
border: none;
width: 80px;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="controls">
<button id="left">◀️</button>
<button id="fire"></button>
<button id="right">▶️</button>
</div>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const EMOJI = {
player: '🚀',
invader: '👾',
bullet: '🔹',
enemyBullet: '🔸',
pylon: '🧱',
ufo: '🛸'
};
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function playSound(type) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain).connect(audioCtx.destination);
const now = audioCtx.currentTime;
switch (type) {
case 'pew':
osc.type = 'square';
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(100, now + 0.2);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
break;
case 'pop':
osc.type = 'triangle';
osc.frequency.setValueAtTime(120, now);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
break;
case 'ufo':
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, now);
osc.frequency.linearRampToValueAtTime(800, now + 0.4);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4);
break;
case 'boom':
osc.type = 'sine';
osc.frequency.setValueAtTime(80, now);
gain.gain.setValueAtTime(0.5, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
break;
}
osc.start(now);
osc.stop(now + 0.5);
}
const state = {
player: { x: 0, y: 0 },
bullets: [],
enemyBullets: [],
invaders: [],
pylons: [],
moveDir: 1,
tick: 0,
lastPlayerShot: 0,
lastEnemyShot: 0,
score: 0,
gameOver: false,
ufo: null
};
let left = false, right = false, shoot = false;
function resizeCanvas() {
const controlsHeight = document.getElementById('controls').offsetHeight;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight - controlsHeight;
}
function init() {
resizeCanvas();
state.player.y = canvas.height - 40;
state.player.x = canvas.width / 2;
createInvaders();
createPylons();
requestAnimationFrame(loop);
}
function createInvaders() {
const cols = 10, rows = 3;
const spacingX = 36, spacingY = 36;
const offsetX = (canvas.width - (cols - 1) * spacingX) / 2;
state.invaders = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
state.invaders.push({
x: offsetX + col * spacingX,
y: 40 + row * spacingY,
alive: true,
wigglePhase: Math.random() * Math.PI * 2
});
}
}
}
function createPylons() {
const baseY = canvas.height - 100;
const count = 4;
const spacing = canvas.width / count;
state.pylons = [];
for (let i = 0; i < count; i++) {
for (let dx = 0; dx < 4; dx++) {
for (let dy = 0; dy < 2; dy++) {
state.pylons.push({
x: spacing * (i + 0.5) + dx * 8 - 16,
y: baseY + dy * 10
});
}
}
}
}
function drawEmoji(x, y, emoji, angle = 0) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.font = '28px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(emoji, 0, 0);
ctx.restore();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'white';
ctx.font = '16px sans-serif';
ctx.fillText(`Score: ${state.score}`, 10, 20);
drawEmoji(state.player.x, state.player.y, EMOJI.player);
for (const inv of state.invaders) {
if (inv.alive) {
const angle = Math.sin(state.tick / 10 + inv.wigglePhase) * 0.3;
drawEmoji(inv.x, inv.y, EMOJI.invader, angle);
}
}
for (const b of state.bullets) drawEmoji(b.x, b.y, EMOJI.bullet);
for (const b of state.enemyBullets) drawEmoji(b.x, b.y, EMOJI.enemyBullet);
for (const p of state.pylons) drawEmoji(p.x, p.y, EMOJI.pylon);
if (state.ufo) drawEmoji(state.ufo.x, state.ufo.y, EMOJI.ufo);
}
function update() {
if (state.gameOver) return;
state.tick++;
if (left) state.player.x -= 4;
if (right) state.player.x += 4;
state.player.x = Math.max(20, Math.min(canvas.width - 20, state.player.x));
if (shoot && state.tick - state.lastPlayerShot > 20) {
state.bullets.push({ x: state.player.x, y: state.player.y - 20 });
state.lastPlayerShot = state.tick;
playSound('pew');
}
for (const b of state.bullets) b.y -= 5;
for (const b of state.enemyBullets) b.y += 3;
state.bullets = state.bullets.filter(b => b.y > 0);
state.enemyBullets = state.enemyBullets.filter(b => b.y < canvas.height);
if (state.tick % 40 === 0) {
let edge = false;
for (const inv of state.invaders) {
if (!inv.alive) continue;
inv.x += state.moveDir * 10;
if (inv.x < 20 || inv.x > canvas.width - 20) edge = true;
}
if (edge) {
state.moveDir *= -1;
for (const inv of state.invaders) inv.y += 20;
}
}
if (state.tick - state.lastEnemyShot > 60) {
const shooters = state.invaders.filter(i => i.alive);
if (shooters.length > 0) {
const s = shooters[Math.floor(Math.random() * shooters.length)];
state.enemyBullets.push({ x: s.x, y: s.y + 20 });
state.lastEnemyShot = state.tick;
}
}
if (!state.ufo && Math.random() < 0.005) {
state.ufo = { x: -30, y: 30 };
playSound('ufo');
}
if (state.ufo) {
state.ufo.x += 2;
if (state.ufo.x > canvas.width + 30) state.ufo = null;
}
function hit(b, t) {
return b.x > t.x - 15 && b.x < t.x + 15 && b.y > t.y - 10 && b.y < t.y + 10;
}
for (const b of state.bullets) {
for (const inv of state.invaders) {
if (inv.alive && hit(b, inv)) {
inv.alive = false;
b.y = -100;
state.score += 10;
playSound('pop');
}
}
if (state.ufo && hit(b, state.ufo)) {
state.ufo = null;
b.y = -100;
state.score += 100;
playSound('pop');
}
}
for (const bullet of [...state.bullets, ...state.enemyBullets]) {
for (let i = state.pylons.length - 1; i >= 0; i--) {
const p = state.pylons[i];
if (Math.abs(bullet.x - p.x) < 10 && Math.abs(bullet.y - p.y) < 10) {
state.pylons.splice(i, 1);
bullet.y = -100;
break;
}
}
}
for (const b of state.enemyBullets) {
if (Math.abs(b.x - state.player.x) < 15 && Math.abs(b.y - state.player.y) < 15) {
state.gameOver = true;
draw();
playSound('boom');
setTimeout(() => alert("Game Over!"), 100);
}
}
}
function loop() {
update();
draw();
requestAnimationFrame(loop);
}
// Touch controls
["left", "right", "fire"].forEach(id => {
const btn = document.getElementById(id);
btn.addEventListener("touchstart", e => {
e.preventDefault();
if (id === "left") left = true;
if (id === "right") right = true;
if (id === "fire") shoot = true;
});
btn.addEventListener("touchend", e => {
e.preventDefault();
if (id === "left") left = false;
if (id === "right") right = false;
if (id === "fire") shoot = false;
});
});
document.body.addEventListener('touchmove', e => e.preventDefault(), { passive: false });
window.addEventListener("DOMContentLoaded", init);
window.addEventListener("resize", resizeCanvas);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment