feat: snake game streaming via relay

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)
This commit is contained in:
enzotar 2026-02-26 20:46:53 -08:00
parent f4e5ace37c
commit f7f7363230
4 changed files with 513 additions and 0 deletions

81
examples/game-reaction.ds Normal file
View file

@ -0,0 +1,81 @@
-- DreamStack Reaction Game (Streamed)
-- Whack-a-mole style: click the target before it moves!
-- State is streamed so viewers can watch live.
--
-- Run with:
-- Tab 1: cargo run -p ds-stream (relay)
-- Tab 2: dreamstack stream examples/game-reaction.ds (source/player)
-- Tab 3: open game-viewer build in browser (viewer)
import { Card } from "../registry/components/card"
import { Badge } from "../registry/components/badge"
-- Game state
let score = 0
let misses = 0
let target = 5
let round = 0
-- Timer: move target every 2 seconds
every 2000 -> round += 1
-- Derived: target position follows round
let pos = round % 9 + 1
-- Stream game state
stream reaction_game on "ws://localhost:9100/peer/game" {
mode: signal,
output: score, misses, round, pos
}
view game = column [
text "🎯 Reaction Game" { variant: "title" }
text "Click the lit cell before it moves!" { variant: "subtitle" }
-- Score bar
row [
Badge { label: "Score: {score}", variant: "success" }
Badge { label: "Misses: {misses}", variant: "error" }
Badge { label: "Round: {round}", variant: "info" }
]
-- 3x3 Grid
Card { title: "Game Board" } [
row [
match pos
1 -> button "🎯" { click: score += 1, variant: "primary" }
_ -> button "·" { click: misses += 1, variant: "secondary" }
match pos
2 -> button "🎯" { click: score += 1, variant: "primary" }
_ -> button "·" { click: misses += 1, variant: "secondary" }
match pos
3 -> button "🎯" { click: score += 1, variant: "primary" }
_ -> button "·" { click: misses += 1, variant: "secondary" }
]
row [
match pos
4 -> button "🎯" { click: score += 1, variant: "primary" }
_ -> button "·" { click: misses += 1, variant: "secondary" }
match pos
5 -> button "🎯" { click: score += 1, variant: "primary" }
_ -> button "·" { click: misses += 1, variant: "secondary" }
match pos
6 -> button "🎯" { click: score += 1, variant: "primary" }
_ -> button "·" { click: misses += 1, variant: "secondary" }
]
row [
match pos
7 -> button "🎯" { click: score += 1, variant: "primary" }
_ -> button "·" { click: misses += 1, variant: "secondary" }
match pos
8 -> button "🎯" { click: score += 1, variant: "primary" }
_ -> button "·" { click: misses += 1, variant: "secondary" }
match pos
9 -> button "🎯" { click: score += 1, variant: "primary" }
_ -> button "·" { click: misses += 1, variant: "secondary" }
]
]
-- Reset
button "🔄 Reset Game" { click: score = 0; misses = 0, variant: "ghost" }
]

82
examples/game-snake.ds Normal file
View file

@ -0,0 +1,82 @@
-- DreamStack Snake Game (Streamed)
-- Move the snake with arrow buttons to eat the food!
-- Each button press moves one step.
--
-- Run with:
-- Tab 1: cargo run -p ds-stream (relay)
-- Tab 2: dreamstack stream examples/game-snake.ds (source/player)
-- Tab 3: open the viewer HTML (viewer)
import { Card } from "../registry/components/card"
import { Badge } from "../registry/components/badge"
-- Snake head position (0-indexed on a conceptual 8x8 grid)
let headX = 4
let headY = 4
let score = 0
let moves = 0
-- Food position (changes when eaten — player sets manually for now)
let foodX = 2
let foodY = 2
-- Timer for excitement
let ticks = 0
every 1000 -> ticks += 1
-- Stream the game
stream snake on "ws://localhost:9100/peer/game" {
mode: signal,
output: headX, headY, score, moves, foodX, foodY, ticks
}
view snake_game = column [
text "🐍 Snake Game" { variant: "title" }
text "Move with arrows • Eat the 🍎" { variant: "subtitle" }
-- Score bar
row [
Badge { label: "Score: {score}", variant: "success" }
Badge { label: "Moves: {moves}", variant: "info" }
Badge { label: "Time: {ticks}s", variant: "warning" }
Badge { label: "🐍 ({headX},{headY})", variant: "default" }
Badge { label: "🍎 ({foodX},{foodY})", variant: "error" }
]
-- Game board: 8 rows rendered with match on headY
Card { title: "Board" } [
-- Row 0
row [
when headY == 0 -> when headX == 0 -> text "🟩"
when headY == 0 -> when headX == 1 -> text "🟩"
when headY == 0 -> when headX == 2 -> text "🟩"
when foodY == 0 -> when foodX == 0 -> text "🍎"
text "Row 0: Snake={headY == 0}"
]
]
-- Directional controls
Card { title: "Controls" } [
row [
text " "
button "⬆️" { click: headY -= 1; moves += 1, variant: "primary" }
text " "
]
row [
button "⬅️" { click: headX -= 1; moves += 1, variant: "primary" }
button "⏹️" { variant: "secondary" }
button "➡️" { click: headX += 1; moves += 1, variant: "primary" }
]
row [
text " "
button "⬇️" { click: headY += 1; moves += 1, variant: "primary" }
text " "
]
]
-- Quick food placement
row [
button "🍎 Move Food" { click: foodX = ticks % 7; foodY = (ticks + 3) % 7, variant: "ghost" }
button "🔄 Reset" { click: headX = 4; headY = 4; score = 0; moves = 0, variant: "destructive" }
]
]

314
examples/game-snake.html Normal file
View file

@ -0,0 +1,314 @@
<!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>

36
examples/game-viewer.ds Normal file
View file

@ -0,0 +1,36 @@
-- DreamStack Snake Viewer (Receiver)
-- Watches the snake game live via streaming relay.
--
-- Run with:
-- Tab 1: cargo run -p ds-stream (relay on :9100)
-- Tab 2: open examples/game-snake.html (player)
-- Tab 3: open this build in browser (viewer)
import { Card } from "../registry/components/card"
import { Badge } from "../registry/components/badge"
-- Connect to the snake game stream
let game = stream from "ws://localhost:9100/stream/snake"
view viewer = column [
text "👁️ Snake Spectator" { variant: "title" }
text "Watching the snake game live via relay" { variant: "subtitle" }
row [
Badge { label: "LIVE 🔴", variant: "error" }
Badge { label: "Score: {game.score}", variant: "success" }
Badge { label: "Length: {game.length}", variant: "info" }
Badge { label: "Speed: {game.speed}ms", variant: "warning" }
]
Card { title: "Game State" } [
text "🐍 Head: ({game.headX}, {game.headY})" { variant: "title" }
text "🍎 Food: ({game.foodX}, {game.foodY})"
text "Direction: ({game.dirX}, {game.dirY})"
when game.alive ->
Badge { label: "PLAYING", variant: "success" }
else ->
Badge { label: "GAME OVER", variant: "error" }
]
]