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)