Creating 'Ars Magna' | Chapter 1

Author

Jeff Spurlock

Date Published

When setting out to develop something like this, I would love to tell you that I mapped out everything exactly how it was going to be in the end, but thats just not the way I operate. This began with the desire to make a simple puzzle. I'm a big fan of the New York Times Spelling Bee puzzle, so I already had that in mind, and decided instead of using 7 preset letters and a designated answer list, I would instead provide a seed word of 10 letters, and every answer submitted had to be an anagram or partial anagram of that word. I also didn't want to deal with a defined answers list, so I'm making use of the freedictionary.dev API to check spelling and word validity.

Lets dive in.

The most important piece of this game is a component called <GameProvider>, and it is responsible for providing context to the rest of the game's components. Admittedly, I'm being lazy here because this grew to be much larger that I intended and I should refactor this entire application to use Zustand instead, but, here we go...

1interface GameContextType {
2 answers: string[]
3 currentAnswer: LetterSubmission[]
4 setCurrentAnswer: Dispatch<SetStateAction<LetterSubmission[]>>
5 puzzleWord: string | undefined
6 gameboard: LetterButton[] | undefined
7 shuffleTrigger: (shuffleType: 'shuffle' | 'unshuffle') => void
8 handleSubmit: () => void
9 boardOpacity: number
10}
11const GameContext = createContext<GameContextType | undefined>(undefined)

Here we have the type declaration for all the elements our game context is going to provide down into our components. The answers object is a simple array of accepted answers.

currentAnswer takes an array of LetterSubmissions. This is the core piece of how the game functions. Each time a letter button is clicked, it adds a LetterSubmission to the currentAnswer array, (using the setCurrentAnswer SetStateAction also provided in the GameContext).

The next few all deal with setting up the game board.

  1. puzzleWord is a variable that is actually passed into <GameProvider> from Payload CMS. I don't want to have to publish new code every time I want to change the word, so this entire game is actually a block in Payload. This isn't about Payload though, so maybe we'll talk about this some other time.
  2. gameboard is created here in the GameContext and is later consumed by the <InputBoard> component, for reasons we'll get into below.
  3. shuffleTrigger is a function that can be called to change the position of the letter buttons.
  4. handleSubmit handles the submission of a word.
  5. boardOpacity is responsible for fading the buttons in and out on a shuffle action.

Lets look at those types.

1interface LetterSubmission {
2 letter: string
3 letterId: string
4}
5
6interface LetterButton extends LetterSubmission {
7 originalPosition: number
8 currentPosition: number | undefined
9}

Real simple. A letter submission just needs a letter and an ID, and the letter buttons add an original and current position.

useGameContext Hook

Last bit of house keeping here is how all of this information is accessed. Instead of calling useContext(GameContext) all over the application, i did end up writing a little custom hook. I had bigger plans for it, but they ended up not being needed.

1const useGameContext = () => {
2 const context = useContext(GameContext)
3 if (!context) {
4 throw new Error('useGameContext must be used within GameProvider')
5 }
6 return context
7}


OK, now for the meat of it... I'll show you the whole thing first and then I'll break it down.

GameProvider

