Skip to content

Instantly share code, notes, and snippets.

@tluyben
Created July 13, 2025 08:56
Show Gist options
  • Save tluyben/a769fa2bb0352df648d02c8302dd6499 to your computer and use it in GitHub Desktop.
Save tluyben/a769fa2bb0352df648d02c8302dd6499 to your computer and use it in GitHub Desktop.
shmup sonnet 3.7
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nemesis Horizontal Shmup</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #111;
font-family: 'Arial', sans-serif;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
color: white;
}
#game-container {
position: relative;
width: 800px;
height: 450px;
max-width: 100%;
max-height: 80vh;
background-color: #000;
overflow: hidden;
}
canvas {
display: block;
background-color: #000;
}
#start-screen, #game-over-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.8);
z-index: 10;
}
#game-over-screen {
display: none;
}
h1 {
font-size: 3rem;
color: #ff3860;
text-shadow: 0 0 10px #ff3860;
margin-bottom: 1rem;
}
.btn {
padding: 12px 24px;
margin-top: 20px;
background-color: #ff3860;
color: white;
border: none;
border-radius: 5px;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.3s;
}
.btn:hover {
background-color: #ff1443;
transform: scale(1.05);
}
#score-container {
position: absolute;
top: 10px;
left: 10px;
font-size: 1.2rem;
z-index: 5;
}
#lives-container {
position: absolute;
top: 10px;
right: 10px;
font-size: 1.2rem;
z-index: 5;
}
#controls-info {
margin-top: 20px;
font-size: 1rem;
text-align: center;
color: #ccc;
}
@media (max-width: 600px) {
h1 {
font-size: 2rem;
}
.btn {
font-size: 1rem;
padding: 10px 20px;
}
#score-container, #lives-container {
font-size: 1rem;
}
}
</style>
</head>
<body>
<div id="game-container">
<canvas id="game-canvas"></canvas>
<div id="start-screen">
<h1>NEMESIS</h1>
<p>Navigate through waves of enemies and survive!</p>
<button id="start-btn" class="btn">START GAME</button>
<div id="controls-info">
<p>Use WASD or Arrow Keys to move</p>
<p>Press SPACE to shoot</p>
<p>Press P to pause</p>
</div>
</div>
<div id="game-over-screen">
<h1>GAME OVER</h1>
<p>Your final score: <span id="final-score">0</span></p>
<button id="restart-btn" class="btn">PLAY AGAIN</button>
</div>
<div id="score-container">SCORE: <span id="score">0</span></div>
<div id="lives-container">LIVES: <span id="lives">3</span></div>
</div>
<script>
// Game variables
let canvas, ctx;
let player;
let bullets = [];
let enemies = [];
let explosions = [];
let score = 0;
let lives = 3;
let gameRunning = false;
let lastEnemySpawn = 0;
let enemySpawnInterval = 1500;
let lastTimestamp = 0;
let keys = {};
let gameWidth, gameHeight;
let enemyTypes = [];
let backgroundStars = [];
// Audio contexts and sounds
let audioContext;
let shootSound, explosionSound, hitSound, gameOverSound;
// Game initialization
function initGame() {
canvas = document.getElementById('game-canvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
document.getElementById('start-btn').addEventListener('click', startGame);
document.getElementById('restart-btn').addEventListener('click', restartGame);
window.addEventListener('keydown', (e) => {
keys[e.key] = true;
if (e.key === 'p' && gameRunning) togglePause();
if (e.key === ' ' || e.key === 'Space') {
if (gameRunning) {
createBullet();
}
}
});
window.addEventListener('keyup', (e) => {
keys[e.key] = false;
});
// Touch controls for mobile
setupTouchControls();
// Initialize audio
setupAudio();
// Create enemy types
createEnemyTypes();
// Create stars for background
createStars();
}
function resizeCanvas() {
const container = document.getElementById('game-container');
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
canvas.width = containerWidth;
canvas.height = containerHeight;
gameWidth = canvas.width;
gameHeight = canvas.height;
// If player exists, adjust position after resize
if (player) {
player.y = Math.min(player.y, gameHeight - player.height);
}
}
function setupTouchControls() {
// Add touch areas for mobile controls
let touchStartY = 0;
let touchStartX = 0;
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
// Shoot on tap
if (gameRunning) {
createBullet();
}
});
canvas.addEventListener('touchmove', (e) => {
if (gameRunning && player) {
e.preventDefault();
const touch = e.touches[0];
// Calculate how far the finger has moved
const diffX = touch.clientX - touchStartX;
const diffY = touch.clientY - touchStartY;
// Move the player based on finger movement
player.x += diffX * 0.5;
player.y += diffY * 0.5;
// Keep player within bounds
player.x = Math.max(0, Math.min(player.x, gameWidth - player.width));
player.y = Math.max(0, Math.min(player.y, gameHeight - player.height));
// Update touch start position
touchStartX = touch.clientX;
touchStartY = touch.clientY;
}
});
}
function setupAudio() {
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Create sound functions
shootSound = () => {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = 'square';
oscillator.frequency.setValueAtTime(440, audioContext.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(110, audioContext.currentTime + 0.1);
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.1);
};
explosionSound = () => {
const noise = audioContext.createBufferSource();
const buffer = audioContext.createBuffer(1, audioContext.sampleRate * 0.2, audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < buffer.length; i++) {
data[i] = Math.random() * 2 - 1;
}
const gainNode = audioContext.createGain();
gainNode.gain.setValueAtTime(0.4, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
noise.buffer = buffer;
noise.connect(gainNode);
gainNode.connect(audioContext.destination);
noise.start();
};
hitSound = () => {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(220, audioContext.currentTime);
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.3);
};
gameOverSound = () => {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = 'sawtooth';
oscillator.frequency.setValueAtTime(110, audioContext.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(55, audioContext.currentTime + 1);
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 1);
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start();
oscillator.stop(audioContext.currentTime + 1);
};
} catch (e) {
console.log('Web Audio API not supported:', e);
// Create empty functions if audio is not supported
shootSound = explosionSound = hitSound = gameOverSound = () => {};
}
}
function createEnemyTypes() {
// Different enemy types with their own properties
enemyTypes = [
{
name: 'Scout',
color: '#ff3333',
width: 30,
height: 20,
speed: 3,
health: 1,
score: 10,
render: function(ctx, x, y) {
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.moveTo(x, y + this.height/2);
ctx.lineTo(x + this.width, y);
ctx.lineTo(x + this.width, y + this.height);
ctx.closePath();
ctx.fill();
// Engine glow
ctx.fillStyle = '#ffaa33';
ctx.beginPath();
ctx.arc(x + 5, y + this.height/2, 3, 0, Math.PI * 2);
ctx.fill();
}
},
{
name: 'Bomber',
color: '#3366ff',
width: 40,
height: 25,
speed: 2,
health: 2,
score: 20,
render: function(ctx, x, y) {
// Main body
ctx.fillStyle = this.color;
ctx.fillRect(x, y, this.width, this.height);
// Wing details
ctx.fillStyle = '#6699ff';
ctx.fillRect(x + 10, y - 5, 5, 5);
ctx.fillRect(x + 10, y + this.height, 5, 5);
// Engine glow
ctx.fillStyle = '#ff9933';
ctx.beginPath();
ctx.arc(x + 5, y + this.height/2, 4, 0, Math.PI * 2);
ctx.fill();
}
},
{
name: 'Cruiser',
color: '#66cc33',
width: 50,
height: 30,
speed: 1.5,
health: 3,
score: 30,
render: function(ctx, x, y) {
// Main body
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.moveTo(x, y + this.height/2);
ctx.lineTo(x + 15, y);
ctx.lineTo(x + this.width, y + 5);
ctx.lineTo(x + this.width, y + this.height - 5);
ctx.lineTo(x + 15, y + this.height);
ctx.closePath();
ctx.fill();
// Details
ctx.fillStyle = '#99ff66';
ctx.fillRect(x + 20, y + 10, 20, 10);
// Engine glow
ctx.fillStyle = '#ffcc33';
ctx.beginPath();
ctx.arc(x + 5, y + this.height/2, 5, 0, Math.PI * 2);
ctx.fill();
}
}
];
}
function createStars() {
const starCount = 100;
for (let i = 0; i < starCount; i++) {
backgroundStars.push({
x: Math.random() * gameWidth,
y: Math.random() * gameHeight,
size: Math.random() * 2 + 0.5,
speed: Math.random() * 2 + 1
});
}
}
function startGame() {
if (audioContext && audioContext.state === 'suspended') {
audioContext.resume();
}
document.getElementById('start-screen').style.display = 'none';
document.getElementById('game-over-screen').style.display = 'none';
// Reset game state
score = 0;
lives = 3;
bullets = [];
enemies = [];
explosions = [];
lastEnemySpawn = 0;
// Update UI
document.getElementById('score').textContent = score;
document.getElementById('lives').textContent = lives;
// Create player
player = {
x: 100,
y: gameHeight / 2 - 20,
width: 40,
height: 25,
speed: 5,
shootCooldown: 0,
invulnerable: 0,
render: function(ctx) {
// Don't render if invulnerable and should be blinking
if (this.invulnerable > 0 && Math.floor(this.invulnerable / 5) % 2 === 0) {
return;
}
// Main ship body
ctx.fillStyle = '#33ccff';
ctx.beginPath();
ctx.moveTo(this.x + this.width, this.y + this.height / 2);
ctx.lineTo(this.x + this.width - 10, this.y);
ctx.lineTo(this.x, this.y + 5);
ctx.lineTo(this.x, this.y + this.height - 5);
ctx.lineTo(this.x + this.width - 10, this.y + this.height);
ctx.closePath();
ctx.fill();
// Cockpit
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(this.x + this.width - 15, this.y + this.height / 2, 5, 0, Math.PI * 2);
ctx.fill();
// Engine glow
ctx.fillStyle = '#ff9933';
ctx.beginPath();
ctx.arc(this.x + 5, this.y + this.height / 2, 3, 0, Math.PI * 2);
ctx.fill();
// Wing details
ctx.fillStyle = '#3399cc';
ctx.fillRect(this.x + 15, this.y - 3, 10, 3);
ctx.fillRect(this.x + 15, this.y + this.height, 10, 3);
}
};
gameRunning = true;
lastTimestamp = performance.now();
requestAnimationFrame(gameLoop);
}
function restartGame() {
startGame();
}
function togglePause() {
gameRunning = !gameRunning;
if (gameRunning) {
lastTimestamp = performance.now();
requestAnimationFrame(gameLoop);
}
}
function createBullet() {
if (player.shootCooldown <= 0) {
bullets.push({
x: player.x + player.width,
y: player.y + player.height / 2 - 2,
width: 15,
height: 4,
speed: 10
});
player.shootCooldown = 10; // Cooldown in frames
// Play shoot sound
shootSound();
}
}
function spawnEnemy(timestamp) {
if (timestamp - lastEnemySpawn > enemySpawnInterval) {
// Choose a random enemy type
const enemyType = enemyTypes[Math.floor(Math.random() * enemyTypes.length)];
// Create enemy
enemies.push({
x: gameWidth,
y: Math.random() * (gameHeight - enemyType.height),
width: enemyType.width,
height: enemyType.height,
speed: enemyType.speed,
health: enemyType.health,
score: enemyType.score,
type: enemyType
});
// Decrease spawn interval as game progresses (make it more challenging)
enemySpawnInterval = Math.max(500, 1500 - score / 20);
lastEnemySpawn = timestamp;
}
}
function createExplosion(x, y, size) {
explosions.push({
x: x,
y: y,
size: size,
lifetime: 20, // Frames the explosion will last
currentFrame: 0
});
}
function updatePlayer(deltaTime) {
// Movement controls
if ((keys['ArrowUp'] || keys['w'] || keys['W']) && player.y > 0) {
player.y -= player.speed * (deltaTime / 16.67);
}
if ((keys['ArrowDown'] || keys['s'] || keys['S']) && player.y < gameHeight - player.height) {
player.y += player.speed * (deltaTime / 16.67);
}
if ((keys['ArrowLeft'] || keys['a'] || keys['A']) && player.x > 0) {
player.x -= player.speed * (deltaTime / 16.67);
}
if ((keys['ArrowRight'] || keys['d'] || keys['D']) && player.x < gameWidth - player.width) {
player.x += player.speed * (deltaTime / 16.67);
}
// Shooting cooldown
if (player.shootCooldown > 0) {
player.shootCooldown -= deltaTime / 16.67;
}
// Auto-shooting (for convenience)
if ((keys[' '] || keys['Space']) && player.shootCooldown <= 0) {
createBullet();
}
// Invulnerability frames
if (player.invulnerable > 0) {
player.invulnerable -= 1;
}
}
function updateBullets() {
for (let i = bullets.length - 1; i >= 0; i--) {
bullets[i].x += bullets[i].speed;
// Remove bullets that go off screen
if (bullets[i].x > gameWidth) {
bullets.splice(i, 1);
}
}
}
function updateEnemies(deltaTime) {
for (let i = enemies.length - 1; i >= 0; i--) {
enemies[i].x -= enemies[i].speed * (deltaTime / 16.67);
// Remove enemies that go off screen
if (enemies[i].x + enemies[i].width < 0) {
enemies.splice(i, 1);
}
}
}
function updateExplosions() {
for (let i = explosions.length - 1; i >= 0; i--) {
explosions[i].currentFrame++;
// Remove explosions that have completed their animation
if (explosions[i].currentFrame >= explosions[i].lifetime) {
explosions.splice(i, 1);
}
}
}
function updateStars(deltaTime) {
for (let star of backgroundStars) {
star.x -= star.speed * (deltaTime / 16.67);
// Wrap stars around when they go off screen
if (star.x < 0) {
star.x = gameWidth;
star.y = Math.random() * gameHeight;
}
}
}
function checkCollisions() {
// Check bullet-enemy collisions
for (let i = bullets.length - 1; i >= 0; i--) {
const bullet = bullets[i];
for (let j = enemies.length - 1; j >= 0; j--) {
const enemy = enemies[j];
// Simple collision detection (bounding box)
if (
bullet.x < enemy.x + enemy.width &&
bullet.x + bullet.width > enemy.x &&
bullet.y < enemy.y + enemy.height &&
bullet.y + bullet.height > enemy.y
) {
// Remove bullet
bullets.splice(i, 1);
// Reduce enemy health
enemy.health--;
// If enemy is destroyed
if (enemy.health <= 0) {
// Add score
score += enemy.score;
document.getElementById('score').textContent = score;
// Create explosion
createExplosion(enemy.x + enemy.width/2, enemy.y + enemy.height/2, enemy.width);
// Play explosion sound
explosionSound();
// Remove enemy
enemies.splice(j, 1);
} else {
// Play hit sound
hitSound();
}
// Break out of inner loop since bullet is gone
break;
}
}
}
// Check player-enemy collisions (only if player is not invulnerable)
if (player.invulnerable <= 0) {
for (let i = enemies.length - 1; i >= 0; i--) {
const enemy = enemies[i];
if (
player.x < enemy.x + enemy.width &&
player.x + player.width > enemy.x &&
player.y < enemy.y + enemy.height &&
player.y + player.height > enemy.y
) {
// Player got hit
lives--;
document.getElementById('lives').textContent = lives;
// Create explosion
createExplosion(enemy.x + enemy.width/2, enemy.y + enemy.height/2, enemy.width);
// Remove enemy
enemies.splice(i, 1);
// Play explosion sound
explosionSound();
// Make player invulnerable for a short time
player.invulnerable = 60; // 60 frames = ~1 second
// Check if game over
if (lives <= 0) {
gameOver();
}
break;
}
}
}
}
function gameOver() {
gameRunning = false;
document.getElementById('final-score').textContent = score;
document.getElementById('game-over-screen').style.display = 'flex';
// Play game over sound
gameOverSound();
}
function renderGame() {
// Clear canvas
ctx.clearRect(0, 0, gameWidth, gameHeight);
// Draw stars
for (const star of backgroundStars) {
ctx.fillStyle = `rgba(255, 255, 255, ${star.size / 2.5})`;
ctx.fillRect(star.x, star.y, star.size, star.size);
}
// Draw player
if (player) {
player.render(ctx);
}
// Draw bullets
ctx.fillStyle = '#ffdd33';
for (const bullet of bullets) {
ctx.fillRect(bullet.x, bullet.y, bullet.width, bullet.height);
}
// Draw enemies
for (const enemy of enemies) {
enemy.type.render(ctx, enemy.x, enemy.y);
}
// Draw explosions
for (const explosion of explosions) {
const progress = explosion.currentFrame / explosion.lifetime;
const radius = explosion.size * (1 - progress);
// Create radial gradient for explosion
const gradient = ctx.createRadialGradient(
explosion.x, explosion.y, 0,
explosion.x, explosion.y, radius
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(0.4, 'rgba(255, 200, 50, 0.8)');
gradient.addColorStop(0.6, 'rgba(255, 100, 50, 0.6)');
gradient.addColorStop(1, 'rgba(255, 50, 50, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(explosion.x, explosion.y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
function gameLoop(timestamp) {
if (!gameRunning) return;
const deltaTime = timestamp - lastTimestamp;
lastTimestamp = timestamp;
// Spawn enemies
spawnEnemy(timestamp);
// Update game objects
updatePlayer(deltaTime);
updateBullets();
updateEnemies(deltaTime);
updateExplosions();
updateStars(deltaTime);
// Check collisions
checkCollisions();
// Render game
renderGame();
// Continue game loop
requestAnimationFrame(gameLoop);
}
// Initialize the game when the page loads
window.addEventListener('load', initGame);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment