Skip to main content
All Examples

Snake

The classic snake game. Keyboard input, game loop, and collision detection.

Game
/
import { Array, Duration, Effect, Match, Schema, Stream, pipe } from 'effect'
import { Command, Runtime, Subscription, type Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { GAME, GAME_SPEED } from './constants'
import { Apple, Direction, Position, Snake } from './domain'

// MODEL

export const GameState = Schema.Literals([
  'NotStarted',
  'Playing',
  'Paused',
  'GameOver',
])
export type GameState = typeof GameState.Type

export const Model = Schema.Struct({
  snake: Snake.Snake,
  apple: Position.Position,
  direction: Direction.Direction,
  nextDirection: Direction.Direction,
  gameState: GameState,
  points: Schema.Number,
  highScore: Schema.Number,
})
export type Model = typeof Model.Type

// MESSAGE

export const Message = defineMessageUnion({
  TickedClock: {},
  PressedKey: { key: Schema.String },
  PausedGame: {},
  RestartedGame: {},
  CompletedGenerateApplePosition: { position: Position.Position },
})

export type Message = typeof Message.Type

// INIT

export const init: Runtime.ApplicationInit<Model, Message> = () => {
  const snake = Snake.create(GAME.INITIAL_POSITION)

  return {
    model: {
      snake,
      apple: { x: 15, y: 15 },
      direction: GAME.INITIAL_DIRECTION,
      nextDirection: GAME.INITIAL_DIRECTION,
      gameState: 'NotStarted',
      points: 0,
      highScore: 0,
    },
    commands: [GenerateApplePosition({ snake: snake })],
  }
}

// UPDATE

type UpdateReturn = Update.Return<Model, Message>

export const update = (model: Model, message: Message) =>
  Message.match<UpdateReturn>(message, {
    PressedKey: ({ key }) =>
      Match.value(key).pipe(
        Match.withReturnType<UpdateReturn>(),
        Match.whenOr(
          'ArrowUp',
          'ArrowDown',
          'ArrowLeft',
          'ArrowRight',
          'w',
          'a',
          's',
          'd',
          moveKey => {
            const nextDirection = Match.value(moveKey).pipe(
              Match.withReturnType<Direction.Direction>(),
              Match.whenOr('ArrowUp', 'w', () => 'Up'),
              Match.whenOr('ArrowDown', 's', () => 'Down'),
              Match.whenOr('ArrowLeft', 'a', () => 'Left'),
              Match.whenOr('ArrowRight', 'd', () => 'Right'),
              Match.exhaustive,
            )

            if (model.gameState === 'Playing') {
              return {
                model: evo(model, {
                  nextDirection: () => nextDirection,
                }),
              }
            } else {
              return { model }
            }
          },
        ),
        Match.when(' ', () => {
          const nextGameState = Match.value(model.gameState).pipe(
            Match.withReturnType<GameState>(),
            Match.when('NotStarted', () => 'Playing'),
            Match.when('Playing', () => 'Paused'),
            Match.when('Paused', () => 'Playing'),
            Match.when('GameOver', () => 'GameOver'),
            Match.exhaustive,
          )
          return {
            model: evo(model, {
              gameState: () => nextGameState,
            }),
          }
        }),
        Match.when('r', () => {
          const nextSnake = Snake.create(GAME.INITIAL_POSITION)

          return {
            model: evo(model, {
              snake: () => nextSnake,
              direction: () => GAME.INITIAL_DIRECTION,
              nextDirection: () => GAME.INITIAL_DIRECTION,
              gameState: () => 'NotStarted',
              points: () => 0,
            }),
            commands: [GenerateApplePosition({ snake: nextSnake })],
          }
        }),
        Match.orElse(() => ({ model })),
      ),

    TickedClock: () => {
      if (model.gameState !== 'Playing') {
        return { model }
      }

      const currentDirection = Direction.isOpposite(
        model.direction,
        model.nextDirection,
      )
        ? model.direction
        : model.nextDirection

      const newHead = Position.move(model.snake[0], currentDirection)
      const willEatApple = Position.equivalence(newHead, model.apple)

      const nextSnake = willEatApple
        ? Snake.grow(model.snake, currentDirection)
        : Snake.move(model.snake, currentDirection)

      if (Snake.hasCollision(nextSnake)) {
        return {
          model: evo(model, {
            gameState: () => 'GameOver',
            highScore: highScore => Math.max(model.points, highScore),
          }),
        }
      }

      const commands = willEatApple
        ? [GenerateApplePosition({ snake: nextSnake })]
        : []

      return {
        model: evo(model, {
          snake: () => nextSnake,
          direction: () => currentDirection,
          points: points =>
            willEatApple ? points + GAME.POINTS_PER_APPLE : points,
        }),
        commands,
      }
    },

    PausedGame: () => ({
      model: evo(model, {
        gameState: gameState =>
          gameState === 'Playing' ? 'Paused' : 'Playing',
      }),
    }),

    RestartedGame: () => {
      const startPosition: Position.Position = { x: 10, y: 10 }
      const nextSnake = Snake.create(startPosition)

      return {
        model: evo(model, {
          snake: () => nextSnake,
          direction: () => 'Right',
          nextDirection: () => 'Right',
          gameState: () => 'NotStarted',
          points: () => 0,
        }),
        commands: [GenerateApplePosition({ snake: nextSnake })],
      }
    },

    CompletedGenerateApplePosition: ({ position }) => ({
      model: evo(model, {
        apple: () => position,
      }),
    }),
  })

// COMMAND

export const GenerateApplePosition = Command.define('GenerateApplePosition', {
  args: { snake: Snake.Snake },
  messages: [Message.CompletedGenerateApplePosition],
  execute: ({ snake }) =>
    Apple.generatePosition(snake).pipe(
      Effect.map(position =>
        Message.CompletedGenerateApplePosition({ position }),
      ),
    ),
})

// SUBSCRIPTION

export const subscriptions = Subscription.make<Model, Message>()(entry => ({
  gameClock: entry(
    {
      isPlaying: Schema.Boolean,
      interval: Schema.Number,
    },
    {
      modelToDependencies: model => ({
        isPlaying: model.gameState === 'Playing',
        interval: Math.max(
          GAME_SPEED.MIN_INTERVAL,
          GAME_SPEED.BASE_INTERVAL - model.points,
        ),
      }),
      dependenciesToStream: ({ isPlaying, interval }) =>
        Stream.when(
          Stream.tick(Duration.millis(interval)).pipe(
            Stream.map(Message.TickedClock),
          ),
          Effect.sync(() => isPlaying),
        ),
    },
  ),

  keyboard: Subscription.persistent(
    Stream.fromEventListener<KeyboardEvent>(document, 'keydown').pipe(
      Stream.mapEffect(keyboardEvent =>
        Effect.sync(() => keyboardEvent.preventDefault()).pipe(
          Effect.as(Message.PressedKey({ key: keyboardEvent.key })),
        ),
      ),
    ),
  ),
}))

// VIEW

const cellClass = (x: number, y: number, model: Model): string => {
  const isSnakeHead = Position.equivalence({ x, y }, model.snake[0])
  const isSnakeTail = pipe(
    model.snake,
    Array.tailNonEmpty,
    Array.some(segment => Position.equivalence({ x, y }, segment)),
  )
  const isApple = Position.equivalence({ x, y }, model.apple)

  return Match.value({ isSnakeHead, isSnakeTail, isApple }).pipe(
    Match.when({ isSnakeHead: true }, () => 'bg-green-700'),
    Match.when({ isSnakeTail: true }, () => 'bg-green-500'),
    Match.when({ isApple: true }, () => 'bg-red-500'),
    Match.orElse(() => 'bg-gray-800'),
  )
}

const gridView = (model: Model, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('inline-block border-2 border-gray-600')],
    Array.makeBy(GAME.GRID_SIZE, y =>
      h.div(
        [h.Class('flex')],
        Array.makeBy(GAME.GRID_SIZE, x =>
          h.div([h.Class(`w-6 h-6 ${cellClass(x, y, model)}`)]),
        ),
      ),
    ),
  )

