How I Built a Taboo mobile game
Me and my friends love Taboo. If you don't know how to play Taboo you can find more information here. I have a lot of experience with React but not so much with React Native . I decided to build a Taboo game using React Native just for fun.Taboo is a simple game but the logic behind it is not so simple . So I decided to use xstate to orchestrate the state management.
Setup
I decided to use typescript with this project
Expo
npm install --global expo-cli expo init taboo
xstate
yarn add xstate @xstate/react
Game machine
Game has some settings
-
round time
-
correct answer mutliplier
-
target points
interface Settings { roundTimeInSeconds: number; correctAnwserMultiplier: number; targetPoints: number; } export const defaultSettings: Settings = { roundTimeInSeconds: 20, correctAnwserMultiplier: 1, targetPoints: 5, };
In a taboo game teams are trying to find the word without saying the forbidden words. The game ends when a team reaches the target points.
interface Settings { roundTimeInSeconds: number; correctAnwserMultiplier: number; targetPoints: number; } export const defaultSettings: Settings = { roundTimeInSeconds: 20, correctAnwserMultiplier: 1, targetPoints: 5, }; type Winner = { name: string, points: number, }; interface Question { word: string; forbiddenWords: string[]; }
Now let's define the machine shape using xstate create model
export const gameModel=createModel({ settings: defaultSettings, secondsUntilEndOfRound: defaultSettings.roundTimeInSeconds, currentTeam: 'A' as 'A' | 'B', currentQuestion: {} as Question, winner: {} as Winner, teams: { teamA: { name: 'teamA', points: 0, }, teamB: { name: 'teamB', points: 0, }, }, questions: [{... your questions array}] })
I decided to go with 4 hierarchical states
-
settings
-
waiting
-
playing
-
deciding game result
-
ended
settings
Players should be able to change game settings
settings: { on: { 'OK': { target: 'waitingGame', }, 'SETTINGS_CHANGE': { actions:"settingsChange", } },
wating game
In the waiting game state players can select team names and start the game
waitingGame: { on: { 'SHOW_SETTINGS': { target: 'settings', }, START_GAME: { target: 'playing', actions: 'pickRandomQuestion', }, }, },
pickRandomQuestion just selects a random question from the question pool when the game starts
playing
In the playing state we have two nested states teamA and teamB. At the beginning we need to wait the player to start the round. Whenever a team is playing we need to save the current team in the context so it is easier to manipulate context and avoid duplication.
Also we need to respond to players answers (correct answer,wrong answer ,pass ) and pick a random question in every round.
Every second we need to decrement the remaining time of the round. When the round end we need to transition to the other team. So teamA's state looks like this.
teamA:{ id: 'A', tags: 'teamPlaying', initial: 'waiting', entry: ['currentTeamA', 'pickRandomQuestion'], states: { waiting: { tags: ['waiting'], on: { 'START_ROUND': 'playing', 'RESET_GAME': { target: '#taboo.waitingGame', actions: 'resetGame', }, }, }, playing: { tags: ['playing'], always: { target: '#B', cond: 'hasRoundEnded', actions: ['resetRoundTime'], }, exit: 'resetRoundTime', invoke: { src: 'decrementRoundTimeService', }, on: { PAUSE_GAME: { target: 'paused', }, CORRECT_ANSWER: { actions: ['calculatePoints', 'pickRandomQuestion'], }, WRONG_ASNWER: { actions: ['pickRandomQuestion'], }, RESET_GAME: { target: '#taboo.waitingGame', actions: 'resetGame', }, PASS: { actions: ['pickRandomQuestion'], }, DECREMENT_ROUND_TIME: { actions: 'decrementRoundTime', }, }, }, paused: { tags: ['paused'], on: { CONTINUE_GAME: { target: 'playing', }, }, }, }, }
I am using tags and ids , you can find more in xstate's docs
In the same way teamB's state looks like this
teamB: { id: 'B', tags: 'teamPlaying', initial: 'waiting', entry: ['currentTeamB', 'pickRandomQuestion'], states: { waiting: { tags: ['waiting'], on: { 'START_ROUND': 'playing', 'RESET_GAME': { target: '#taboo.waitingGame', actions: 'resetGame', }, }, }, paused: { tags: ['paused'], on: { CONTINUE_GAME: { target: 'playing', }, }, }, playing: { exit: ['resetRoundTime'], tags: ['playing'], always: { target: '#deciding', cond: 'hasRoundEnded', }, invoke: { src: 'decrementRoundTimeService', }, on: { PAUSE_GAME: { target: 'paused', }, DECREMENT_ROUND_TIME: { actions: 'decrementRoundTime', }, CORRECT_ANSWER: { actions: ['calculatePoints', 'pickRandomQuestion'], }, WRONG_ASNWER: { actions: ['pickRandomQuestion'], }, PASS: { actions: ['pickRandomQuestion'], }, }, }, }, },
At the end of the round of the second team we need to check if the game has ended. So basically we check if any team has reached the target points.
deciding game result
decidingGameResult: { id: 'deciding', always: [ { target: 'ended', cond: 'gameEnded', actions: 'assignWinner', }, { target: '#A', }, ], },
ended
ended: { on: { 'RESET_GAME': { target: '#taboo.waitingGame', actions: 'resetGame', }, }, tags: 'ended', }, },
When the game end players should be able to reset the game
We use a switch statement to decide which screen to show
<Box _web={{ maxWidth: 500, }} minHeight={500} px={12} w='100%' justifyContent='space-between' > {(() => { switch (true) { case state.matches('waitingGame'): return <StartScreen />; case state.matches('settings'): return <SettingsScreen />; case state.hasTag('paused'): return <PauseScreen />; case state.hasTag('playing'): return <TeamPlaying />; case state.hasTag('teamPlaying'): return <TeamWaiting />; case state.hasTag('ended'): return <EndScreen />; default: return null; } })()} </Box>
Start Screen
To show the settings screen
<Icon onPress={() => { send({ type: 'SHOW_SETTINGS' }); }} marginLeft={5} color='white' height={30} width={30} as={<Ionicons name='settings-outline' />} />
Settings Screen
-
round time
-
correct answer mutliplier
-
target points
Waiting Game Screen
We start the round ( target points 5 ).
<PrimaryButton onPress={() => { send(gameModel.events.START_ROUND()); }} > εναρξη </PrimaryButton>
Team playing screen
<Stack mt={10} direction='row' space={4} justifyContent='space-between'> <ControlIcon onPress={() => { send(gameModel.events.CORRECT_ANSWER()); }} bg='green.100' icon={<Icon as={Ionicons} size={8} color='gray.500' name='checkmark' />} /> <ControlIcon onPress={() => { send(gameModel.events.WRONG_ASNWER()); }} bg='rose.400' icon={<Icon as={Ionicons} size={8} color='white' name='close-outline' />} /> <ControlIcon onPress={() => { send(gameModel.events.PASS()); }} bg='white' icon={ <Icon as={Ionicons} size={8} color='gray.500' name='play-skip-forward' ml={0.5} /> } /> </Stack>
-
correct answer
-
wrong answer
-
pass