I Stopped Fighting My Tools and Built a Game Engine in D

BradleyChatha1 pts1 comments

I Stopped Fighting My Tools and Built a Game Engine in D | The D Blog

Building games should be fun. At some point, it stopped feeling that way for me.

My primary workflow used to revolve around the Godot Engine and its scripting language. It was a great fit for my needs (2D games with a retro feel), but there was always a little bit of friction. Some of it was me wanting something different, and the rest was the engine shifting toward a more opinionated, editor-driven design. I always preferred a code-driven approach for my projects. One example of this can be found in my unfinished GDScript library, Sashimi.

Eventually, that friction grew with new Godot releases, leading me to where I am now: developing my own game engine in D called Parin.

Of course, Parin was not my first attempt at game development outside of Godot. My initial goal was to see if I could create a workflow as nice as the one I was used to. That led me on a long detour through languages like Nim, Go, Zig, C, and D. After all that searching, I realized D was exactly what I needed. It’s a pragmatic and unopinionated language that gets out of my way.

In this blog, I’ll go over some features of D and how I use them to make games. The TL;DR is:

A single language for game logic and scripting.

Fast compile times under 1 second.

The freedom to choose the best memory allocation strategy.

Achieving C-like speed with a much cleaner developer experience.

Game Made with Parin: Worms Within

Memory Management

D’s unopinionated approach is most evident in the control it gives me over memory. In Parin, I’ve structured the code so that it avoids the garbage collector by default. It instead relies primarily on static data structures and an arena allocator that is cleared at the end of every frame.

Arena Allocators

The engine implements two types of arenas:

Arena: A fixed-size buffer. It’s perfect for temporary memory where the upper bound is known.

GrowingArena: A linked list of Arena chunks. This provides a “pay-as-you-go” strategy.

struct Arena {<br>ubyte* data;<br>size_t capacity;<br>size_t offset;<br>// ... metadata for checkpoints.<br>Arena* next;

struct GrowingArena {<br>Arena* head;<br>Arena* current;<br>size_t chunkCapacity;

From the two, GrowingArena is the type of the arena mentioned earlier. To make these types more ergonomic, a RAII helper is used sometimes called ScopedArena. It uses the destructor to automatically rollback the arena offset when a scope ends. Combined with D’s with statement, it creates an elegant way to work with arenas:

import parin;

void main() {<br>ubyte[1024] buffer = void;<br>auto arena = Arena(buffer);

with (ScopedArena(arena)) {<br>make!char('C'); // The `make` method of `ScopedArena` advances the offset.<br>with (ScopedArena(arena)) {<br>make!short(3);<br>make!char('D');<br>assert(arena.offset == 5);<br>// The offset is back to where it was before the nested block.<br>assert(arena.offset == 1);<br>// The offset is back to the start.<br>assert(arena.offset == 0);

Static Data Structures

Similar to Arena and GrowingArena, many data structures take a compile-time argument to toggle between static or dynamic allocation. The engine prefers the static versions because they avoid runtime allocations and allow for easy bundling of different data into a single block of memory. Below is a simplified example of how this works:

// A list with a dynamic capacity.<br>struct List(T) {<br>T[] items;<br>size_t capacity;

// A list with a fixed capacity.<br>struct FixedList(T, size_t N) {<br>T[N] data;<br>size_t length;

T[] items() {<br>return data[0 .. length];

enum capacity = N;

// A 2D grid. Type `D` defines its behavior.<br>struct Grid(T, D = List!T) {<br>D tiles;<br>int rowCount;<br>int colCount;

void fill(T value) {<br>foreach (ref tile; tiles.items) {<br>tile = value;

// A dynamic grid type.<br>alias Rooms = Grid!short;<br>// A static grid type.<br>alias Map = Grid!(short, FixedList!(short, 128 * 128));

In the example above both List and FixedList share a common public interface (items and capacity). While their underlying types differ, with items being a variable for the first and a property for the other, they remain functionally compatible. Consequently, generic functions that work with Grid types will use either without issue.

Below is a more complicated example of this from Parin: a generational array (handle map) type:

struct GenList(T, D = SparseList!T, G = List!Gen) if (isGenContainerPartsValid!(T, D, G)) {<br>D data;<br>G generations;

bool isGenContainerPartsValid(T, D, G)() {<br>static if (__traits(hasMember, D, "isBasicContainer")) {<br>static if (isSparseContainerPartsValid!(T, D.Data)) {<br>static if (__traits(hasMember, G, "isBasicContainer")) {<br>// NOTE: Can be written better, but I don't care.<br>return G.isBasicContainer && G.hasFixedCapacity == D.hasFixedCapacity;<br>} else {<br>return false;<br>} else {<br>return false;<br>} else {<br>return false;

Dynamic Allocations

For the parts that require dynamic allocation, the engine provides two paths. It sometimes accepts user-allocated memory, meaning a user can decide...

arena data engine static offset list

Related Articles