react-jam-winter-2023

React Jam Winter 2023

A few days ago I completed my first-ever game jam, React Jam Winter 2023. This was a 13 day challenge to build a game using React, and optionally Rune for multiplayer functionality. This was also my first time using Pixi React and my first time using the Rune SDK.

Overall, it went very smoothly. I'll share what worked nicely, some pitfalls I faced, and finish with some final thoughts. If you are thinking about building a game with React and Rune, it might help to keep some of these things in mind.

What went well

Keeping components in sync with game state was a breeze. This was thanks to the state management library I used and the way Rune works.

Zustand + Rune = easy and highly-performant updates to your react components

I recommend using Zustand for managing state client-side, and it works perfectly with Rune. The nice thing about Zustand is it's so easy to use. Your react components simply use a hook to select what data they need out of the store. What's more, your component will only re-render if the specific thing it selects changes (not when any other part of the store changes).

You can just chuck the entire game state object into your store, and let every component pull what they need out of it.

The body of my onChange callback within Rune.initClient is 1 line. It looks like:

Rune.initClient({
onChange: ({ game, yourPlayerId, players }) => {
useStore.setState({ game, yourPlayerId, players })
},
})

Let's say our GameState looks something like this:

game = {
players: {
player123: {
inventory: [],
location: {x: 0, y: 0}
},
player456: {
inventory: [],
location: {x: 1, y: 0}
}
},
...other stuff...
}

Now let's say we have a component that displays the player's inventory.

function Inventory() {
const playerId = useStore((state) => state.yourPlayerId)
const inventory = useStore((state) => state.players[playerId].inventory)

return (
<ul>
{inventory.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)
}

This component will re-render whenever this player's inventory changes. But it WILL NOT re-render when the player's location changes, even though the player object is changing. Or any other part of the state is changing. Only when the specific thing this component selects (inventory) changes, will it re-render.

This massively improves performance by preventing unnecessary re-renders, which is especially important if your game state updates many times per second.

A clear model for how state changes

GameState is the source of truth. Most everything in your game should be a reflection of the current state of the game object passed to your onChange callback. You don't make changes directly to objects, instead you dispatch Rune.actions.

If you have used Redux or anything resembling an event-based architecture, this should seem familiar. But if you haven't, this could take some adjustment.

This is actually really powerful and scalable. With this design, it's easier to understand all the ways players can influence the game because they are all defined in one place: in the actions property of your logic. Not strewn throughout your program like spaghetti code.

It's easier to avoid bugs when all of your UI is a declarative representation of the latest state of the game. All sorts of bugs in applications are caused by parts of the system getting out of sync, or clobbering each other in race conditions. Rune avoids this by making everything happen via actions and applying them in order.

Pitfalls

Now here are some things that tripped me up.

Invalid code in logic.js

Be careful about what you import into your logic.ts file (directly or indirectly). A dependency might cause your compiled logic.js file to include invalid code.

This happened to me because I was using an A* library (for pathfinding) in my update callback, and that library internally uses a lot of disallowed code like try/catch, window, global, RegExp, and so on.

The issue only reared its head when I tried to upload my game. It ran in dev and built just fine.

At the last minute I had to change my game to perform the A* calculation client-side, and pass the result in an action so that update could retrieve it from the game state.

This also means you cannot reference your Zustand store from within logic.ts (by doing useStore.getState()...) because Zustand itself will include invalid code. But that's ok because your logic.ts shouldn't be reaching into global state anyway.

Generally speaking I think you should only import into your logic.ts file stuff that you wrote yourself — simple objects and functions and so on. This also makes it easy to stay well under the 1mb limit for logic.js when you're not bloating it with imported libraries.

Side effects in your actions causing the dreaded "state desync detected"

Your actions should be pure functions, meaning they don't produce any side effects like mutating data outside of themselves. In other words, if the action ran multiple times with the same data and game state passed into it, it should have the same result every time. Keep your action pure by limiting mutations to only the game object passed into it and anything defined within the function.

I ran afoul of this rule when I tried to randomly pick values from a global array. I was accidentally shuffling the array, when I should have been shuffling a copy of the array (keeping the original array unchanged).

I saw other developers in Discord run into similar problems. For example, setting a value in game to the return value of a method of some class, but that method returns a different value each time it's called because it mutates some internal data.

I recommend staying away from classes in general. Classes are little bundles of mutable data and they just don't really fit into an immutable/pure-function paradigm.

Restarting the game in the Rune app doesn't reload the page

Tapping "restart" basically just calls your setup function again. Keep this in mind if you are doing anything else on page load that should be done again, or if you are storing any client-side state outside of your GameState.

I ran into this problem because I was storing a few pieces of global data outside of GameState, such as whether the user had a dialog box open. I didn't think these sort of UI state belonged in GameState because it doesn't really concern the game itself, and actions/update don't need to know about it.

The problem is restarting causes the GameState to reinitialize, but my UI state would become stale. There are a couple of ways to fix this:

  1. Just put everything in GameState.
  2. Detect when the game has been restarted and do whatever setup you need to do.

#1 means every UI interaction that needs to modify global state has to become a Rune.action which might be too cumbersome.

I did not find a great way to do #2. I tried generating a random id in setup and then in my onChange doing:

if (game.gameId !== previousGame?.gameId) {
useStore.getState().reset()
}

But that didn't work, when you restart previousGame will not refer to the last state before the restart. Although gameId changed between games, game.gameId always equaled previousGame?.gameId.

So I had to store the gameId in Zustand.

if (game.gameId !== useStore.getState().gameId) {
useStore.getState().reset(game.gameId)
}

And my store's reset function:

reset: (gameId) =>
set({ gameId, dialogOpen: false, screen: "characterSelect", ...other defaults... }),

I don't love this solution, but it works.

For this reason, I would strongly suggest you put everything that would need to reset into GameState, if you can do so. Refactor to keep state local when and where it makes sense. Only store stuff globally outside of GameState that would not need to change when the game restarts. As a last resort, use the reset method above.

Final thoughts

Rune does a lot of heavy lifting for you when it comes to networked multiplayer. And it exposes a small and easy to use API. So should you start using it to make games?

There are a few restrictions:

  1. Games are limited to up to 4 players (at the time of this writing)
  2. You cannot persist data across sessions (at the time of this writing)
  3. You can't make network calls
  4. There are size limitations for your logic file and game overall

Obviously this is not a suitable platform for all types of games (and they are not trying to be). But if the game you want to make fits within the above guidelines, then I think building it on Rune is an excellent choice.

I won't be porting AstroClicker over to Rune because it's intended to be played over a long time, which isn't a match for the sort of short, multiplayer sessions Rune excels at. But I think I will try making some more cute little games and deploying them on Rune.

© Justin Allen. All rights reserved.