In my free time I decided to build a roguelike in React. Why? Because I thought it might be fun and I wanted to have a small game present on my website. I probably could've used WebGL or WebAssembly but where's the fun in that? Let's use the wrong tools to build something cool!
Preface: The intent of this series of posts is to document my thoughts, not to build a production-ready application. I have absolutely no clue about what I'm doing so the code will reflect this
Implementing an Entity Component System
I don't have any precise idea of what I want to do so let's start with the how. One of the latest patterns used in game development (especially for roguelikes) is the Entity Component System pattern.
Let's break down what's going on. We have three main actors:
- Entities: Entities are as simple as IDs. They don't hold any data and they just represent something in our game world
- Components: Components are akin to "Tags" that we can attach to Entities. They can hold data or be completely empty (you can think about them as class attributes in some way)
- Systems: Systems describe the functional logic of our application. They work on specific Entities+Components and do something with them
The main idea is to have Systems running in a loop targeting Entities that have specific Components. In our example above we can see that there's a RendererSystem that acts on every Entity that has a RenderComponent, a MoveSystem that acts on every Entity that has a MoveComponent and finally a FollowComponent that works with Entities with a FollowComponent. If you look closely you can see that some Components hold data (for example the Render one has a position), while others do not.
A basic pseudocode implementation for the RenderSystem above could look like this:
define RenderSystem:
entities = globalState.getAllEntitiesWithComponent(RenderComponent)
for each entity in entities:
render(entity.render.symbol).atCoordinates(entity.render.position.x, entity.render.position.y)
The biggest advantage of this method is that we're using composition over inheritance making our components as loosely coupled as possible. We can have independent Components and add functionality to Entities as needed, even at runtime.
ECS in React
Instead of trying to reinvent the wheel I tried searching for a React ECS library and found [this one]. Its documentation is kinda sparse but after playing a bit with it I managed to get it working.
First of all you have an ECS.Provider
which holds all the data pertaining to the application. Inside you can define Entities
and Systems
. An Entity
is then a collection of Components
(which are called Facets
to avoid confusion, so from now on I will be calling them like this as well)
Let's try and render something on our screen. The architecture is the one we had above, so we need to define a PlayerComponent
and a RenderSystem
. Instead of a RenderFacet
I will create a PositionFacet
since everything that has a position will need to be rendered anyways.
import { Facet } from '@react-ecs/core'
export default class Position extends Facet<Position> {
location? = { x: 0, y: 0 }
}
The code is simple. It's just a class that extends Facet
and has a location attribute. Now to the PlayerEntity
export default function Player() {
return (
<>
<Position />
<DOMView>
<img
style={{
position: 'absolute',
left: 0,
top: 0,
width: 100,
height: 100
}}
src="https://i.imgur.com/kFjaH5l.png"
/>
</DOMView>
</>
)
}
This one is sligly more complex and there a couple of things to explain. First of all the DOMView
component which is a helper wrapper provided by the library that allows its children to be viewed on the DOM. Then we have the React fragment (<>
). The library requires each Entity
to be wrapped with an <Entity>
tag but by moving it to a separate file I started having hook issues. I still didn't manage to find a solution so for now this separation will have to suffice.
Finally we have the ViewSystem
export default function ViewSystem({}) {
const query = useQuery((e) => e.hasAll(DOMView, Position)) // [1]
return useSystem((dt) => { // [2]
query.loop([DOMView, Position], (e, [view, pos]) => { // [3]
view.element.style.left = `${pos.location.x * 100}px`
view.element.style.top = `${pos.location.y * 100}px`
})
})
}
What's going on here?
- In line [1] we make a query to the ECS Provider to find all the
Elements
that have both aDOMView
and aPosition
Facet (In our case this is just the player). - In line [2] we return a useSystem hook that registers our function in the ECS Provider. It takes a callback as input and which gets called every time the ECS updates its state
- Finally we declare what we want to happen on each frame. In this case we want to loop on every Entity in our query (`.loop() is a helper method). We can get both Facets associated with the entity we're looping on and in this case we simply assign it's left and right css values to our position data.
Finally we need to put this everything together. I created an ecs.tsx
file that will hold our game logic.
export default function Canvas() {
const ECS = useECS()
useAnimationFrame(ECS.update)
return (
<div>
<ECS.Provider>
<ViewSystem />
<Entity>
<Player />
</Entity>
</ECS.Provider>
</div>
)
}
So far it's a pretty simple function. We instantiate an ECS hook by using useECS()
and then we tell him to update the systems on each frame. In the return we have the ECS.Provider
and inside we can find our Systems and Entities. (Note how we need to put the <Entity>
tag around our Player
because of what was said before)
By running this we can see that the ECS is indeed working and rendering a simple Smiley face on the screen. Rejoice!
In our next installment we will look at how to read for player input and make our face move. Stay tuned!
© thevinter.RSS