These two complementary paradigms, rather than object-orientation, could be the future of game development.
Note: This article is a follow up to the previous article, ‘Why Functional Programming Works for Games’.
When we tell the story of functional programming and data-oriented design in games, we tell a story of trade-offs.
What do you get with Functional Programming in games?
- A declarative programming interface, such as Elm-style.
- Ease in reasoning about program behavior at a high level.
- The complete freedom of an open system.
- A great debugging experience.
And what do you pay to get it?
- Higher performance costs depending on how declarative you want to be.
Conversely, what do you get with Data-Oriented Design?
- Unrivaled speed compared to high-level programming paradigms.
- Ease in reasoning about program performance at a low level.
And what do you pay to get it?
- Be prohibited from using abstractive language features and techniques.
- Difficulty in reasoning about program behavior at a high level.
- A requirement to work in an exclusively closed system.
By looking at these trade-offs, we see that functional programming and data-orientated programming are quite orthogonal. But for games, that’s actually a great thing because orthogonal means complementary!
So how do we know which approach to use when writing games?
It’s all about knowing in what way your game needs to scale. For an example, let’s look at game Entities in the Nu Game Engine –
In Nu, you have 3 tiers of Entity configurations out-of-the-box –
- Optimized (Omnipresent = true)
- Cullable (Omnipresent = false)
- Elm-style (use the EntityDispatcher<_, _, _> type)
Depending on the tier of the Entity, different numbers of them can be used before soaking the CPU –
- Optimized => 15,000* On-Screen
- Cullable => 10,000* On-Screen
- Elm-Style => 6,000* On-Screen (5,000* when Omnipresent = false)
*These numbers are discovered by running Nu’s Metrics project in its various modes.
As you can see, you can get a lot of scalability if you’re willing to dial back some declarativity. And since most types of Entities won’t need scalability beyond the hundreds, you can stick with Elm-style Entities by default. If you need a bunch of bullets flying around the screen, you can use the default Cullable configuration. If you’re going for bullets on the scale of a bullet-hell shooter, you can opt for the Optimized configuration.
Another important item to note is that the Optimized profile is 75% bounded on SDL’s rendering, so the bottleneck is actually not in the Nu code –
This means that once I finally get time write the custom batched, multithreaded OpenGL renderer, we’ll be able to see closer to 40,000 Optimized entities on screen at 60FPS.
What about bleeding edge games where you have certain game elements on screen that number in the multiple 100,000’s? Well, that’s where you have to eschew Nu’s normal path of programming and embrace the data-oriented style – https://github.com/bryanedds/Nu/blob/master/Nu/Nu/Ecs.fs. Even if you were using an object-oriented system, you would still need to duck out to an array-based approach like an ECS to get to the next level of scalability.
4. Data-Oriented / ECS => 100,000+
In my view, game engines of the future will use a mixture of functional programming and data-oriented design. Object-orientation will take a back seat to these emerging paradigms, it having only succeeded in giving us the worst of both worlds — insufficient abstraction and inefficient performance.
By using both functional programming and data-oriented design in games, we will aim to get the best of both worlds!
5. Compute Shaders => 1,000,000+
And I think I should mention the next level of scalability beyond ECS – compute shaders!
Check out what people are doing with GPU-only simulation programming here –
Of course, Nu doesn’t provide anything specific to enable this, but it should be pretty straight-forward to expose support for this once we implement OpenGL rendering.
So to summarize, entity scaling goes approximately as follows –
Elmish => 1000's Classic Nu => 10000's (same for OOP API's like classic Unity) ECS => 100000's Compute Shaders => 1000000's
As you can see, the trade-offs are magnitudinal as you step from declarative to imperative. Nu supports all of these gradients now except for compute shaders, and those are on the horizon as well.