1const GameProvider: FC<PropsWithChildren<{ puzzleWord?: string }>> = ({ children, puzzleWord }) => {
2 const storedValueRef = useRef<string[]>([])
3 const [answers, setAnswers] = useState<string[]>(storedValueRef.current)
4 const [currentAnswer, setCurrentAnswer] = useState<LetterSubmission[]>([])
5 const [gameboard, setGameboard] = useState<LetterButton[] | undefined>(getGameboard(puzzleWord))
6 const [boardOpacity, setBoardOpacity] = useState(1)
7
8 const shuffleBoard = () => {
9 if (!gameboard) {
10 throw new Error('Cannot Shuffle, Gameboard not set')
11 } else {
12 const newPositions = Array.from(Array(gameboard.length).keys())
13 const selectPosition = (newPositions: number[]) => {
14 const randomIndex = Math.floor(Math.random() * newPositions.length)
15 const [removedItem] = newPositions.splice(randomIndex, 1)
16 return removedItem
17 }
18 const newGameboard: LetterButton[] = gameboard.map((letterButton) => {
19 return { ...letterButton, currentPosition: selectPosition(newPositions) }
20 })
21 setGameboard((prev) => newGameboard)
22 }
23 }
24
25 const unshuffleBoard = () => {
26 if (!gameboard) {
27 throw new Error('Cannot Shuffle, Gameboard not set')
28 } else {
29 const newGameboard: LetterButton[] = gameboard.map((letterButton) => {
30 return { ...letterButton, currentPosition: letterButton.originalPosition }
31 })
32 setGameboard((prev) => newGameboard)
33 }
34 }
35
36 const shuffleTrigger = (shuffleType: 'shuffle' | 'unshuffle') => {
37 setBoardOpacity(0)
38 if (shuffleType === 'shuffle') {
39 shuffleBoard()
40 } else {
41 unshuffleBoard()
42 }
43 setTimeout(() => {
44 setBoardOpacity(1)
45 }, 200)
46 }
47
48 useEffect(() => {
49 const game = JSON.parse(localStorage.getItem('game') || '{}')
50 if (game === puzzleWord) {
51 storedValueRef.current = JSON.parse(localStorage.getItem('ars') || '[]')
52 } else {
53 storedValueRef.current = []
54 localStorage.setItem('game', JSON.stringify(puzzleWord))
55 localStorage.setItem('ars', JSON.stringify(storedValueRef.current))
56 }
57 setAnswers(storedValueRef.current)
58 }, [puzzleWord])
59
60 function handleSubmit() {
61 const newAnswer = currentAnswer.map((sub) => sub.letter).join('')
62 const oldAnswerSet = JSON.parse(localStorage.getItem('ars') || '[]')
63 const newAnswerSet: string[] = [...oldAnswerSet, newAnswer]
64 localStorage.setItem('ars', JSON.stringify(newAnswerSet))
65 setAnswers(newAnswerSet)
66 }
67
68 return (
69 <GameContext.Provider
70 value={{
71 answers,
72 currentAnswer,
73 setCurrentAnswer,
74 puzzleWord,
75 handleSubmit,
76 gameboard,
77 shuffleTrigger,
78 boardOpacity,
79 }}
80 >
81 {children}
82 </GameContext.Provider>
83 )
84}

Lets take it a piece at a time.

1const storedValueRef = useRef<string[]>([])
2const [answers, setAnswers] = useState<string[]>(storedValueRef.current)
3const [currentAnswer, setCurrentAnswer] = useState<LetterSubmission[]>([])
4const [gameboard, setGameboard] = useState<LetterButton[] | undefined>(getGameboard(puzzleWord))
5const [boardOpacity, setBoardOpacity] = useState(1)

The first thing we declare here is the storedValueRef, which is used in the default values of the answers state variables. The rest of these are the state variables and SetStateActions that will be passed into the provider.

The storedValueRef begins as an empty array here, but further down we have a useEffect() that is curcial to the way this game persists answers between page loads.

1 useEffect(() => {
2 const game = JSON.parse(localStorage.getItem('game') || '{}')
3 if (game === puzzleWord) {
4 storedValueRef.current = JSON.parse(localStorage.getItem('ars') || '[]')
5 } else {
6 storedValueRef.current = []
7 localStorage.setItem('game', JSON.stringify(puzzleWord))
8 localStorage.setItem('ars', JSON.stringify(storedValueRef.current))
9 }
10 setAnswers(storedValueRef.current)
11}, [puzzleWord])
12

This game uses a browsers local storage to keep a copy of your accepted answers. It will also reset your accepted answers if I change the puzzleWord in my CMS. starting with the game variable, we look at local storage to see what word the user last played, if it is equal to the current puzzle word, we set our storedValueRef.current equal to a the array stored in local storage. This means that the answers in the UI will match the answers in your local storage when you load the page. If it doesn't match the puzzleWord, then there are two possible scenarios; the user has never played, so it doesn't exist in their local storage, or you have played, and the word has been updated. In both instances, we want to do the same thing; set the storedValueRef to an empty array of answers, and set the puzzleword so we can persist the users score for this word.

Note: for those of you who see the vulnerability here, yes, this game is incredibly easy to cheat if you know how to modify the string value in your browsers local storage.

The next important part here is the getGameboard() function that is passed into the initial value of the gameboard state variable.

1const getGameboard = (puzzleWord: string | undefined): LetterButton[] | undefined => {
2 if (!puzzleWord) return undefined
3 return puzzleWord?.split('').map((letter, index): LetterButton => {
4 return {
5 letter: letter,
6 letterId: `${letter}-${index}`,
7 originalPosition: index,
8 currentPosition: index,
9 }
10 })
11}

This function takes the puzzleWord, splits it into an array of each letter of the word, and then maps that array to an array of LetterButton objects. Once instantiated, the letter, letterId, and originalPosition properties never change, but that currentPosition property is important. This is used in the <InputBoard> component to set the flex order property. This value is changed by two functions, shuffleBoard() and unshuffleBoard(), which are both called by the shuffleTrigger() function, which is provided in the GameContext.

