Examples/Invoke-SnakeDemo.ps1

Import-Module "$PSScriptRoot/../Elm.psd1" -Force

# ---------------------------------------------------------------------------
# Snake demo
# Classic Snake game in the terminal.
# Demonstrates: timer + key subscriptions together, game loop state, grid render.
#
# Controls:
# Arrow keys / WASD - change direction
# Space - start / pause
# R - restart
# Q - quit
# ---------------------------------------------------------------------------

$script:COLS  = 30   # playfield columns (each cell = 1 char)
$script:ROWS  = 18   # playfield rows
$script:SPEED = 150  # ms per tick

function Get-RandomFood {
    param([object[]]$Snake)
    $occupied = @{}
    foreach ($seg in $Snake) { $occupied["$($seg.X),$($seg.Y)"] = $true }
    $free = [System.Collections.Generic.List[object]]::new()
    for ($y = 0; $y -lt $script:ROWS; $y++) {
        for ($x = 0; $x -lt $script:COLS; $x++) {
            if (-not $occupied.ContainsKey("$x,$y")) {
                $free.Add([PSCustomObject]@{ X = $x; Y = $y })
            }
        }
    }
    if ($free.Count -eq 0) { return $null }
    $free[(Get-Random -Maximum $free.Count)]
}

function New-SnakeModel {
    $head = [PSCustomObject]@{ X = 14; Y = 9 }
    $body = @(
        [PSCustomObject]@{ X = 13; Y = 9 }
        [PSCustomObject]@{ X = 12; Y = 9 }
    )
    $snake = @($head) + $body
    [PSCustomObject]@{
        Snake     = $snake
        Dir       = 'Right'
        NextDir   = 'Right'
        Food      = (Get-RandomFood -Snake $snake)
        Score     = 0
        Running   = $false
        GameOver  = $false
    }
}

$initFn = {
    [PSCustomObject]@{
        Model = New-SnakeModel
        Cmd   = $null
    }
}

$updateFn = {
    param($msg, $model)

    switch ($msg) {
        'Tick' {
            if (-not $model.Running -or $model.GameOver) {
                return [PSCustomObject]@{ Model = $model; Cmd = $null }
            }

            $dir = $model.NextDir

            $snake = @($model.Snake)
            $head  = $snake[0]

            $newHead = switch ($dir) {
                'Up'    { [PSCustomObject]@{ X = $head.X;     Y = $head.Y - 1 } }
                'Down'  { [PSCustomObject]@{ X = $head.X;     Y = $head.Y + 1 } }
                'Left'  { [PSCustomObject]@{ X = $head.X - 1; Y = $head.Y     } }
                'Right' { [PSCustomObject]@{ X = $head.X + 1; Y = $head.Y     } }
            }

            # Wall collision
            $hitWall = ($newHead.X -lt 0 -or $newHead.X -ge $script:COLS -or
                        $newHead.Y -lt 0 -or $newHead.Y -ge $script:ROWS)

            # Self collision
            $hitSelf = $false
            foreach ($seg in $snake) {
                if ($seg.X -eq $newHead.X -and $seg.Y -eq $newHead.Y) {
                    $hitSelf = $true; break
                }
            }

            if ($hitWall -or $hitSelf) {
                $newModel = [PSCustomObject]@{
                    Snake    = $model.Snake
                    Dir      = $dir
                    NextDir  = $dir
                    Food     = $model.Food
                    Score    = $model.Score
                    Running  = $false
                    GameOver = $true
                }
                return [PSCustomObject]@{ Model = $newModel; Cmd = $null }
            }

            # Eat food?
            $ate      = ($null -ne $model.Food -and $newHead.X -eq $model.Food.X -and $newHead.Y -eq $model.Food.Y)
            $newSnake = if ($ate) {
                @($newHead) + $snake        # keep tail (grow)
            } else {
                @($newHead) + $snake[0..($snake.Count - 2)]  # drop tail
            }
            $newFood  = if ($ate) { Get-RandomFood -Snake $newSnake } else { $model.Food }
            $newScore = if ($ate) { $model.Score + 1 } else { $model.Score }

            $newModel = [PSCustomObject]@{
                Snake    = $newSnake
                Dir      = $dir
                NextDir  = $dir
                Food     = $newFood
                Score    = $newScore
                Running  = $true
                GameOver = $false
            }
            return [PSCustomObject]@{ Model = $newModel; Cmd = $null }
        }

        'Up'    { $opp = 'Down'  }
        'Down'  { $opp = 'Up'    }
        'Left'  { $opp = 'Right' }
        'Right' { $opp = 'Left'  }
    }

    # Direction change (only if not opposite to current)
    if ($msg -in @('Up','Down','Left','Right')) {
        $opp = switch ($msg) {
            'Up'    { 'Down'  }
            'Down'  { 'Up'    }
            'Left'  { 'Right' }
            'Right' { 'Left'  }
        }
        if ($model.Dir -ne $opp) {
            $newModel = [PSCustomObject]@{
                Snake    = $model.Snake
                Dir      = $model.Dir
                NextDir  = $msg
                Food     = $model.Food
                Score    = $model.Score
                Running  = $model.Running
                GameOver = $model.GameOver
            }
            return [PSCustomObject]@{ Model = $newModel; Cmd = $null }
        }
        return [PSCustomObject]@{ Model = $model; Cmd = $null }
    }

    switch ($msg) {
        'Toggle' {
            if ($model.GameOver) {
                return [PSCustomObject]@{ Model = $model; Cmd = $null }
            }
            $newModel = [PSCustomObject]@{
                Snake    = $model.Snake
                Dir      = $model.Dir
                NextDir  = $model.NextDir
                Food     = $model.Food
                Score    = $model.Score
                Running  = -not $model.Running
                GameOver = $model.GameOver
            }
            return [PSCustomObject]@{ Model = $newModel; Cmd = $null }
        }
        'Restart' {
            return [PSCustomObject]@{ Model = (New-SnakeModel); Cmd = $null }
        }
        'Quit' {
            return [PSCustomObject]@{
                Model = $model
                Cmd   = [PSCustomObject]@{ Type = 'Quit' }
            }
        }
    }

    [PSCustomObject]@{ Model = $model; Cmd = $null }
}

