How I Built a Taboo mobile game

George Kalogeropoulos
·
July 09, 2021

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

Typescript setup

xstate

  yarn add xstate @xstate/react

Typescript setup

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

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

Settings Screen
  • round time

  • correct answer mutliplier

  • target points

Waiting Game Screen

Waiting Game Screen

We start the round ( target points 5 ).

<PrimaryButton
	onPress={() => {
		send(gameModel.events.START_ROUND());
	}}
>
	εναρξη
</PrimaryButton>

Team playing screen

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

You can find the code below

github

Built with Next.js, MDX, Tailwind and Vercel