How I built a React-based Snake game with Context API
When I worked on the Sudoku app, I made a conscious decision to use Redux for state management. In my blog post titled How I built a web-based Sudoku app, I said that I would give it a try to use just the Context API to manage the app state in a future project.
That project was finished yesterday as I decided that it would be better to spend my time working on something fun rather than lurking over Reddit and Blind for the latest updates on the whole SVB saga. I built a React-based Snake game.
The game is available for free at http://sean-snake.netlify.com/. Check it out and let me know if you’re able to reach the end of it. The source code is available at https://github.com/seanluong/snake.
At a high level, this is a React-based application that was written in TypeScript with React MUI as the component libraries. While this app (so far) doesn’t involve any backend work (check out my blog post about the backend part of the Sudoku app here), it does come with a fair share of challenges and interesting lesson learned. This post will discuss my 3 favorite things about working on this project.
Handle keyboard events
While the Sudoku app is mostly mouse click driven except for when users have to enter numbers, the nature of the Snake game requires more active interaction between a user and the game. On desktop it probably makes the most sense to allow people to move the snake and change its direction by pressing arrow keys.
The difference between handling mouse click events and key press events is that it’s more straightforward to identify the element to which the event handler should be linked for a mouse click than for a key press.
For the Snake game, we would want the event handler for the key press events to be linked to the whole HTML document so that as long as users focus on the document the events will be handled. This was done by using a combination of useEffect
and useRef
as follows
// Use useRef to have a reference to the `document` object
const documentRef = useRef<Document>(document);
useEffect(() => {
// Check if the document is available for access
if (documentRef.current) {
const document = documentRef.current;
// Add the event listener
document.addEventListener('keydown', handleKeyDowned);
// Clean up
return () => {
document.removeEventListener('keydown', handleKeyDowned)
}
}
}, [documentRef]);
Support users on mobile devices
While it’s probably best for desktop users to interact with the game via key press events, mobile users probable find it easier to play the game by tapping on the screen. This is one of the challenges of the Snake game poses that I didn’t face with Sudoko app.
At first, I thought of allowing people to tap any where on the board. The event handler would detect where the tapped target was with respect to the snake’s current position and calculate the new direction accordingly. One potential UX issue with this approach is that tapping on the board could prevent users from having a complete view of the game due to the presence of their own fingers. Because I didn’t want users to put their fingers on the board, I decided to create buttons on the screen for each of the 4 movement directions. The final UI looks like this:
I used react-device-detect to check if a user was playing from mobile or from desktop.
Manage the game state
The Context API enables making certain pieces of data available to descendant components without having to do prop drilling. Unlike Redux, which is an external library, the Context API is built-in to React so there’s no need to install another dependency. Another nice thing about the Context API is that we can take advantage of a hook called useContext
to access the data stored in the context.
To use the Context API for state management we can create a context at the top-level of the app to store the app state object. To update the app state, we need to use useReducer
, a React hook that is meant to be used to manage non-trivial component’s local state. If the state is not a primitive value (e.g. a number, a string, or a boolean value, etc.) then you should probably be using useReducer
instead of useState
.
Create the context provider for the state
In the Snake game, I defined a GameStateProvide
component
interface GameStateContextType {
gameState: GameState;
dispatch: (action: Action) => void;
}
const GameStateContext = createContext<GameStateContextType>({
gameState: {} as GameState,
dispatch: () => {},
})
const GameStateProvider = ({ children }: PropsWithChildren) => {
const [gameState, dispatch] = useReducer(reducer, /* initial game state */);
return (
<GameStateContext.Provider value={{ gameState, dispatch }}>
{children}
</GameStateContext.Provider>
);
}
then used GameStateProvider
at the top-level of the app as follows
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<GameStateProvider>
<App />
</GameStateProvider>
</React.StrictMode>,
)
By having GameStateProvided
wrapped around App
, all the data stored in the GameStateContext
is now made available to App
and any of its descendant.
Access the data in the game state’s context
While useContext
is good enough for any component in our component tree to access the app state, it’s more convenient to define a custom hook. This was done by the code below:
const useGameStateContext = () => useContext(GameStateContext);
When a component wants to access the app state, we can call useGameStateContext
as follows
const { gameState, dispatch } = useGameStateContext();
gameState
object is the app state. dispatch
is a function that is returned by calling useReducer
. We call dispatch
whenever we need to dispatch an action that will be processed by the app’s reducer. This pattern should be no stranger to people who are familiar with Redux.