DEV Community

Cover image for Build a game with Svelte, XState and SpeechRecognition
Gustavo Castillo
Gustavo Castillo

Posted on

3 2

Build a game with Svelte, XState and SpeechRecognition

Hello folks this weekend I've been playing with XState, Svelte and the SpeechRecognition API đŸŽ€, so I decided to build a mini number guessing game and model my states with a statechart, so let's see how to do it.

If you want to try it out go to this 🌎 Live demo (only works on Chrome desktop or mobile).

Note: The SpeechRecognition only recognises words in English (or at least I couldn't make it work in Spanish 😝), so even though the game is in Spanish you must say the number in English.

Types

As we're going to use TypeScript let's define our types first.

export type NumberGuessContextType = {
  recognition: SpeechRecognition | null
  randomNumber: number
  hint: string
  error: string
  isChrome: boolean
}

export type NotSupportedErrorType = {
  type: 'NOT_SUPPORTED_ERROR'
  error: string
}

export type CheckReadinessType = {
  type: 'CHECK_READINESS'
}

type NotAllowedErrorType = {
  type: 'NOT_ALLOWED_ERROR'
  error: string
}

type SpeakType = {
  type: 'SPEAK'
  message: string
}

type PlayAgainType = {
  type: 'PLAY_AGAIN'
}

export type UpdateHintType = {
  type: 'UPDATE_HINT'
  data: string
}

export type NumberGuessEventType =
  | NotSupportedErrorType
  | CheckReadinessType
  | NotAllowedErrorType
  | SpeakType
  | PlayAgainType
  | UpdateHintType

export type NumberGuessStateType = {
  context: NumberGuessContextType
  value: 'verifyingBrowser' | 'failure' | 'playing' | 'checkNumber' | 'gameOver'
}
Enter fullscreen mode Exit fullscreen mode

Add global type for SpeechRecognition

The SpeechRecognition API is very experimental, so in order to TS knows about it we've to tech TS how to treat this API, let's declare a global interface to type webkitSpeechRecognition.

export declare global {
  interface Window {
    webkitSpeechRecognition: SpeechRecognition
  }
}
Enter fullscreen mode Exit fullscreen mode

Machine

Now is the turn of our state machine, this is where we're going to put all the logic behind our little game.

import { createMachine, assign } from 'xstate'
import type {
  NumberGuessContextType,
  NumberGuessEventType,
  NumberGuessStateType,
  NotSupportedErrorType,
  UpdateHintType,
} from 'src/machine/types'

const numberGuessMachine = createMachine<
  NumberGuessContextType,
  NumberGuessEventType,
  NumberGuessStateType
>(
  {
    id: 'guessNumber',
    initial: 'verifyingBrowser',
    context: {
      hint: '',
      recognition: null,
      randomNumber: -1,
      error: '',
      isChrome: false,
    },
    states: {
      verifyingBrowser: {
        entry: 'checkBrowser',
        on: {
          NOT_SUPPORTED_ERROR: {
            target: 'failure',
            actions: 'displayError',
          },
          CHECK_READINESS: {
            target: 'playing',
            actions: 'initGame',
            cond: 'isSpeechRecognitionReady',
          },
          NOT_ALLOWED_ERROR: {
            target: 'failure',
            actions: 'displayError',
            cond: 'hasError',
          },
        },
      },
      playing: {
        after: {
          2500: {
            actions: 'clearHint',
            cond: 'hasHint',
          },
        },
        on: {
          SPEAK: {
            target: 'checkNumber',
          },
        },
      },
      checkNumber: {
        invoke: {
          id: 'checkingNumber',
          src: 'checkNumber',
          onDone: {
            actions: 'updateHint',
            target: 'gameOver',
          },
          onError: {
            actions: 'updateHint',
            target: 'playing',
          },
        },
      },
      gameOver: {
        exit: 'initGame',
        on: {
          PLAY_AGAIN: {
            target: 'playing',
          },
          SPEAK: {
            target: 'playing',
            cond: 'isPlayAgain',
          },
        },
      },
      failure: {
        type: 'final',
      },
    },
  },
  {
    actions: {
      checkBrowser: assign({
        isChrome: _ => navigator.userAgent.includes('Chrome'),
      }),
      displayError: assign<NumberGuessContextType, NotSupportedErrorType>({
        error: (_, event) => event.error,
      }) as any,
      initGame: assign({
        hint: _ => '',
        recognition: _ => new window.SpeechRecognition(),
        randomNumber: _ => Math.floor(Math.random() * 100) + 1,
      }),
      updateHint: assign<NumberGuessContextType, UpdateHintType>({
        hint: (_, event) => event.data,
      }) as any,
      clearHint: assign({
        hint: _ => '',
      }),
    },
    guards: {
      hasError(_, event: NumberGuessEventType) {
        if (event.type === 'NOT_ALLOWED_ERROR') {
          return event.error !== ''
        }
        return false
      },
      hasHint(context) {
        return context.hint !== ''
      },
      isUnsupportedBrowser(_, event: NumberGuessEventType) {
        return event.type !== 'NOT_SUPPORTED_ERROR'
      },
      isSpeechRecognitionReady() {
        window.SpeechRecognition =
          window.SpeechRecognition || window.webkitSpeechRecognition
        return window.SpeechRecognition !== undefined
      },
      isPlayAgain(_, event: NumberGuessEventType) {
        if (event.type === 'SPEAK') {
          return event.message === 'play'
        }
        return false
      },
    },
    services: {
      checkNumber(
        context: NumberGuessContextType,
        event: NumberGuessEventType
      ) {
        if (event.type !== 'SPEAK') {
          return Promise.reject('AcciĂłn no vĂĄlida.')
        }

        const num = +event.message

        if (Number.isNaN(num)) {
          return Promise.reject('Ese no es un nĂșmero vĂĄlido, intenta de nuevo')
        }

        if (num > 100 || num < 1) {
          return Promise.reject('El nĂșmero debe estar entre 1 y 100')
        }

        if (num === context.randomNumber) {
          return Promise.resolve('ÂĄFelicidades has ganado!')
        }

        if (num > context.randomNumber) {
          return Promise.reject('MENOR')
        }

        return Promise.reject('MAYOR')
      },
    },
  }
)

export { numberGuessMachine }
Enter fullscreen mode Exit fullscreen mode

Using our machine

Time to use our numberGuessMachine in the App component.

<script lang="ts">
  import { onMount, onDestroy } from 'svelte'
  import { interpret } from 'xstate'
  import { realisticLook } from 'src/utils'
  import { numberGuessMachine } from 'src/machine/numberGuess'

  const service = interpret(numberGuessMachine).start()

  function onSpeak(event: SpeechRecognitionEvent) {
    const [result] = event.results
    const [transcripts] = result
    const { transcript: message } = transcripts
    service.send({
      message,
      type: 'SPEAK',
    })
  }

  onMount(() => {
    if (!$service.context.isChrome) {
      return service.send({
        type: 'NOT_SUPPORTED_ERROR',
        error: 'Lo siento, tu navegador no soporta la API SpeechRecognition.',
      })
    }

    navigator.mediaDevices
      .getUserMedia({ audio: true })
      .then(() => {
        service.send({
          type: 'CHECK_READINESS',
        })

        const recognition = $service.context.recognition
        if (!recognition) {
          return
        }

        recognition.start()
        recognition.addEventListener('result', onSpeak)
        recognition.addEventListener('end', () => recognition.start())
      })
      .catch(() => {
        service.send({
          type: 'NOT_ALLOWED_ERROR',
          error:
            'Por favor, permita el uso del đŸŽ€ para poder jugar. Y despuĂ©s recargue la pĂĄgina.',
        })
      })
  })

  onDestroy(() => {
    $service.context?.recognition?.stop()
    service.stop()
  })

  service.onTransition(state => {
    if (state.matches('gameOver')) {
      realisticLook()
    }
  })
</script>

<section class="container" data-state={$service.toStrings().join(' ')}>
  {#if $service.matches('failure') && !$service.context.isChrome}
    <div>{$service.context.error}</div>
  {/if}
  {#if $service.matches('playing')}
    <div>
      <svg
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
        stroke="currentColor"
        class="mic"
      >
        <path
          stroke-linecap="round"
          stroke-linejoin="round"
          stroke-width="2"
          d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
        />
      </svg>

      <h1>Adivina el nĂșmero entre 1 y 100</h1>

      <h3>Menciona el nĂșmero que desees (en inglĂ©s).</h3>

      <div class="msg">
        {$service.context.hint}
      </div>
    </div>
  {/if}
  {#if $service.matches('gameOver')}
    <div>
      <h2>
        {$service.context.hint}
        <br />
        <br />
        El nĂșmero era: {$service.context.randomNumber}
      </h2>
      <button
        class="play-again"
        on:click={() =>
          service.send({
            type: 'PLAY_AGAIN',
          })}>Play</button
      >
      <p class="mt-1">O menciona "play"</p>
    </div>
  {/if}
  {#if $service.matches('failure') && $service.context.isChrome}
    <div>
      {$service.context.error}
    </div>
  {/if}
</section>
Enter fullscreen mode Exit fullscreen mode

Notes

đŸ’» Source code: number-guess

Happy coding đŸ‘‹đŸœ

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

👋 Kindness is contagious

Please leave a ❀ or a friendly comment on this post if you found it helpful!

Okay