$viewFn = {
    param($model)

    $snake  = @($model.Snake)
    $snakeSet = @{}
    foreach ($seg in $snake) { $snakeSet["$($seg.X),$($seg.Y)"] = $true }

    # Build the grid as rows of text
    $headStyle  = New-ElmStyle -Foreground 'BrightGreen'  -Bold
    $bodyStyle  = New-ElmStyle -Foreground 'Green'
    $foodStyle  = New-ElmStyle -Foreground 'BrightRed'    -Bold
    $wallStyle  = New-ElmStyle -Foreground 'BrightBlack'
    $titleStyle = New-ElmStyle -Foreground 'BrightCyan'   -Bold
    $scoreStyle = New-ElmStyle -Foreground 'BrightWhite'
    $hintStyle  = New-ElmStyle -Foreground 'BrightBlack'
    $deadStyle  = New-ElmStyle -Foreground 'BrightRed'    -Bold

    $head = $snake[0]
    $border = '+' + ('-' * $script:COLS) + '+'

    $children = [System.Collections.Generic.List[object]]::new()
    $children.Add((New-ElmText -Content 'Snake' -Style $titleStyle))
    $children.Add((New-ElmText -Content "Score: $($model.Score)" -Style $scoreStyle))
    $children.Add((New-ElmText -Content $border -Style $wallStyle))

    for ($y = 0; $y -lt $script:ROWS; $y++) {
        $rowChars = [System.Text.StringBuilder]::new()
        $null = $rowChars.Append('|')
        for ($x = 0; $x -lt $script:COLS; $x++) {
            $key = "$x,$y"
            if ($x -eq $head.X -and $y -eq $head.Y) {
                $null = $rowChars.Append('@')
            } elseif ($snakeSet.ContainsKey($key)) {
                $null = $rowChars.Append('o')
            } elseif ($null -ne $model.Food -and $x -eq $model.Food.X -and $y -eq $model.Food.Y) {
                $null = $rowChars.Append('*')
            } else {
                $null = $rowChars.Append(' ')
            }
        }
        $null = $rowChars.Append('|')
        $children.Add((New-ElmText -Content $rowChars.ToString() -Style $wallStyle))
    }

    $children.Add((New-ElmText -Content $border -Style $wallStyle))

    if ($model.GameOver) {
        $children.Add((New-ElmText -Content '' ))
        $children.Add((New-ElmText -Content "GAME OVER Score: $($model.Score)" -Style $deadStyle))
        $children.Add((New-ElmText -Content '[R] Restart [Q] Quit' -Style $hintStyle))
    } elseif ($model.Running) {
        $children.Add((New-ElmText -Content '[Arrow/WASD] steer [Space] pause [Q] quit' -Style $hintStyle))
    } else {
        $children.Add((New-ElmText -Content '[Space] Start [Q] Quit' -Style $hintStyle))
    }

    New-ElmBox -Children $children.ToArray()
}

$subFn = {
    param($model)
    $subs = [System.Collections.Generic.List[object]]::new()

    # Quit always works
    $subs.Add((New-ElmKeySub -Key 'Q' -Handler { 'Quit' }))

    if ($model.GameOver) {
        $subs.Add((New-ElmKeySub -Key 'R' -Handler { 'Restart' }))
    } else {
        $subs.Add((New-ElmKeySub -Key 'Space'    -Handler { 'Toggle'  }))
        $subs.Add((New-ElmKeySub -Key 'R'        -Handler { 'Restart' }))
        $subs.Add((New-ElmKeySub -Key 'UpArrow'  -Handler { 'Up'      }))
        $subs.Add((New-ElmKeySub -Key 'DownArrow' -Handler { 'Down'   }))
        $subs.Add((New-ElmKeySub -Key 'LeftArrow' -Handler { 'Left'   }))
        $subs.Add((New-ElmKeySub -Key 'RightArrow' -Handler { 'Right' }))
        $subs.Add((New-ElmKeySub -Key 'W' -Handler { 'Up'    }))
        $subs.Add((New-ElmKeySub -Key 'S' -Handler { 'Down'  }))
        $subs.Add((New-ElmKeySub -Key 'A' -Handler { 'Left'  }))
        $subs.Add((New-ElmKeySub -Key 'D' -Handler { 'Right' }))

        if ($model.Running) {
            $subs.Add((New-ElmTimerSub -IntervalMs $script:SPEED -Handler { 'Tick' }))
        }
    }

    return $subs.ToArray()
}

function Invoke-SnakeDemo {
    <#
    .SYNOPSIS
        Classic Snake game in the terminal.

    .DESCRIPTION
        Arrow keys / WASD to steer. Space to start/pause. R to restart. Q to quit.
        Demonstrates combined timer + key subscriptions in the Elm architecture.

    .NOTES
        Requires the Elm module and a terminal at least 32 columns wide.
        Run from Examples: . .\Invoke-SnakeDemo.ps1; Invoke-SnakeDemo
    #>

    [CmdletBinding()]
    param()

    Start-ElmProgram -InitFn $initFn -UpdateFn $updateFn -ViewFn $viewFn -SubscriptionFn $subFn
}

Invoke-SnakeDemo