Let's make the skeleton of a clicker (idle) game in React. To manage our game's state, I'll start off by using the useState hook, and later upgrade it to redux toolkit. I came across this clicker vanilla javascript tutorial and thought it'd be helpful to expand upon it using React and its hooks.
useState Clicker
Create a new react project using create-react-app or Vite. I'm using Vite with typescript:
npm create vite@latest .
Create a "components" folder and a file named "Game.tsx" if you're using typescript. In that file, I use the "tsrafce" snippet to generate the boilerplate:
// Game.tsx
import React from 'react'
type Props = {}
const Game = (props: Props) => {
return (
<div>Game</div>
)
}
export default Game
I then import Game from components into App.tsx:
// App.tsx
import Game from "./components/Game"
function App() {
return (
<div className="App">
<Game />
</div>
)
}
Setup
To set up the clicker game, we'll need to store some state: how much currency the user has, how powerful their clicks are (in generating currency), how much currency they generate idly, etc. For now, store the state in a useState object:
// Game.tsx
import React, { useState } from 'react'
type Props = {}
const Game = (props: Props) => {
// store game state
const [gameState, setGameState] = useState({
currency: 0,
clickPower: 10,
currencyPerMs: .001
})
return (
<div>Game</div>
)
}
export default Game
Give the div inside the Game component a style so that users have some area to click in, and make sure that your App itself has some width and height.
.game__container {
width: 200px;
height: 200px;
border: 1px solid black;
}
// Game.tsx
import React, { useState } from 'react'
import './styles.css'
type Props = {}
const Game = (props: Props) => {
// store game state
const [gameState, setGameState] = useState({
currency: 0,
clickPower: 10,
currencyPerMs: .001
})
return (
<div className="game__container">Game</div>
)
}
export default Game
User Clicks
To update the game on each user click, add a click handler to mutate the currency inside gameState. You could do this with a button or svg, whereas I'm simply allowing users to click inside the game div to increment their currency.
// Game.tsx
const Game = (props: Props) => {
// store game state
const [gameState, setGameState] = useState({
currency: 0,
clickPower: 10,
currencyPerMs: .001
})
const handleClick = () => {
setGameState(prevState => ({
...prevState, // spread prevState values
currency: parseFloat((prevState.currency + prevState.clickPower).toFixed(2)) // update currency
}))
console.log("clicked")
}
return (
<div
className="game__container"
onClick={() => handleClick()}
>
<h3>Currency: {gameState.currency}</h3>
</div>
)
}
export default Game
The parseFloat((prevState.currency + prevState.clickPower).toFixed(2)) code uses toFixed(2) to round the number to two places just in case our values are decimals later on, while parseFloat converts the string returned by toFixed(2) to a number.
Now, when the user clicks inside the box, the currency value should be updated inside state.
AutoClicker
Let's move on to autoclicking: the user will gain some amount of currency every millisecond (or some other interval). We'll have to use useEffect and setInterval.
// Game.tsx
const Game = (props: Props) => {
// store game state
const [gameState, setGameState] = useState({
currency: 0,
clickPower: 10,
currencyPerMs: .001
})
const handleClick = () => {
setGameState(prevState => ({
...prevState, // spread prevState values
currency: parseFloat((prevState.currency + prevState.clickPower).toFixed(2)) // update currency
}))
console.log("clicked")
}
const updateGame = (delta_time: number) => {
setGameState(prevState => ({
...gameState,
currency: parseFloat((prevState.currency + prevState.currencyPerMs * delta_time).toFixed(2))
}))
}
// game loop
useEffect(() => {
let last_time: null | number = null // previous current time
const loop = setInterval(() => {
const time_now = performance.now() // current time
if (last_time === null) {
last_time = time_now
}
const delta_time = time_now - last_time // change in time
last_time = time_now
updateGame(delta_time)
}, 1000 / 60) // frequency
return () => clearInterval(loop) // clean up the interval
}, [])
return (
<div
className="game__container"
onClick={() => handleClick()}
>
<h3>Currency: {gameState.currency}</h3>
</div>
)
}
export default Game
We add an updateGame function in order to update the currency in the effect, every time the interval runs. In the interval inside the effect, we first initialize a variable to store the previous time, last_time, in order to later calculate the change in time const delta_time = time_now - last_time between then and const time_now = performance.now().
Here's a sandbox with the current code so far without typescript.
Redux Clicker
To implement the clicker using redux toolkit instead of useState, we'll need a store, a slice with some reducers, and a few custom hooks to grab the data from the store (useSelector) and to update the data in the store (useDispatch).
Store Setup
Download react-redux and redux toolkit:
npm i react-redux @reduxjs/toolkit
Create a State folder, with the files store.ts and gameSlice.ts. In store.ts, do some initial setup:
// store.ts
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({
reducer: {
}
})
// some typing
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
At the moment we don't have any reducers, so we'll need to create them in gameSlice, import them into store.tsx, and place them inside the store.
First, wrap the App in a store provider so we can access and update the store wherever we want in the app:
// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import './index.css'
import App from './App'
import { store } from './State/store'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
Now setup the gameSlice. We'll use the same state data structure as we did in the useState version.
// gameSlice.ts
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
currency: 0,
clickPower: 10,
currencyPerMs: .001
}
const gameSlice = createSlice({
name: "game",
initialState,
reducers: {}
})
// export reducer
export default gameSlice.reducer
Inside the reducers: {} in gameSlice is where we'll store the logic to update the game state. For now, we export the gameSlice.reducer to import it into store.tsx:
// store.ts
import { configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector } from 'react-redux'
import type { TypedUseSelectorHook } from 'react-redux'
import gameReducer from './gameSlice'
export const store = configureStore({
reducer: {
game: gameReducer // here's where your reducers live if you want to add more
}
})
// store typing
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
// hooks typing
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
Instead of using the base useSelector and useDispatch hooks, we'll use gently typed hooks exported from the store.tsx file above.
Game Logic
As an alternative to the updateGame function used in the useState clicker's game loop, we'll dispatch actions to update state. We need to create those actions inside the game slice. For now, we'll make two: increment and auto increment.
// gameSlice.ts
const gameSlice = createSlice({
name: "game",
initialState,
reducers: {
// auto increment currency
autoIncrement: (state, action) => {
const newCurrency = parseFloat((state.currency + state.currencyPerMs * action.payload).toFixed(2))
state.currency = newCurrency
},
// increment currency by click
increment: (state) => {
const newCurrency = parseFloat((state.currency + state.clickPower).toFixed(2))
state.currency = newCurrency
},
}
})
// export actions
export const {
increment,
autoIncrement
} = gameSlice.actions
The actions use the same logic as the useState Clicker, except the autoIncrement action receives a payload, specifically the delta_time information that was passed to updateGame. The increment action simply adds the state's clickPower to the state's currency, so it doesn't need to receive any data through the action.payload.
Selecting and Dispatching
We now need to retrieve the state data and update it. Since we exported the useAppSelector and useAppDispatch hooks (the slightly modified useSelector and useDispatch hooks that redux provides) from store, and the incremenent and autoIncrement actions from gameSlice, we import them to be used in our main game file.
We'll use the same useEffect and setInterval structure as before for the game loop.
// Game.tsx
import { useAppDispatch, useAppSelector } from '../../State/stateHooks'
import { increment, click, upgrade } from '../../State/gameSlice'
const GameLoop = () => {
// initialize useAppDispatch and useAppSelector
const currency = useAppSelector((state) => state.game.currency)
const dispatch = useAppDispatch()
// redux user click handler
const handleClick = () => {
dispatch(click())
}
// redux game loop
useEffect(() => {
let last_time: null | number = null
const loop = setInterval(() => {
const time_now = performance.now()
if (last_time === null) {
last_time = time_now
}
const delta_time = time_now - last_time
setTotalTime(prevTotalTime => prevTotalTime + delta_time)
last_time = time_now
dispatch(increment(delta_time))
}, 1000 / 60) // fps
return () => clearInterval(loop)
}, [])
return (
<div
className="game__container"
onClick={() => handleClick()}
>
<h3>Currency: {currency}</h3>
</div>
)
}
Going Farther
Of course, this is barely a skeleton of an actual clicker game. We'll need to add upgrades to the autoClicker, to the user's clickPower, some sort of mechanism to save the user's progress, styling, and other optional features.
Top comments (0)