Add mobile-friendly web Snake game on port 10000

Node.js HTTP server serving a canvas Snake game optimised for phones:
inline onclick handlers for reliable touch, swipe support, big d-pad,
no-cache headers, and responsive canvas sizing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
edo 2026-04-30 22:52:29 +00:00
parent 6e2fc5a05a
commit e896912c04
2 changed files with 366 additions and 0 deletions

329
public/index.html Normal file
View File

@ -0,0 +1,329 @@
<!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>Snake</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: #0a0a0f;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 10px 8px 6px;
font-family: 'Courier New', monospace;
color: #e0e0e0;
user-select: none;
gap: 6px;
}
h1 {
font-size: clamp(1.3rem, 5.5vw, 2.2rem);
letter-spacing: 0.25em;
color: #4eff91;
text-shadow: 0 0 20px #4eff9188;
}
#scoreboard {
display: flex;
gap: 2rem;
font-size: clamp(0.8rem, 3.5vw, 1rem);
letter-spacing: 0.12em;
}
#scoreboard span { color: #888; }
#scoreboard strong { color: #ffe44e; }
#canvas-wrap {
position: relative;
border: 2px solid #4eff9155;
border-radius: 4px;
flex-shrink: 0;
}
canvas { display: block; }
#overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(10,10,15,0.9);
border-radius: 3px;
gap: 10px;
cursor: pointer;
}
#overlay.hidden { display: none; }
#overlay h2 {
font-size: clamp(1.3rem, 5.5vw, 1.8rem);
letter-spacing: 0.2em;
color: #ff4e4e;
}
#overlay p { color: #aaa; font-size: clamp(0.8rem, 3vw, 0.9rem); }
#btn {
padding: 12px 32px;
font-family: inherit;
font-size: clamp(0.9rem, 4vw, 1.1rem);
letter-spacing: 0.2em;
background: #4eff91;
color: #0a0a0f;
border: none;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(78,255,145,0.3);
}
#btn:active { opacity: 0.8; }
/* D-pad */
#dpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 8px;
flex-shrink: 0;
}
@media (min-width: 600px) and (pointer: fine) { #dpad { display: none; } }
.db {
background: #1a1a2e;
border: 2px solid #4eff9166;
border-radius: 50%;
color: #4eff91;
font-size: clamp(1.1rem, 4.5vw, 1.6rem);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(78,255,145,0.2);
width: 100%;
aspect-ratio: 1;
}
.db:active { background: #4eff9133; border-color: #4eff91; }
.blank { visibility: hidden; }
</style>
</head>
<body>
<h1>SNAKE</h1>
<div id="scoreboard">
<div><span>SCORE </span><strong id="score">0</strong></div>
<div><span>BEST &nbsp;</span><strong id="best">0</strong></div>
</div>
<div id="canvas-wrap">
<canvas id="c"></canvas>
<div id="overlay" onclick="doStart()">
<h2 id="ot">SNAKE</h2>
<p id="os">tap to start</p>
<button id="btn" onclick="doStart()">START</button>
</div>
</div>
<div id="dpad">
<div class="blank"></div>
<button class="db" onclick="doDir('up')"></button>
<div class="blank"></div>
<button class="db" onclick="doDir('left')"></button>
<div class="blank"></div>
<button class="db" onclick="doDir('right')"></button>
<div class="blank"></div>
<button class="db" onclick="doDir('down')"></button>
<div class="blank"></div>
</div>
<script>
const COLS = 20, ROWS = 20;
// Compute cell size to fit screen
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
function computeCell() {
const vw = window.innerWidth, vh = window.innerHeight;
const dpadSize = vw < 600 ? Math.min(vw * 0.72, 260) : 0;
const availW = Math.min(vw - 16, 540);
const availH = vh - 120 - dpadSize - 20; // header + dpad + gaps
return Math.max(10, Math.floor(Math.min(availW / COLS, availH / ROWS)));
}
let CELL = computeCell();
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
// Also size the dpad
const dpad = document.getElementById('dpad');
const dpadSize = Math.min(window.innerWidth * 0.72, 260);
dpad.style.width = dpadSize + 'px';
dpad.style.height = dpadSize + 'px';
let snake, dir, nextDir, food, score, hiscore = 0, timer, alive = false, foodTick = 0;
const overlay = document.getElementById('overlay');
const ot = document.getElementById('ot');
const os = document.getElementById('os');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
function ri(n) { return Math.floor(Math.random() * n); }
function spawnFood() {
const occ = new Set(snake.map(s => s.x + ',' + s.y));
let p;
do { p = { x: ri(COLS), y: ri(ROWS) }; } while (occ.has(p.x + ',' + p.y));
food = p;
}
// Exposed globally so inline onclick works
window.doStart = function() {
if (alive) return;
const mx = COLS >> 1, my = ROWS >> 1;
snake = [{ x: mx, y: my }, { x: mx-1, y: my }, { x: mx-2, y: my }];
dir = { x: 1, y: 0 };
nextDir = { x: 1, y: 0 };
score = 0;
scoreEl.textContent = '0';
alive = true;
overlay.classList.add('hidden');
spawnFood();
clearInterval(timer);
timer = setInterval(tick, 120);
};
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
window.doDir = function(d) {
if (!alive) { doStart(); return; }
const nd = DIRS[d];
if (nd.x === -dir.x && nd.y === -dir.y) return;
nextDir = nd;
};
function tick() {
foodTick++;
dir = nextDir;
const h = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
if (h.x < 0 || h.x >= COLS || h.y < 0 || h.y >= ROWS ||
snake.some(s => s.x === h.x && s.y === h.y)) {
clearInterval(timer);
alive = false;
draw();
ot.textContent = 'GAME OVER';
os.textContent = 'Score: ' + score;
document.getElementById('btn').textContent = 'PLAY AGAIN';
overlay.classList.remove('hidden');
return;
}
snake.unshift(h);
if (h.x === food.x && h.y === food.y) {
score++;
if (score > hiscore) { hiscore = score; bestEl.textContent = hiscore; }
scoreEl.textContent = score;
spawnFood();
} else {
snake.pop();
}
draw();
}
function draw() {
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Grid lines
ctx.strokeStyle = '#ffffff0a';
ctx.lineWidth = 1;
for (let x = 0; x <= COLS; x++) { ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,canvas.height); ctx.stroke(); }
for (let y = 0; y <= ROWS; y++) { ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(canvas.width,y*CELL); ctx.stroke(); }
// Food
const pulse = 0.6 + 0.4 * Math.sin(foodTick * 0.25);
const fx = food.x*CELL + CELL/2, fy = food.y*CELL + CELL/2;
const gr = ctx.createRadialGradient(fx, fy, 1, fx, fy, CELL * pulse);
gr.addColorStop(0, '#ff4e4ecc'); gr.addColorStop(1, '#ff4e4e00');
ctx.fillStyle = gr;
ctx.fillRect(food.x*CELL - CELL, food.y*CELL - CELL, CELL*3, CELL*3);
ctx.fillStyle = '#ff4e4e';
ctx.beginPath(); ctx.arc(fx, fy, CELL*0.32, 0, Math.PI*2); ctx.fill();
// Snake
snake.forEach((s, i) => {
const x = s.x*CELL, y = s.y*CELL, p = i ? 2 : 1, r = Math.max(2, CELL*0.22);
ctx.fillStyle = i === 0 ? '#4eff91' : '#29b861';
ctx.shadowColor = i === 0 ? '#4eff91' : 'transparent';
ctx.shadowBlur = i === 0 ? 10 : 0;
rr(x+p, y+p, CELL-p*2, CELL-p*2, r);
ctx.fill();
});
ctx.shadowBlur = 0;
}
function rr(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x+r, y); ctx.lineTo(x+w-r, y); ctx.quadraticCurveTo(x+w,y,x+w,y+r);
ctx.lineTo(x+w, y+h-r); ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
ctx.lineTo(x+r, y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-r);
ctx.lineTo(x, y+r); ctx.quadraticCurveTo(x,y,x+r,y);
ctx.closePath();
}
// Keyboard
const KEYS = { ArrowUp:'up',ArrowDown:'down',ArrowLeft:'left',ArrowRight:'right',w:'up',s:'down',a:'left',d:'right' };
document.addEventListener('keydown', e => {
const d = KEYS[e.key] || KEYS[e.key.toLowerCase()];
if (!d) return;
e.preventDefault();
doDir(d);
});
// Swipe on canvas
let sw = null;
canvas.addEventListener('touchstart', e => { sw = { x: e.touches[0].clientX, y: e.touches[0].clientY }; }, { passive: true });
canvas.addEventListener('touchend', e => {
if (!sw) return;
const dx = e.changedTouches[0].clientX - sw.x;
const dy = e.changedTouches[0].clientY - sw.y;
sw = null;
if (Math.abs(dx) < 15 && Math.abs(dy) < 15) { doStart(); return; }
doDir(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up'));
}, { passive: true });
// Touchstart on dpad for instant response (no 300ms delay)
document.querySelectorAll('.db[onclick]').forEach(b => {
b.addEventListener('touchstart', e => {
e.preventDefault();
b.onclick();
}, { passive: false });
});
// Draw blank initial frame
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
window.addEventListener('resize', () => {
CELL = computeCell();
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
if (snake) draw();
});
</script>
</body>
</html>

37
server.js Normal file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env node
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = 10000;
const MIME = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.ico': 'image/x-icon',
};
const server = http.createServer((req, res) => {
let filePath = req.url === '/' ? '/index.html' : req.url;
filePath = path.join(__dirname, 'public', filePath);
const ext = path.extname(filePath);
const mime = MIME[ext] || 'text/plain';
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'no-store' });
res.end(data);
});
});
server.listen(PORT, () => {
console.log(`Snake running at http://localhost:${PORT}`);
});