1const shuffleBoard = () => {
2 if (!gameboard) {
3 throw new Error('Cannot Shuffle, Gameboard not set')
4 } else {
5 const newPositions = Array.from(Array(gameboard.length).keys())
6 const selectPosition = (newPositions: number[]) => {
7 const randomIndex = Math.floor(Math.random() * newPositions.length)
8 const [removedItem] = newPositions.splice(randomIndex, 1)
9 return removedItem
10 }
11 const newGameboard: LetterButton[] = gameboard.map((letterButton) => {
12 return { ...letterButton, currentPosition: selectPosition(newPositions) }
13 })
14 setGameboard((prev) => newGameboard)
15 }
16}
17
18const unshuffleBoard = () => {
19 if (!gameboard) {
20 throw new Error('Cannot Shuffle, Gameboard not set')
21 } else {
22 const newGameboard: LetterButton[] = gameboard.map((letterButton) => {
23 return { ...letterButton, currentPosition: letterButton.originalPosition }
24 })
25 setGameboard((prev) => newGameboard)
26 }
27}
28
29const shuffleTrigger = (shuffleType: 'shuffle' | 'unshuffle') => {
30 setBoardOpacity(0)
31 if (shuffleType === 'shuffle') {
32 shuffleBoard()
33 } else {
34 unshuffleBoard()
35 }
36 setTimeout(() => {
37 setBoardOpacity(1)
38 }, 200)
39}

Here is where the shuffleTrigger() function is defined to be passed into the context. You'll see it calls the shuffleBoard() or unshuffleBoard() functions depending on its input, and also sets the board's opacity to 0, and later back to 1, for that fade in and out effect that we'll talk about in a later chapter.

shuffleBoard() creates an array of numbers, newPositions, based on the length of the gameboard object. Next we have function called selectPosition() that will randomly pick one of the numbers from the newPositions array and remove it from the array. Now we're ready to create our new gameboard state. We instantiate newGameboard by mapping the existing gameboard, and returning all of the existing values with the spread operator, but overwrite the currentPosition with a selected new position; currentPosition: selectPosition(newPositions). finally, we pass the newGameboard object into the setGameboard() SetStateAction.

Fortunately, unshuffleBoard() is significantly simpler. This function just maps through the array, setting the currentPosition property to the value stored in originalPosition and the resulting array is passed into the setGameboard() SetStateAction.

Finally, we have our last piece of our GameContext, the handleSubmit() function

1 function handleSubmit() {
2 const newAnswer = currentAnswer.map((letterSubmission) => letterSubmission.letter).join('')
3 const oldAnswerSet = JSON.parse(localStorage.getItem('ars') || '[]')
4 const newAnswerSet: string[] = [...oldAnswerSet, newAnswer]
5 localStorage.setItem('ars', JSON.stringify(newAnswerSet))
6 setAnswers(newAnswerSet)
7 }

This is actually only half of the submit function. This does not deal with any of the validation and user feedback, as that is all handled in the <Submission> component, which we will look at in a later chapter, but once we know a word has been accepted, we want to set it in two places; the answers state variable and the browser local storage. We can get the newAnswer by a combination of the map() function, returning the letter property of each letter submission in the currentAnswer array, and then the join() function to combine the array of letters into a single string. From there we can pull the oldAnswerSet out of the localStorage, and create a newAnswerSet by spreading in the oldAnswerSet and adding the newAnswer. Lastly, set the localStorage to the newAnswerSet, and set the newAnswerSet to the answers state variable.

JSX Return Statement

This is going to seem underwhelming. After all of that, the only thing <GameProvider> returns is a single component wrapped around its children.

1return (
2 <GameContext.Provider
3 value={{
4 answers,
5 currentAnswer,
6 setCurrentAnswer,
7 puzzleWord,
8 handleSubmit,
9 gameboard,
10 shuffleTrigger,
11 boardOpacity
12 }}
13 >
14 {children}
15 </GameContext.Provider>
16 )

But you can see all of those functions we've defined to be used later and all the game state we need to manipulate with the UI all lives here in this GameContext.Provider component. Here's a sneak peak at the rest of the application though, and you'll see how important this piece becomes.

1<GameProvider puzzleWord={puzzleWord?.toUpperCase()}>
2 <GameHeading />
3 <InputBoard />
4 <Submission />
5</GameProvider>

Thats all it is. We have <GameHeading>, which has UI controls for shuffling, unshuffling, displaying the current score, and a popover for showing all of the words that have been accepted. <InputBoard> is where each LetterButton exists, and controls how and where these buttons are displayed and controls how items get added to the currentAnswer state. And then <Submission> , which shows the letters the user has selected to make a word, provides a backspace function to remove the last letter submission, makes async calls to api.freedictionary.dev to verify a words validity, provides user feed back through a toast component. We'll have a chapter on each of these components, but they will fortunately be shorter than this one.