DEV Community

loading...
Cover image for Build a game with Svelte, XState and SpeechRecognition

Build a game with Svelte, XState and SpeechRecognition

Gustavo Castillo
I'm a web developer I won't fix your computer >:(
・4 min read

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 👋🏽

Discussion (0)