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 | undefined6 gameboard: LetterButton[] | undefined7 shuffleTrigger: (shuffleType: 'shuffle' | 'unshuffle') => void8 handleSubmit: () => void9 boardOpacity: number10}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 LetterSubmission
s. 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.
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.gameboard
is created here in the GameContext and is later consumed by the<InputBoard>
component, for reasons we'll get into below.shuffleTrigger
is a function that can be called to change the position of the letter buttons.handleSubmit
handles the submission of a word.boardOpacity
is responsible for fading the buttons in and out on a shuffle action.
Lets look at those types.
1interface LetterSubmission {2 letter: string3 letterId: string4}56interface LetterButton extends LetterSubmission {7 originalPosition: number8 currentPosition: number | undefined9}
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 context7}
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)78 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 removedItem17 }18 const newGameboard: LetterButton[] = gameboard.map((letterButton) => {19 return { ...letterButton, currentPosition: selectPosition(newPositions) }20 })21 setGameboard((prev) => newGameboard)22 }23 }2425 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 }3536 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 }4748 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])5960 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 }6768 return (69 <GameContext.Provider70 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 undefined3 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 removedItem10 }11 const newGameboard: LetterButton[] = gameboard.map((letterButton) => {12 return { ...letterButton, currentPosition: selectPosition(newPositions) }13 })14 setGameboard((prev) => newGameboard)15 }16}1718const 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}2829const 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.Provider3 value={{4 answers,5 currentAnswer,6 setCurrentAnswer,7 puzzleWord,8 handleSubmit,9 gameboard,10 shuffleTrigger,11 boardOpacity12 }}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.