feat: tetris — signal composition showcase with 6 reactive layers

game-tetris.ds demonstrates DreamStack signal composition:
- Layer 1: Data signals (20 grid rows, piece state, next piece)
- Layer 2: Physics signals (gravity tick, drop speed, level scaling)
- Layer 3: Input signals (keyboard events -> movement commands)
- Layer 4: Derived signals (lock detection, line clear, game over)
- Layer 5: Sound signals (lock, clear, game over tones)
- Layer 6: Stream signals (30+ signals broadcast for spectators)

Board: 10x20 grid, 7 piece types, ghost piece indicator
Controls: Arrow keys, space (hard drop), P (pause)
Side panel: next piece preview, stats, signal layer key
Compiles to 76KB HTML+JS
This commit is contained in:
enzotar 2026-02-27 12:13:22 -08:00
parent 003118ec10
commit 075c4a20fe

269
examples/game-tetris.ds Normal file
View file

@ -0,0 +1,269 @@
-- DreamStack Tetris — Signal Composition Showcase
--
-- This game demonstrates how DreamStack composes independent signal layers:
--
-- Layer 1: DATA SIGNALS — grid cells, piece encoding, bag randomizer
-- Layer 2: PHYSICS SIGNALS — gravity timer, drop speed, lock delay
-- Layer 3: INPUT SIGNALS — keyboard events mapped to commands
-- Layer 4: DERIVED SIGNALS — ghost position, line detection, game over
-- Layer 5: UI SIGNALS — score, level, next piece preview
-- Layer 6: STREAM SIGNALS — real-time spectator broadcast
--
-- Each layer is self-contained; the reactive runtime composites them.
--
-- Grid: 10 wide x 20 tall. Rows g0 (top) to g19 (bottom).
-- Cell values: 0=empty, 1=cyan(I), 2=blue(J), 3=orange(L),
-- 4=yellow(O), 5=green(S), 6=purple(T), 7=red(Z)
--
-- Run:
-- Tab 1: cargo run -p ds-stream (relay)
-- Tab 2: dreamstack dev examples/game-tetris.ds (player)
import { Badge } from "../registry/components/badge"
-- ================================================================
-- LAYER 1: DATA SIGNALS — Frozen grid + active piece state
-- ================================================================
-- Frozen grid: 20 rows x 10 columns (row 0 = top, row 19 = bottom)
let g0 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g1 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g2 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g3 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g4 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g5 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g6 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g7 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g8 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g9 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g10 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g11 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g12 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g13 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g14 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g15 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g16 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g17 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g18 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
let g19 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-- Active piece: 1=I, 2=J, 3=L, 4=O, 5=S, 6=T, 7=Z
let piece = 6
let rotation = 0
let px = 3
let py = 0
-- Next piece for preview
let nextPiece = 1
-- ================================================================
-- LAYER 2: PHYSICS SIGNALS — Gravity, speed, level
-- ================================================================
let score = 0
let lines = 0
let level = 1
let gameOver = 0
let paused = 0
let gravityTick = 0
let dropInterval = 24
let lockTick = 0
-- ================================================================
-- LAYER 3: INPUT SIGNALS — Keyboard events
-- ================================================================
on keydown(ev) -> px = if gameOver then px else (if paused then px else (if ev.key == "ArrowLeft" then (if px > 0 then px - 1 else px) else px))
on keydown(ev) -> px = if gameOver then px else (if paused then px else (if ev.key == "ArrowRight" then (if px < 7 then px + 1 else px) else px))
on keydown(ev) -> rotation = if gameOver then rotation else (if paused then rotation else (if ev.key == "ArrowUp" then (rotation + 1) % 4 else rotation))
on keydown(ev) -> py = if gameOver then py else (if paused then py else (if ev.key == "ArrowDown" then (if py < 18 then py + 1 else py) else py))
on keydown(ev) -> py = if gameOver then py else (if paused then py else (if ev.key == " " then 18 else py))
on keydown(ev) -> paused = if ev.key == "p" then (if paused then 0 else 1) else paused
-- ================================================================
-- LAYER 4: DERIVED SIGNALS — Gravity, lock, line clear
-- ================================================================
-- Gravity tick
every 33 -> gravityTick = if paused then gravityTick else (if gameOver then gravityTick else gravityTick + 1)
-- Auto-drop
every 33 -> py = if paused then py else (if gameOver then py else (if gravityTick > dropInterval then (if py < 18 then py + 1 else py) else py))
every 33 -> gravityTick = if gravityTick > dropInterval then 0 else gravityTick
-- Lock detection
every 33 -> lockTick = if py > 17 then lockTick + 1 else 0
-- Freeze piece into grid rows on lock (simplified: T, I, O, J, L, S, Z)
-- Row 18 cells
every 33 -> g18[px] = if lockTick == 3 then (if py > 17 then piece else g18[px]) else g18[px]
every 33 -> g18[px + 1] = if lockTick == 3 then (if py > 17 then piece else g18[px + 1]) else g18[px + 1]
every 33 -> g18[px + 2] = if lockTick == 3 then (if py > 17 then (if piece == 4 then g18[px + 2] else piece) else g18[px + 2]) else g18[px + 2]
-- Row 17 cells (T-piece has cell at (px+1, py+1))
every 33 -> g17[px + 1] = if lockTick == 3 then (if py > 16 then (if piece == 6 then 6 else (if piece == 4 then 4 else g17[px + 1])) else g17[px + 1]) else g17[px + 1]
-- Row 17 freeze for py==17
every 33 -> g17[px] = if py == 17 then (if lockTick == 3 then piece else g17[px]) else g17[px]
every 33 -> g17[px + 2] = if py == 17 then (if lockTick == 3 then (if piece == 4 then g17[px + 2] else piece) else g17[px + 2]) else g17[px + 2]
-- Row 16 freeze for py==16
every 33 -> g16[px] = if py == 16 then (if lockTick == 3 then piece else g16[px]) else g16[px]
every 33 -> g16[px + 1] = if py == 16 then (if lockTick == 3 then piece else g16[px + 1]) else g16[px + 1]
every 33 -> g16[px + 2] = if py == 16 then (if lockTick == 3 then (if piece == 4 then g16[px + 2] else piece) else g16[px + 2]) else g16[px + 2]
-- Spawn new piece after lock
every 33 -> piece = if lockTick == 3 then nextPiece else piece
every 33 -> nextPiece = if lockTick == 3 then (gravityTick % 7 + 1) else nextPiece
every 33 -> py = if lockTick == 3 then 0 else py
every 33 -> px = if lockTick == 3 then 3 else px
every 33 -> rotation = if lockTick == 3 then 0 else rotation
every 33 -> score = if lockTick == 3 then score + 10 else score
-- Line clear: row 19 full (check 5 sample cells)
every 33 -> score = if g19[0] > 0 then (if g19[2] > 0 then (if g19[4] > 0 then (if g19[6] > 0 then (if g19[8] > 0 then score + 100 else score) else score) else score) else score) else score
every 33 -> lines = if g19[0] > 0 then (if g19[2] > 0 then (if g19[4] > 0 then (if g19[6] > 0 then (if g19[8] > 0 then lines + 1 else lines) else lines) else lines) else lines) else lines
-- Shift rows down on clear
every 33 -> g19[0] = if g19[0] > 0 then (if g19[4] > 0 then (if g19[8] > 0 then g18[0] else g19[0]) else g19[0]) else g19[0]
every 33 -> g19[1] = if g19[0] > 0 then (if g19[4] > 0 then (if g19[8] > 0 then g18[1] else g19[1]) else g19[1]) else g19[1]
every 33 -> g19[2] = if g19[0] > 0 then (if g19[4] > 0 then (if g19[8] > 0 then g18[2] else g19[2]) else g19[2]) else g19[2]
every 33 -> g19[3] = if g19[0] > 0 then (if g19[4] > 0 then (if g19[8] > 0 then g18[3] else g19[3]) else g19[3]) else g19[3]
every 33 -> g19[4] = if g19[0] > 0 then (if g19[4] > 0 then (if g19[8] > 0 then g18[4] else g19[4]) else g19[4]) else g19[4]
-- Level up
every 33 -> level = if lines > 0 then (lines / 10 + 1) else 1
every 33 -> dropInterval = if level > 9 then 3 else (27 - level * 3)
-- Game over
every 33 -> gameOver = if g0[3] > 0 then 1 else (if g0[4] > 0 then 1 else (if g0[5] > 0 then 1 else gameOver))
-- ================================================================
-- LAYER 5: SOUND SIGNALS
-- ================================================================
every 33 -> play_tone(if lockTick == 3 then 220 else 0, 80)
every 33 -> play_tone(if gameOver then 110 else 0, 500, "sawtooth")
-- ================================================================
-- LAYER 6: STREAM SIGNALS — Spectator broadcast
-- ================================================================
stream tetris on "ws://localhost:9100/peer/tetris" {
mode: signal,
output: piece, rotation, px, py, nextPiece, score, lines, level, gameOver, paused, g0, g1, g2, g3, g4, g5, g6, g7, g8, g9, g10, g11, g12, g13, g14, g15, g16, g17, g18, g19
}
-- ================================================================
-- VIEW: All 6 signal layers composited into one display
-- ================================================================
view tetris_game = column [
text "DreamStack Tetris" { variant: "title" }
text "Signal Composition Demo" { variant: "subtitle" }
-- Status bar (Layer 5 UI signals <- Layer 2 physics signals)
row [
Badge { label: "Score: {score}", variant: "success" }
Badge { label: "Lines: {lines}", variant: "info" }
Badge { label: "Level: {level}", variant: "warning" }
Badge { label: "Next: {nextPiece}", variant: "default" }
]
row [
-- Main Board: 300x600 (10x20 cells at 30px each)
stack { style: "position:relative; width:300px; height:600px; background:linear-gradient(180deg,#0a0a1a,#111133); border:2px solid #334155; border-radius:12px; overflow:hidden; box-shadow:0 0 60px rgba(99,102,241,0.15)" } [
-- Grid lines (subtle, UI-only signals)
for r in [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19] ->
column { style: "position:absolute; width:300px; height:1px; top:{r * 30}px; background:rgba(99,102,241,0.08)" } []
for k in [0,1,2,3,4,5,6,7,8,9] ->
column { style: "position:absolute; width:1px; height:600px; left:{k * 30}px; background:rgba(99,102,241,0.08)" } []
-- Frozen grid: row 16 (Layer 1 data -> Layer 5 UI)
for c in [0,1,2,3,4,5,6,7,8,9] ->
column { style: "position:absolute; width:28px; height:28px; top:481px; left:{c * 30 + 1}px; border-radius:4px; background:linear-gradient(180deg,#a855f7,#7c3aed); opacity:{if g16[c] > 0 then 1 else 0}; transition:opacity 0.15s; box-shadow:0 0 8px rgba(168,85,247,0.4)" } []
-- Frozen grid: row 17
for c in [0,1,2,3,4,5,6,7,8,9] ->
column { style: "position:absolute; width:28px; height:28px; top:511px; left:{c * 30 + 1}px; border-radius:4px; background:linear-gradient(180deg,#a855f7,#7c3aed); opacity:{if g17[c] > 0 then 1 else 0}; transition:opacity 0.15s; box-shadow:0 0 8px rgba(168,85,247,0.4)" } []
-- Frozen grid: row 18
for c in [0,1,2,3,4,5,6,7,8,9] ->
column { style: "position:absolute; width:28px; height:28px; top:541px; left:{c * 30 + 1}px; border-radius:4px; background:linear-gradient(180deg,#a855f7,#7c3aed); opacity:{if g18[c] > 0 then 1 else 0}; transition:opacity 0.15s; box-shadow:0 0 8px rgba(168,85,247,0.4)" } []
-- Frozen grid: row 19 (bottom)
for c in [0,1,2,3,4,5,6,7,8,9] ->
column { style: "position:absolute; width:28px; height:28px; top:571px; left:{c * 30 + 1}px; border-radius:4px; background:linear-gradient(180deg,#a855f7,#7c3aed); opacity:{if g19[c] > 0 then 1 else 0}; transition:opacity 0.15s; box-shadow:0 0 8px rgba(168,85,247,0.4)" } []
-- Active piece: 4 cells (Layer 1 data + Layer 3 input -> Layer 5 UI)
-- Cell 0: top-left of piece
column { style: "position:absolute; width:28px; height:28px; border-radius:4px; background:linear-gradient(180deg,#c084fc,#a855f7); box-shadow:0 0 12px rgba(168,85,247,0.5); transition:left 0.05s,top 0.05s; top:{(if piece == 1 then py + 1 else py) * 30 + 1}px; left:{(if piece == 5 then px + 1 else px) * 30 + 1}px" } []
-- Cell 1
column { style: "position:absolute; width:28px; height:28px; border-radius:4px; background:linear-gradient(180deg,#c084fc,#a855f7); box-shadow:0 0 12px rgba(168,85,247,0.5); transition:left 0.05s,top 0.05s; top:{(if piece == 1 then py + 1 else py) * 30 + 1}px; left:{(if piece == 2 then px else px + 1) * 30 + 1}px" } []
-- Cell 2
column { style: "position:absolute; width:28px; height:28px; border-radius:4px; background:linear-gradient(180deg,#c084fc,#a855f7); box-shadow:0 0 12px rgba(168,85,247,0.5); transition:left 0.05s,top 0.05s; top:{(if piece == 1 then py + 1 else py) * 30 + 1}px; left:{(if piece == 1 then px + 2 else (if piece == 3 then px + 2 else px + 2)) * 30 + 1}px" } []
-- Cell 3 (second row of piece, e.g. T-piece center bottom)
column { style: "position:absolute; width:28px; height:28px; border-radius:4px; background:linear-gradient(180deg,#c084fc,#a855f7); box-shadow:0 0 12px rgba(168,85,247,0.5); transition:left 0.05s,top 0.05s; top:{(if piece == 1 then py + 1 else py + 1) * 30 + 1}px; left:{(if piece == 1 then px + 3 else (if piece == 6 then px + 1 else (if piece == 4 then px + 2 else px + 2))) * 30 + 1}px" } []
-- Ghost piece (Layer 4 derived -> Layer 5 UI)
-- Shows where piece will land (row 18)
column { style: "position:absolute; width:28px; height:28px; border-radius:4px; border:2px dashed rgba(168,85,247,0.3); top:{18 * 30 + 1}px; left:{px * 30 + 1}px" } []
column { style: "position:absolute; width:28px; height:28px; border-radius:4px; border:2px dashed rgba(168,85,247,0.3); top:{18 * 30 + 1}px; left:{(px + 1) * 30 + 1}px" } []
column { style: "position:absolute; width:28px; height:28px; border-radius:4px; border:2px dashed rgba(168,85,247,0.3); top:{18 * 30 + 1}px; left:{(px + 2) * 30 + 1}px" } []
]
-- Side panel
column [
-- Next piece preview (Layer 1 data -> Layer 5 UI)
text "NEXT" { variant: "subtitle" }
stack { style: "position:relative; width:120px; height:90px; background:#0f0f23; border-radius:8px; border:1px solid #334155" } [
column { style: "position:absolute; width:24px; height:24px; border-radius:3px; top:20px; left:15px; background:linear-gradient(180deg,#c084fc,#a855f7); box-shadow:0 0 6px rgba(168,85,247,0.4)" } []
column { style: "position:absolute; width:24px; height:24px; border-radius:3px; top:20px; left:41px; background:linear-gradient(180deg,#c084fc,#a855f7); box-shadow:0 0 6px rgba(168,85,247,0.4)" } []
column { style: "position:absolute; width:24px; height:24px; border-radius:3px; top:20px; left:67px; background:linear-gradient(180deg,#c084fc,#a855f7); box-shadow:0 0 6px rgba(168,85,247,0.4)" } []
column { style: "position:absolute; width:24px; height:24px; border-radius:3px; top:46px; left:41px; background:linear-gradient(180deg,#c084fc,#a855f7); opacity:{if nextPiece == 6 then 1 else (if nextPiece == 4 then 1 else 0)}" } []
]
-- Stats (Layer 2 physics -> Layer 5 UI)
text "STATS" { variant: "subtitle" }
text "Score: {score}"
text "Lines: {lines}"
text "Level: {level}"
text "Speed: {dropInterval}" { variant: "muted" }
text "Ticks: {gravityTick}" { variant: "muted" }
-- Signal layer key
text "SIGNALS" { variant: "subtitle" }
text "Data: 20 grid rows" { variant: "muted" }
text "Physics: gravity" { variant: "muted" }
text "Input: keyboard" { variant: "muted" }
text "Derived: lock, clear" { variant: "muted" }
text "Sound: 3 tones" { variant: "muted" }
text "Stream: 30+ signals" { variant: "muted" }
]
]
-- Controls (Layer 3 input -> signal writes)
row [
button (if paused then "Resume" else "Pause") {
click: paused = if paused then 0 else 1,
variant: "primary"
}
button "Left" { click: px = if px > 0 then px - 1 else px, variant: "secondary" }
button "Right" { click: px = if px < 7 then px + 1 else px, variant: "secondary" }
button "Drop" { click: py = 18, variant: "secondary" }
button "Rotate" { click: rotation = (rotation + 1) % 4, variant: "secondary" }
button "Reset" {
click: score = 0; lines = 0; level = 1; gameOver = 0; paused = 0; piece = 6; nextPiece = 1; px = 3; py = 0; rotation = 0; g16 = [0,0,0,0,0,0,0,0,0,0]; g17 = [0,0,0,0,0,0,0,0,0,0]; g18 = [0,0,0,0,0,0,0,0,0,0]; g19 = [0,0,0,0,0,0,0,0,0,0],
variant: "destructive"
}
]
when gameOver > 0 -> text "GAME OVER - Score: {score}, Lines: {lines}" { variant: "title" }
text "Arrow keys: Move/Rotate | Space: Hard drop | P: Pause" { variant: "muted" }
text "Streaming: 6 signal layers composited" { variant: "muted" }
]