Skip to main content
All Examples

Snake

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

Game
View source on GitHub
/
import {
  Array,
  Duration,
  Effect,
  Match as M,
  Schema as S,
  Stream,
  pipe,
} from 'effect'
import { Runtime, Subscription } from 'foldkit'
import { Command } from 'foldkit/command'
import { Html, html } from 'foldkit/html'
import { m } 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 = S.Literal(
  'NotStarted',
  'Playing',
  'Paused',
  'GameOver',
)
export type GameState = typeof GameState.Type

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

// MESSAGE

const TickedClock = m('TickedClock')
const PressedKey = m('PressedKey', { key: S.String })
const PausedGame = m('PausedGame')
const RestartedGame = m('RestartedGame')
const RequestedApple = m('RequestedApple', { snake: Snake.Snake })
const GotApple = m('GotApple', { position: Position.Position })

export const Message = S.Union(
  TickedClock,
  PressedKey,
  PausedGame,
  RestartedGame,
  RequestedApple,
  GotApple,
)
export type Message = typeof Message.Type

// INIT

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

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

// UPDATE

const update = (
  model: Model,
  message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
  M.value(message).pipe(
    M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
    M.tagsExhaustive({
      PressedKey: ({ key }) =>
        M.value(key).pipe(
          M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
          M.whenOr(
            'ArrowUp',
            'ArrowDown',
            'ArrowLeft',
            'ArrowRight',
            'w',
            'a',
            's',
            'd',
            moveKey => {
              const nextDirection = M.value(moveKey).pipe(
                M.withReturnType<Direction.Direction>(),
                M.whenOr('ArrowUp', 'w', () => 'Up'),
                M.whenOr('ArrowDown', 's', () => 'Down'),
                M.whenOr('ArrowLeft', 'a', () => 'Left'),
                M.whenOr('ArrowRight', 'd', () => 'Right'),
                M.exhaustive,
              )

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

            return [
              evo(model, {
                snake: () => nextSnake,
                direction: () => GAME.INITIAL_DIRECTION,
                nextDirection: () => GAME.INITIAL_DIRECTION,
                gameState: () => 'NotStarted',
                points: () => 0,
              }),
              [requestApple(nextSnake)],
            ]
          }),
          M.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 [
            evo(model, {
              gameState: () => 'GameOver',
              highScore: highScore => Math.max(model.points, highScore),
            }),
            [],
          ]
        }

        const commands = willEatApple ? [requestApple(nextSnake)] : []

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

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

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

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

      RequestedApple: ({ snake }) => [
        model,
        [
          Apple.generatePosition(snake).pipe(
            Effect.map(position => GotApple({ position })),
          ),
        ],
      ],

      GotApple: ({ position }) => [
        evo(model, {
          apple: () => position,
        }),
        [],
      ],
    }),
  )

// COMMAND

const requestApple = (snake: Snake.Snake): Command<Message> =>
  Effect.succeed(RequestedApple({ snake }))

// SUBSCRIPTION

const SubscriptionDeps = S.Struct({
  gameClock: S.Struct({
    isPlaying: S.Boolean,
    interval: S.Number,
  }),
  keyboard: S.Null,
})

const subscriptions = Subscription.makeSubscriptions(SubscriptionDeps)<
  Model,
  Message
>({
  gameClock: {
    modelToDependencies: (model: Model) => ({
      isPlaying: model.gameState === 'Playing',
      interval: Math.max(
        GAME_SPEED.MIN_INTERVAL,
        GAME_SPEED.BASE_INTERVAL - model.points,
      ),
    }),
    depsToStream: (deps: { isPlaying: boolean; interval: number }) =>
      Stream.when(
        Stream.tick(Duration.millis(deps.interval)).pipe(
          Stream.map(() => Effect.succeed(TickedClock())),
        ),
        () => deps.isPlaying,
      ),
  },

  keyboard: {
    modelToDependencies: () => null,
    depsToStream: () =>
      Stream.fromEventListener<KeyboardEvent>(document, 'keydown').pipe(
        Stream.map(keyboardEvent =>
          Effect.sync(() => {
            keyboardEvent.preventDefault()
            return PressedKey({ key: keyboardEvent.key })
          }),
        ),
      ),
  },
})

// VIEW

const { div, h1, p, Class } = html<Message>()

const cellView = (x: number, y: number, model: Model): Html => {
  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)

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

  return div([Class(`w-6 h-6 ${cellClass}`)], [])
}

const gridView = (model: Model): Html =>
  div(
    [Class('inline-block border-2 border-gray-600')],
    Array.makeBy(GAME.GRID_SIZE, y =>
      div(
        [Class('flex')],
        Array.makeBy(GAME.GRID_SIZE, x => cellView(x, y, model)),
      ),
    ),
  )

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

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

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

// RUN

const element = Runtime.makeElement({
  Model,
  init,
  update,
  view,
  subscriptions,
  container: document.getElementById('root')!,
})

Runtime.run(element)