Build a game with Svelte, XState and SpeechRecognition
Tags: xstate, typescript, svelte
Published: 2021-03-22 00:43:50

---
title: Build a game with Svelte, XState and SpeechRecognition
published: true
description: 
tags: xstate, typescript, svelte
cover_image: https://images.pexels.com/photos/1111597/pexels-photo-1111597.jpeg?cs=srgb&dl=pexels-fotografierende-1111597.jpg&fm=jpg
---

Hello folks this weekend I've been playing with `XState`, `Svelte` and the [SpeechRecognition API](https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition) :microphone:, so I decided to build a mini number guessing game and model my states with a [statechart](https://en.wikipedia.org/wiki/State_diagram), so let's see how to do it.

> If you want to try it out go to this :earth_americas: [Live demo](https://number-guess-two.vercel.app/) (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 :stuck_out_tongue_closed_eyes:), 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.

```ts
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'
}
```

## 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`.

```ts
export declare global {
  interface Window {
    webkitSpeechRecognition: SpeechRecognition
  }
}
```

## Machine

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

```ts
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 }
```

## Using our machine

Time to use our `numberGuessMachine` in the `App` component.

```ts
<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>
```

## Notes

:computer: Source code: [number-guess](https://github.com/gustavocd/number-guess)

Happy coding 👋🏽