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' }
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 } }
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 }
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>
Notes
💻 Source code: number-guess
Happy coding 👋🏽
Top comments (0)