game-snake.html — Full canvas Snake game: - 20x20 grid with gradient snake (eyes, body fade) - Keyboard (WASD/arrows) + button controls - Wall wrapping, self-collision detection - Speed increases on food eat (200ms → 80ms min) - Game over screen with restart - Streams every frame via DreamStack relay (0x31 SignalDiff) - Periodic auto-sync (0x30 every 50 frames) - Graceful fallback when relay unavailable game-viewer.ds — DreamStack receiver: - Connects to ws://localhost:9100/stream/snake - Shows live score, length, speed, position - PLAYING/GAME OVER status badge game-snake.ds — DreamStack source (simplified) game-reaction.ds — Reaction game (bonus)
314 lines
10 KiB
HTML
314 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>🐍 DreamStack Snake — Streamed</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Inter', -apple-system, system-ui, sans-serif;
|
|
background: #0a0a0f;
|
|
color: #e2e8f0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
padding: 1.5rem;
|
|
}
|
|
h1 { font-size: 2rem; font-weight: 800; margin-bottom: 0.25rem; }
|
|
.subtitle { color: #94a3b8; font-size: 0.875rem; margin-bottom: 1rem; }
|
|
.scores {
|
|
display: flex; gap: 0.75rem; margin-bottom: 1rem;
|
|
}
|
|
.badge {
|
|
display: inline-flex; align-items: center;
|
|
padding: 0.3rem 0.9rem; border-radius: 9999px;
|
|
font-size: 0.8rem; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.025em;
|
|
}
|
|
.badge-success { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); }
|
|
.badge-warning { background: rgba(234,179,8,0.15); color: #facc15; border: 1px solid rgba(234,179,8,0.2); }
|
|
.badge-error { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.2); }
|
|
.badge-info { background: rgba(56,189,248,0.15); color: #38bdf8; border: 1px solid rgba(56,189,248,0.2); }
|
|
.badge-live { background: rgba(239,68,68,0.2); color: #f87171; border: 1px solid rgba(239,68,68,0.3); animation: pulse 1.5s infinite; }
|
|
@keyframes pulse { 0%,100%{ opacity:1; } 50%{ opacity:0.5; } }
|
|
|
|
canvas {
|
|
border: 2px solid rgba(99,102,241,0.3);
|
|
border-radius: 12px;
|
|
background: rgba(255,255,255,0.02);
|
|
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
|
|
}
|
|
.controls {
|
|
margin-top: 1rem; display: flex; gap: 0.5rem; flex-direction: column; align-items: center;
|
|
}
|
|
.controls .row { display: flex; gap: 0.5rem; }
|
|
.btn {
|
|
width: 56px; height: 56px; border-radius: 12px;
|
|
border: 1px solid rgba(99,102,241,0.3);
|
|
background: rgba(99,102,241,0.15);
|
|
color: #a5b4fc; font-size: 1.5rem;
|
|
cursor: pointer; transition: all 0.15s;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.btn:hover { background: rgba(99,102,241,0.3); transform: scale(1.05); }
|
|
.btn:active { transform: scale(0.95); }
|
|
.btn-reset {
|
|
width: auto; padding: 0 1.5rem; font-size: 0.9rem; margin-top: 0.5rem;
|
|
background: rgba(239,68,68,0.15); border-color: rgba(239,68,68,0.3); color: #f87171;
|
|
}
|
|
.status { margin-top: 0.75rem; font-size: 0.75rem; color: #64748b; }
|
|
.game-over {
|
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
|
background: rgba(0,0,0,0.85); border: 2px solid rgba(239,68,68,0.4);
|
|
border-radius: 16px; padding: 2rem 3rem; text-align: center;
|
|
backdrop-filter: blur(8px); z-index: 10;
|
|
}
|
|
.game-over h2 { font-size: 1.5rem; color: #f87171; margin-bottom: 0.5rem; }
|
|
.game-over p { color: #94a3b8; margin-bottom: 1rem; }
|
|
.wrapper { position: relative; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>🐍 DreamStack Snake</h1>
|
|
<p class="subtitle">Arrow keys or buttons to move · Streamed live via relay</p>
|
|
|
|
<div class="scores" id="scores">
|
|
<span class="badge badge-success" id="score-badge">Score: 0</span>
|
|
<span class="badge badge-info" id="speed-badge">Speed: 200ms</span>
|
|
<span class="badge badge-warning" id="length-badge">Length: 3</span>
|
|
<span class="badge badge-live" id="stream-badge">⏳ Connecting…</span>
|
|
</div>
|
|
|
|
<div class="wrapper" id="wrapper">
|
|
<canvas id="board" width="400" height="400"></canvas>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<div class="row"><button class="btn" onclick="setDir(0,-1)">⬆️</button></div>
|
|
<div class="row">
|
|
<button class="btn" onclick="setDir(-1,0)">⬅️</button>
|
|
<button class="btn" style="opacity:0.3;cursor:default;">⏹️</button>
|
|
<button class="btn" onclick="setDir(1,0)">➡️</button>
|
|
</div>
|
|
<div class="row"><button class="btn" onclick="setDir(0,1)">⬇️</button></div>
|
|
<button class="btn btn-reset" onclick="resetGame()">🔄 Reset</button>
|
|
</div>
|
|
|
|
<p class="status" id="status">Connecting to relay…</p>
|
|
|
|
<script>
|
|
// ── Game Config ──
|
|
const GRID = 20;
|
|
const CELL = 20; // 400/20
|
|
let snake = [{x:10,y:10},{x:9,y:10},{x:8,y:10}];
|
|
let dir = {x:1,y:0};
|
|
let nextDir = {x:1,y:0};
|
|
let food = {x:15,y:10};
|
|
let score = 0;
|
|
let speed = 200;
|
|
let alive = true;
|
|
let timer = null;
|
|
|
|
const canvas = document.getElementById('board');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// ── Drawing ──
|
|
function draw() {
|
|
// Background
|
|
ctx.fillStyle = '#0f0f1a';
|
|
ctx.fillRect(0,0,400,400);
|
|
// Grid lines
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
|
for(let i=0;i<=GRID;i++){
|
|
ctx.beginPath(); ctx.moveTo(i*CELL,0); ctx.lineTo(i*CELL,400); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(0,i*CELL); ctx.lineTo(400,i*CELL); ctx.stroke();
|
|
}
|
|
// Food
|
|
ctx.fillStyle = '#ef4444';
|
|
ctx.beginPath();
|
|
ctx.arc(food.x*CELL+CELL/2, food.y*CELL+CELL/2, CELL/2-2, 0, Math.PI*2);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#fca5a5';
|
|
ctx.font = '14px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('🍎', food.x*CELL+CELL/2, food.y*CELL+CELL/2+5);
|
|
// Snake body
|
|
snake.forEach((seg, i) => {
|
|
if(i === 0) {
|
|
// Head — bright gradient
|
|
const g = ctx.createRadialGradient(seg.x*CELL+CELL/2,seg.y*CELL+CELL/2,2,seg.x*CELL+CELL/2,seg.y*CELL+CELL/2,CELL/2);
|
|
g.addColorStop(0,'#86efac');
|
|
g.addColorStop(1,'#22c55e');
|
|
ctx.fillStyle = g;
|
|
} else {
|
|
const fade = 1 - (i / (snake.length + 5)) * 0.6;
|
|
ctx.fillStyle = `rgba(74,222,128,${fade})`;
|
|
}
|
|
roundRect(ctx, seg.x*CELL+1, seg.y*CELL+1, CELL-2, CELL-2, 4);
|
|
ctx.fill();
|
|
});
|
|
// Eyes on head
|
|
const h = snake[0];
|
|
ctx.fillStyle = '#0f0f1a';
|
|
const ex = dir.x === 0 ? 3 : (dir.x > 0 ? 5 : -1);
|
|
const ey = dir.y === 0 ? 3 : (dir.y > 0 ? 5 : -1);
|
|
ctx.beginPath();ctx.arc(h.x*CELL+CELL/2-3+ex,h.y*CELL+CELL/2-3+ey,2,0,Math.PI*2);ctx.fill();
|
|
ctx.beginPath();ctx.arc(h.x*CELL+CELL/2+3+ex,h.y*CELL+CELL/2-3+ey,2,0,Math.PI*2);ctx.fill();
|
|
}
|
|
|
|
function roundRect(ctx,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();
|
|
}
|
|
|
|
// ── Game Logic ──
|
|
function tick() {
|
|
if(!alive) return;
|
|
dir = {...nextDir};
|
|
const head = {x: snake[0].x + dir.x, y: snake[0].y + dir.y};
|
|
// Wall collision (wrap around)
|
|
head.x = (head.x + GRID) % GRID;
|
|
head.y = (head.y + GRID) % GRID;
|
|
// Self collision
|
|
if(snake.some(s => s.x === head.x && s.y === head.y)) {
|
|
alive = false;
|
|
draw();
|
|
showGameOver();
|
|
broadcastState();
|
|
return;
|
|
}
|
|
snake.unshift(head);
|
|
// Eat food
|
|
if(head.x === food.x && head.y === food.y) {
|
|
score++;
|
|
spawnFood();
|
|
// Speed up
|
|
speed = Math.max(80, speed - 5);
|
|
clearInterval(timer);
|
|
timer = setInterval(tick, speed);
|
|
} else {
|
|
snake.pop();
|
|
}
|
|
draw();
|
|
updateUI();
|
|
broadcastState();
|
|
}
|
|
|
|
function spawnFood() {
|
|
do {
|
|
food = {x: Math.floor(Math.random()*GRID), y: Math.floor(Math.random()*GRID)};
|
|
} while(snake.some(s => s.x === food.x && s.y === food.y));
|
|
}
|
|
|
|
function setDir(x, y) {
|
|
// Prevent 180° turns
|
|
if(dir.x === -x && dir.y === -y) return;
|
|
nextDir = {x, y};
|
|
}
|
|
|
|
function resetGame() {
|
|
snake = [{x:10,y:10},{x:9,y:10},{x:8,y:10}];
|
|
dir = {x:1,y:0}; nextDir = {x:1,y:0};
|
|
score = 0; speed = 200; alive = true;
|
|
spawnFood();
|
|
const go = document.getElementById('game-over');
|
|
if(go) go.remove();
|
|
clearInterval(timer);
|
|
timer = setInterval(tick, speed);
|
|
draw(); updateUI();
|
|
}
|
|
|
|
function showGameOver() {
|
|
const d = document.createElement('div');
|
|
d.id = 'game-over';
|
|
d.className = 'game-over';
|
|
d.innerHTML = `<h2>Game Over!</h2><p>Score: ${score} · Length: ${snake.length}</p>
|
|
<button class="btn btn-reset" onclick="resetGame()">🔄 Play Again</button>`;
|
|
document.getElementById('wrapper').appendChild(d);
|
|
}
|
|
|
|
function updateUI() {
|
|
document.getElementById('score-badge').textContent = `Score: ${score}`;
|
|
document.getElementById('speed-badge').textContent = `Speed: ${speed}ms`;
|
|
document.getElementById('length-badge').textContent = `Length: ${snake.length}`;
|
|
}
|
|
|
|
// ── Keyboard ──
|
|
document.addEventListener('keydown', e => {
|
|
switch(e.key) {
|
|
case 'ArrowUp': case 'w': setDir(0,-1); break;
|
|
case 'ArrowDown': case 's': setDir(0,1); break;
|
|
case 'ArrowLeft': case 'a': setDir(-1,0); break;
|
|
case 'ArrowRight': case 'd': setDir(1,0); break;
|
|
case 'r': resetGame(); break;
|
|
}
|
|
});
|
|
|
|
// ── DreamStack Relay Streaming ──
|
|
let ws = null;
|
|
let diffBatch = 0;
|
|
|
|
function connectRelay() {
|
|
try {
|
|
ws = new WebSocket('ws://localhost:9100/peer/snake');
|
|
ws.binaryType = 'arraybuffer';
|
|
ws.onopen = () => {
|
|
document.getElementById('stream-badge').textContent = '🔴 STREAMING';
|
|
document.getElementById('status').textContent = 'Connected to relay — game is streaming live!';
|
|
// Send initial sync
|
|
broadcastState();
|
|
};
|
|
ws.onclose = () => {
|
|
document.getElementById('stream-badge').textContent = '⏳ Reconnecting…';
|
|
document.getElementById('status').textContent = 'Relay disconnected. Retrying in 3s…';
|
|
setTimeout(connectRelay, 3000);
|
|
};
|
|
ws.onerror = () => {
|
|
document.getElementById('status').textContent = 'Relay not available. Game works offline. Start relay: cargo run -p ds-stream';
|
|
};
|
|
} catch(e) {
|
|
document.getElementById('status').textContent = 'Relay unavailable — playing offline';
|
|
}
|
|
}
|
|
|
|
function broadcastState() {
|
|
if(!ws || ws.readyState !== 1) return;
|
|
// DreamStack signal format: 0x31 (SignalDiff) + JSON
|
|
const state = {
|
|
score, speed, length: snake.length, alive,
|
|
headX: snake[0]?.x ?? 0, headY: snake[0]?.y ?? 0,
|
|
foodX: food.x, foodY: food.y,
|
|
dirX: dir.x, dirY: dir.y,
|
|
// Encode snake body as flat array for viewer
|
|
body: snake.map(s => s.x * GRID + s.y).join(',')
|
|
};
|
|
const json = JSON.stringify(state);
|
|
const enc = new TextEncoder();
|
|
const payload = enc.encode(json);
|
|
const frame = new Uint8Array(1 + payload.length);
|
|
frame[0] = 0x31; // SignalDiff
|
|
frame.set(payload, 1);
|
|
ws.send(frame);
|
|
diffBatch++;
|
|
// Periodic full sync
|
|
if(diffBatch % 50 === 0) {
|
|
const syncFrame = new Uint8Array(1 + payload.length);
|
|
syncFrame[0] = 0x30; // SignalSync
|
|
syncFrame.set(payload, 1);
|
|
ws.send(syncFrame);
|
|
}
|
|
}
|
|
|
|
// ── Start ──
|
|
draw();
|
|
timer = setInterval(tick, speed);
|
|
connectRelay();
|
|
</script>
|
|
</body>
|
|
</html>
|