const gameStateView = (gameState: GameState): string =>
  Match.value(gameState).pipe(
    Match.when('NotStarted', () => 'Press SPACE to start'),
    Match.when('Playing', () => 'Playing - SPACE to pause'),
    Match.when('Paused', () => 'Paused - SPACE to continue'),
    Match.when('GameOver', () => 'Game Over - Press R to restart'),
    Match.exhaustive,
  )

const instructionsView = (h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('mt-4 text-sm text-gray-400')],
    [
      h.p([], ['Use ARROW KEYS or WASD to move']),
      h.p([], ['SPACE to pause/start']),
      h.p([], ['R to restart']),
    ],
  )

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: `Snake | ${model.points} pts`,
  body: h.div(
    [
      h.Class(
        'flex flex-col items-center justify-center min-h-screen bg-black text-white p-8',
      ),
    ],
    [
      h.h1([h.Class('text-4xl font-bold mb-4')], ['Snake Game']),
      h.div(
        [h.Class('flex gap-8 mb-4')],
        [
          h.p([h.Class('text-xl')], [`Score: ${model.points}`]),
          h.p([h.Class('text-xl')], [`High Score: ${model.highScore}`]),
        ],
      ),
      h.p([h.Class('text-lg mb-4')], [gameStateView(model.gameState)]),
      gridView(model, h),
      instructionsView(h),
    ],
  ),
})