loading...
Cover image for Making a 2D RPG game with react-three-fiber

Making a 2D RPG game with react-three-fiber

flagrede profile image Florent Lagrede ・11 min read

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.

Colmen's Quest

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

R3F 2D game demo

Demo link

Let's start by analyzing the elements and features that we have in this demo.
We have:

  • 🗺 A map
  • 🚶‍♂️ 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:

GitHub logo 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

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

Folder structure

  • @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

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>
Enter fullscreen mode Exit fullscreen mode

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 from react-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} />
    </>
Enter fullscreen mode Exit fullscreen mode

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 #
# # # # # # # # # # # # # # # # #
Enter fullscreen mode Exit fullscreen mode

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:

entities

  • # : 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

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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

And for the material we're just applying the Threejs basic material:

<meshBasicMaterial attach="material" {...materialProps}>
    <texture ref={textureRef} attach="map" {...textureProps} />
</meshBasicMaterial>
Enter fullscreen mode Exit fullscreen mode

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 the Collider 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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Workstation interaction

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.

zombie plant

Solution

The scene changer

        <GameObject x={16} y={5}>
            <Collider />
            <Interactable />
            <ScenePortal name="exit" enterDirection={[-1, 0]} target="other/start" />
        </GameObject>
Enter fullscreen mode Exit fullscreen mode

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

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:

portal workflow

The Player

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>
Enter fullscreen mode Exit fullscreen mode

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:

move schema

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);
        }
    });
Enter fullscreen mode Exit fullscreen mode

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 the useFrame hook from react-three-fiber. This hook is really useful as it allows us to perform update on each frame
  • 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

astar

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

zombie plant 2

Solution

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.

Discussion

pic
Editor guide
Collapse
daviddalbusco profile image
David Dal Busco

Très très cool!

Thx for the share 👍

Collapse
seanmclem profile image
Seanmclem

This is amazing. Do you think a lot of the 2D scripts for things like colliders and movement would adapt well to 3D?

Collapse
flagrede profile image
Florent Lagrede Author

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.

Collapse
zacharypeterson profile image
Zachary Peterson

Awesome article. Thank you so much!

Collapse
dskiba profile image
Denis Skiba

Usefull topic!
Thx you!