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)