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:
parent
f4e5ace37c
commit
f7f7363230
4 changed files with 513 additions and 0 deletions
81
examples/game-reaction.ds
Normal file
81
examples/game-reaction.ds
Normal 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
82
examples/game-snake.ds
Normal 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
314
examples/game-snake.html
Normal 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
36
examples/game-viewer.ds
Normal 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" }
|
||||
]
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue