← Home

Broadview 2.0: Why, and what's next?

2 August, 2024

This post is mainly about my personal project, Broadview County, which I'm currently in the rewriting. I go into technical details about why it was hard to maintain, and what I'm doing to make it better.

What is Broadview?

If you aren't already aware, Broadview is an indie game project I've spent the majority of my free time creating over the last few years. It's a strict roleplay game with progression elements. Unlike typical roleplay games which are ephemeral in nature (jump in, pick what you want to do, leave), Broadview has a persistent model: You accrue money, cars, and other resources, can get a job in our player-run government, and much more.

Upon our initial release in January of 2023, we fostered a community of over 8,000 Roblox members and 2,000 Discord members. The game was doing really well, and in about 8 months, we acquired about 160,000 visits (a lot for suh a niche concept!). Our most notable metric was session time: Roblox analytics don't let me go back too far, and I'm confident the number was higher closer to inception, but we frequently surpassed Roblox's benchmark for session time and returning users: Players spent a lot of time in our game!

Session time of August 2023

Session time of August 2023

Difficult iteration

Unfortunately, the game became a bit hard to iterate on. Rojo aside, the game was built around two pieces of technology: Flamework, and Roact! Flamework is a framework for roblox-ts that lets you use object oriented programming to associate behavior and data with instances ("Components"), and use "Singleton" classes to represent core systems on the server and client.

While this is pretty neat—for us, the combination of game state and behavior it made it tough for a large game with so many moving parts to effectively communicate and work with one another. We also lacked a single source of truth: sometimes it was attributes in the DataModel wired up to Components, sometimes it was variables in a singleton, and sometimes it was Rodux, which we lightly (and arguably, improperly) used to communicate with UI.

Replication was a doozy as well! Due to the lack of centralized state, services were directly responsible for replicating their state to clients. This lead to having nearly 30 different remote events and functions, just to tell the client what's going on in the game:

// Psuedo-code!
Service()
class CarShopService {
	constructor() {
		functions.requestCarShopData.setCallback(() => {
			return this.state
		})
	}
	private update() {
		events.syncCarShopData.broadcast(this.state)
	}
}

Rodux, and newer alternatives like Reflex offer a solution to this, which is one I did end up using in a demo I briefly worked on last summer as well as an example game I made to represent React and Rodux, but I don't consider that completely ideal either. You do get benefits like having your state all in one place, the ability to use libraries like react-rodux and react-reflex to easily keep your UI in sync, as well as "actions" that can be easily repeated or reversed to compose an effect.

Unfortunately, there's downsides: updating state in containers like this is slow, especially if your changes are deep: it has to be cloned every time. Further, if you want your game to react to state changes in gameplay code, you practically need to spam "observers" and "subscriptions" that sometimes use complicated selectors to track them. We also lacked any form of debugging tool for these libraries, which I did end up making myself, but it wasn't enough.

// Creating a side effect for changes in state
// We could just spawn and destroy the zombie directly, but is Reflex really our source of truth then?
store.observe(
	state => state.zombies,
	(zombieEntity, index) => zombieEntity.id,
	(zombieEntity, index) => {
		if (zombieEntity.task.type === ZombieTaskType.Spawning) {
			this.spawnZombie(zombieEntity.id, zombieEntity.task.position)
		}

		return () => {
			this.despawnZombie(zombieEntity.id)
		}
	}
)

We also struggled with inheritance. We used Flamework's Component model to represent instances, their data, and behavior. A graph of the "item" components in our game looked a little like this:

Seems fine on the surface, right? It was, until our players wanted a flashlight that was also a melee weapon. Or, a gun with a light attachment (ok, that might be more simple). How do we combine the behavior of these two distinct types of items without repeating all of our complex logic?

Some reach for Mixins. I think they lead to complex and confusing class hierarchies, and it makes it difficult to track and debug where behaviors come from. They also tightly couple themselves to their implementations, which make changes higher up the tree difficult. They're just not flexible enough for me.

So, what then?

We've decided to structure our game around ECS! There are a few notable libraries available for Roblox: Matter, the most complete and popular option, and ECR, a relatively new one with a focus on performance. We've gone with Matter for Broadview.

On the surface, ECS is very simple: It stands for Entities, Components, and Systems. Entities are typically just a number. Components are pieces of data (in Matter's case, tables). Systems are functions that run in a fixed order, manipulate the World (the collection of your entities and components), and return nothing.

Matter's documentation provides a pretty good summary of why you should use ECS, but in summary:

A super simple example of why this is great, is our oxygen system. Another feature that players requested is the ability for cars and items, like guns, to become damaged when underwater, the same as characters do. In theory, this isn't complicated, but it leads to repeated code in multiple places of our game.

With ECS, it's as simple as this:

function underwaterDamage(world: World) {
	for (const [entity, oxygen, health, transform] of world.query(Oxygen, Health, Transform)) {
		const now = os.clock()

		const underwater = isUnderwater(transform.cframe)
		if (underwater) {
			// decrease their oxygen
			world.insert(entity, oxygen.patch({ value: math.max(0, oxygen.value - 10) }))
		} else {
			// ... increase ?
		}

		// damage them!
		if (oxygen.value === 0 && now - oxygen.lastDamage > 1) {
			world.insert(
				entity,
				oxygen.patch({ lastDamage: now }),
				health.patch({ value: math.max(0, health.value - 10 ) })
			)
		}
	}
}

That is practically our system for handling oxygen in Broadview 2.0. All we have to do is make sure our characters, items, cars, or anything else we want are given the Oxygen and Health components, and we're off to the races!

Replication

Our replication problem is easily solved with ECS! Because all of our state is already in the World, all we need to do is send it over. The simplest example of replication is provided in Matter's documentation, but at it's core, is simple: Query over the changed components, and send their data to the client. The client receives them, and inserts the data into the world. Done!

We've taken this a step further in 2.0 by doing delta compression on changed components, and serializing the final payload into buffers for minimum bandwidth. Helpful for a large world with lots and lots of stuff going on!

UI

When I started the rewrite, we reconciled the parts of our World relevant to our UI into a Reflex producer. This became cumbersome and sometimes error-prone. I decided to make some React hooks that allow me to query over the world and re-render components as needed. This isn't open source yet, but here's an example of the world::get hook in action:

export function CharacterPreview(props: Props) {
	const world = useWorld()
	const [character, inventory, health] = useGet(props.entity, Character, Inventory, Health)

	const cash = useMemo(() => {
		for (const item of inventory) {
			if (item.category === ItemCategory.Wallet) return item.cash
		}

		return 0
	}, [inventory])

	// Render the component ... we have all the data we need!
	return <frame/>
}

Debugging

Matter comes with a pretty slick debugger that's useful for seeing what's going on in your World and the timing of your systems. More details can be found in their documentation, but here's a screenshot of what it looks like in 2.0 so far!

Matter's debugger with a Character component selected

Matter's debugger with a Character component selected

Conclusion

The transition to 2.0 has been a very challenging yet rewarding journey, and it'll likely take me several more months to complete the rewrite. I'm super excited, because it's allowed us to make a more scalable, maintainable, and flexible game. If you're a developer, I hope you take a stab at ECS and implement it in your next game! Here's a few notable talks/videos about it, for further research.