In this article we're going to take a closer look at an open source demo published by @coldi. Coldi made a game, called Colmen's Quest (that you should definitely check out), using react and react-three-fiber. He was kind enough to share the core engine that he made for his game to the community.
It might sound odd to use a 3D library like ThreeJS to make a 2D game but it is actually not that uncommon at all. For example Unity, the popular 3D game engine, is also used a lot for 2D games like Hollow Knight.
Speaking about Unity, the game architecture that Coldi used is also inspired by Unity and resolve around the concept of GameObject components that we will talk about just after.
Adding react-three-fiber to the stack provides a terrific dev experience to make a webgl game with React.
This project is a really valuable learning material. By exploring it in this article we will learn a lot about game dev techniques, react-three-fiber and also React knowledge in general. We will also try to apply our newly acquired knowledge by tweaking the demo a bit. Let's dive in !
The game demo
Let's start by analyzing the elements and features that we have in this demo.
We have:
- ๐บ A map
- defined with tilesets
- ๐ถโโ๏ธ A character that can be moved with either a mouse or a keyboard
- the mouse movement is trickier as it needs to compute the path ahead
- ๐งฑ A collision system
- which prevents to walk into walls or objects
- ๐ An interaction system
- pizza can be picked up and it is possible to interact with computers and coffee machines
- ๐ฝ A scene system
- to move from one room to another
We can start by cloning the demo here:
coldi / r3f-game-demo
A demo on how to do a simple tile-based game with React and react-three-fiber
react-three-fiber Game Demo
This repo shows an example implementation of a top-down 2d game made with React and react-three-fiber.
I used the core functionality to create Colmen's Quest and wanted to give you an idea of how a game can be done with React.
This is by no means the best way to build a game, it's just my way. ๐
I suggest you use this code as an inspiration and not as a starting point to build your game on top of it. I also do not intend to maintain this code base in any way.
Get started
You can start the game by yarn && yarn start
, then open your Browser.
To get a better understanding of the architecture I used, you may want to read this thread on Twitter.
๐ Also Florent Lagrede (@flagrede) did an amazing job in writing anโฆ
Folders architecture
- @core: everything that is reusable and not specific to the current demo
- components: components that hold logics more specific to the current demo.
-
entities: describe elements in the game world (Pizza, Plant, Player...). All these elements are
GameObject
. We're going to explain more of this concept just below. -
scenes: represents the different rooms in the game. Scenes are an aggregation of
GameObject
. In the demo there are two scenes (Office and Other).
Game architecture
The component architecture looks like this:
<Game>
<AssetLoader urls={urls} placeholder="Loading assets ...">
<SceneManager defaultScene="office">
<Scene id="office">
<OfficeScene />
</Scene>
<Scene id="other">
<OtherScene />
</Scene>
</SceneManager>
</AssetLoader>
</Game>
We're going to explain each of them.
Architecture - top part
Game
This component has 4 main features:
- register all
GameObject
inside the game - a global state
- render the
Canvas
component fromreact-three-fiber
- pass a context to all its children with the global state and methods to find/register
GameObject
AssetLoader
This component will load all image and audio assets of the game with the Image
and Audio
web object. It also displays an html overlay on top of the canvas while the assets are loading.
SceneManager
This component holds the state regarding the Scene
currently being displayed. It also exposes a method setScene
through a Context
in order to update the current scene.
Scene
This component, besides displaying its children GameObject
, will dispatch the events scene-init
and scene-ready
whenever the current scene changes.
There is also a level system present in the file that is not being used by the demo.
Architecture - Bottom part
Now we are going to look a little deeper, inside the code of the OfficeScene
.
<>
<GameObject name="map">
<ambientLight />
<TileMap data={mapData} resolver={resolveMapTile} definesMapSize />
</GameObject>
<GameObject x={16} y={5}>
<Collider />
<Interactable />
<ScenePortal name="exit" enterDirection={[-1, 0]} target="other/start" />
</GameObject>
<Player x={6} y={3} />
</>
The GameObject
component we saw earlier is the most important piece of the architecture. It represents almost every element in the game world. For example for the OfficeScene
just above we have 3 GameObject
:
- A Map
- A Scene changer
- The Player
GameObject
holds state information like position
, enabled/disabled
or its layer
in the game (ie: ground, obstacle, item, character ...). They can contain other GameObject
as well.
GameObject
can also contain other components that Coldi called Scripts
. These scripts can hold the logic for interaction, collision or movement for example. Basically game objects are a composition of these reusable Scripts
and other GameObject
. This is a really powerful API because you can describe a game object behaviour component by just dropping components in it.
Game Objects
We're going to explore more the 3 GameObject
we saw earlier:
The map
This component will create the map of the Scene based on an entities mapping string. For example the Office mapping string looks like this:
# # # # # # # # # # # # # # # # #
# ยท W T # T ยท ยท W T ยท W ยท ยท ยท T #
# ยท ยท ยท ยท ยท ยท ยท ยท ยท ยท ยท ยท ยท ยท o ยท
# o ยท ยท # ยท ยท ยท # # # # ยท ยท # # #
# # # # # ยท ยท ยท # W o W ยท ยท T W #
# C C C # ยท ยท ยท T ยท ยท ยท ยท ยท ยท ยท #
# o ยท ยท ยท ยท ยท ยท ยท ยท ยท ยท ยท ยท ยท o #
# # # # # # # # # # # # # # # # #
Inside the OfficeScene
there is a function called resolveMapTile
which will map each character to a game entity. Entities are GameObject
that match a real element in the game world.
In this case we have the following entities mapping:
- # : wall
- . : floor
- W : workstation
- C : coffee machine
- T : plant
The child component TileMap
will then be responsible to return the map base on the entities mapping string and the resolveMapTile
functions.
The final map is a 2D grid, with each cell holding one or several GameObject
components.
Entities - workstation
Let's take a closer look at what an entity looks like. We're going to look at the Workstation
one.
export default function Workstation(props: GameObjectProps) {
return (
<GameObject {...props}>
<Sprite {...spriteData.objects} state="workstation-1" />
<Collider />
<Interactable />
<WorkstationScript />
</GameObject>
);
}
We can see the GameObject
component we were talking about and some child components(Sprite
, Collider
, Interactable
and WorkstationScript
) that define its behaviour.
Sprite
The Sprite component is responsible for displaying all graphical elements in the game.
We didn't talk much about react-three-fiber
until now, but it is in this component that most of visual rendering happens.
In ThreeJS elements are rendered through mesh
objects. A mesh is a combination of a geometry and material.
In our case for the geometry we're using a simple Plane of 1x1 dimension:
THREE.PlaneBufferGeometry(1, 1)
And for the material we're just applying the Threejs basic material:
<meshBasicMaterial attach="material" {...materialProps}>
<texture ref={textureRef} attach="map" {...textureProps} />
</meshBasicMaterial>
With a plain basic material however we would be just seeing a simple square. Our sprites are actually displayed by giving the <texture>
object, which will apply sprites to the <meshBasicMaterial>
.
To sum up, the visual render of this demo is mostly 2D plane with texture applied to them and a camera looking at all of them from the top.
The collider
This component is responsible for handling collisions. It has two jobs:
- store the walkable state (if it is possible to step on it or not) of the
GameObject
using it. By default theCollider
is initialized as non walkable. - listen and trigger events to do some logic whenever there is a collision.
The component also uses the hook useComponentRegistry
to register itself to its GameObject
. This allows other elements in the game (like the player) to know that this game object is an obstacle.
For now we just have added an obstacle on the map, let's continue with the next component.
Interactable
This component is responsible for handling logic when the player interacts with other elements in the game. An interaction occurs when the player has a collision with another GameObject
(this is why the Collider
from earlier was needed).
Interactable
has severals methods:
- interact: executed by the
GameObject
that initiates an interaction - onInteract: executed by the
GameObject
that receives an interaction - canInteract: is it possible to interact with it
The Interactable
component, as the Collider
, registers itself to its GameObject
.
The WorkstationScript
function WorkstationScript() {
const { getComponent } = useGameObject();
const workState = useRef(false);
useGameObjectEvent<InteractionEvent>('interaction', () => {
workState.current = !workState.current;
if (workState.current) {
getComponent<SpriteRef>('Sprite').setState('workstation-2');
} else {
getComponent<SpriteRef>('Sprite').setState('workstation-1');
}
return waitForMs(400);
});
return null;
}
At last we have a script, specific to this entity, to handle some logic.
We can see here that this script is listening to the interaction
event from earlier. Whenever this happens it just swaps the sprite of the computer.
Exercice
We're going to add a monster entity, disguised as a plant. Inside the object sprite sheet asset, we can see that there are two plants that are not used in the demo.
The goal will be to use them to create a new entity called ZombiePlant and place it inside the other Scene.
When interacting with the entity, the plant should swap from one sprite to the other one.
We will also have to change both the entities mapping string and the resolveMapTile
function inside the OtherScene
.
The scene changer
<GameObject x={16} y={5}>
<Collider />
<Interactable />
<ScenePortal name="exit" enterDirection={[-1, 0]} target="other/start" />
</GameObject>
Now let's look at the components that handle the scene change.
This component will be triggered when the player steps on it.
To create this effect, the scene changer has 3 child components:
- Collider
- Interactable
- ScenePortal
We are already familiar with some elements like Interactable
and Collider
. This shows us how reusable GameObject
can be with this architecture. Let's look at the ScenePortal.
Scene Portal
This component is responsible for doing the scene change when the player interacts with it.
It has the following props:
- name: name of the portal
-
target: destination where the player should be teleported (scene + portal). This parameter is a string with the following template:
sceneName/portalName
- enterDirection: direction that the player should face when entering the new scene;
The component listens to the interaction
event through the hook useInteraction
. When he receives an interaction, it will check if it comes from the player. In that case the port
function is called. It will change the current scene in the global game state. After that the component will wait for the SceneInitEvent
and SceneReadyEvent
to move the player in the right position and direction.
Workflow example
Let's try to visualize the whole workflow of the ScenePortal:
The Player
We're now going to explore the biggest GameObject
of the game, the Player
one.
The player GameObject
looks like this:
<GameObject name="player" displayName="Player" layer="character" {...props}>
<Moveable />
<Interactable />
<Collider />
<CharacterScript>
<Sprite {...spriteData.player} />
</CharacterScript>
<CameraFollowScript />
<PlayerScript />
</GameObject>
We are still familiar with Interactable
and Collider
.
Let's see what the new components are doing.
Moveable
This component just exposes an API, it does not listen to any events. It means that there will be another GameObject
that will call the Movable's API to move the GameObject
using it (in our case the Player).
The most important method is the move
one. It takes a targetPosition as parameter, checks if this position is a collision and if not move the GameObject
to it.
It also triggers a lot of events that can be used elsewhere. The events sequence look like that:
Also the method move
uses the animejs library to animate the player sprite from one position to another.
CharacterScript
useGameLoop(time => {
// apply wobbling animation
wobble();
// apply breathe animation
if (!movementActive.current) {
// breathe animation while standing still
const breathIntensity = 20;
scaleRef.current.scale.setY(1 + Math.sin(time / 240) / breathIntensity);
} else {
// no breathe animation while moving
scaleRef.current.scale.setY(1);
}
});
This component is responsible for doing some animation to the Player Sprite. The script handle:
- flipping the sprite in the current moving direction (use the
attempt-move
event we saw earlier) - apply a
wobble
effect while moving- this effect is applied inside the
useGameLoop
hook. Under the hood this hook uses theuseFrame
hook from react-three-fiber. This hook is really useful as it allows us to perform update on each frame
- this effect is applied inside the
- add a footstep sprite and sound while moving
- make the animation bounce while moving (use the
moving
event we saw earlier)
To sum up this component perform sprite animation by listening to movement events from the Moveable
component.
PlayerScript
Final piece of the Player
entity, the PlayerScript
.
This component handles the logic that the player can do. It will deal with both cursor and keyboard inputs.
Keyboard controls
There are 4 hooks useKeyPress
that add the listener to the key given in parameter. These hooks return a boolean whenever the listed keys are pressed. These booleans are then checked inside a useGameLoop
, that we saw previously, and compute the next position consequently. The new position is set in the local state of PlayerScript
.
Cursor controls
This part is a bit more tricky. While the keyboard controls could move the player one tile by one tile, the cursor can move it to several tiles. It means that the whole path to the selected position must be computed before moving.
In order to do that the method use a popular path finding algorithm named A star (or A*). This algorithm computes the shortest path between two points in a grid by taking collision into consideration.
As for the keyboard events the new position is updated into the local PlayerScript
state. In addition the path is also displayed visually in this case. In the render method there is PlayerPathOverlay
component which is responsible for doing just that.
Moving to the new position
In both cases we saw that the new position is updated in the local state of the component.
There is a useEffect that listens to that change and that will try to move the GameObject
. Remember the Moveable
component from before ? Here we get it and call its move
method on him. If the move is not possible, the method returns false
. In that case we will try to interact with the GameObject
that is in the position that the player couldn't go to.
Exercice
This was a big piece but now we should understand how game objects work together, let's try to make a new thing now.
Remember our ZombiePlant
entity? We're going to add some new features to it:
- When the player interacts with it: should bounce back from the player (like if the player was attacking it)
- Whenever the interaction occurs: should play a sound effect (we can reuse the eating for example)
- On the third interaction the zombie plant should disappear
Conclusion
This is it, we've gone through most of the demo !
I hope you learned a lot of things in this demo walkthrough (I did). Thanks again to @coldi for sharing this demo with the community.
Also as he said a lot of things could have been implemented differently. For example the collision system could have been done with a physic engine like react-use-cannon
.
This is still a terrific example of how to make games with react-three-fiber
.
Hopefully this gives you some ideas to make a game of your own !
If you're interested in front-end, react-three-fiber or gamedev, I will publish more content about these topics here.
Thanks for reading, happy coding.
Top comments (10)
Very clear! Such a great explanation โค
I'm currently building a game like this in r3f while taking some inspiration from this repo. Out of curiosity, have you seen any recent three js or r3f 2d games/implementations? It'd be cool to see more
Sure, you can have a look at some here: webgamedev.com/games-demos , look out for the ones with a react icon.
I personally released one in 2020: flyven.itch.io/panic-at-the-museum
This is awesome, thanks! Iโm somewhat experienced with r3f and shaders, but not so much with building games. Any resource recommendations that could help me figure out how to build out the game logic in three js? Iโve done Simonโs course
Trรจs trรจs cool!
Thx for the share ๐
I love it!
Awesome article. Thank you so much!
Usefull topic!
Thx you!
This is amazing. Do you think a lot of the 2D scripts for things like colliders and movement would adapt well to 3D?
The current scripts works well for a grid based game, either 2D or 3D since collision and movement are computed based on tiles. For a non grid game it would be best to use something like use-canon or plankjs depending what your game needs.