Puzzle Game
A game where you push blocks around and use the power of mathematics to interact with the world around you and solve puzzles.
Play demo here: https://drive.google.com/file/d/114WXCk3p4rPQgUeSWNGvWN9G4C0QcTwZ/view?usp=sharing
I encourage you to play before reading on.
This is a living document that will be added to as development continues and I decide something is interesting enough to write about.
Basic Gameplay
The video above demonstrates the basic mechanics of the game. You move characters around on a 3D grid, pushing blocks around to get all the characters to the exit, which are the yellow tiles you see on the right of the level. The purple block is a Register that holds values and can evaluate expressions. The red tile blocking the exit is a Gate which is tied to the register and requires a specific value (2, in this case) before it will lower itself, clearing the path to the exit. In this level, the player pushes a 1, a +, and another 1 into the register to make 2 which opens the gate.
Engine
The game runs on a custom engine called Workbench written in the Odin programming language with the following major features:
- Physically-based forward renderer using DirectX 11
- Cascaded shadow maps
- Skeletal animation (currently implemented in an old OpenGL backend, still needs to be ported to DirectX)
- Data-driven material system
- Entity system and scene editor
- Extensible asset pipeline
I heard a quote from Casey Muratori a few years ago that went something like this:
If you’re a game developer who only knows Unity and Unity suddenly goes under or disappears, you now don’t know how to make games at all.
This terrified me. The fact that I was resting my career on a specific tool seemed truly absurd, so I started looking into how game engines worked under the hood. While it is a lot more work it is also immensely rewarding and the times where it’s rough aren’t nearly as frustrating. I don’t ever plan on going back to something like Unity or Unreal for any personal project, the amount of control you get over your application with a custom engine is too much to give up.
Level Editor
The developer version of the game has a level editor built into it. This allows for rapid creation of levels and testing puzzle ideas. The video above demonstrates the level editor, making one of the tutorial levels in the game. You can place and rotate entities, change their properties in the inspector, and then save the scene in order to test.
Renderer
The renderer is a physically-based forward renderer built on top of DirectX 11 featuring a data-driven material system, cascaded shadow maps, and tonemapping, with bloom and skeletal animation implemented in an old OpenGL backend still needing to be ported over. The following is an example of a shader definition and a material definition defined in a custom markup language called WBML (WorkBench Markup Language):
// lit.shader
{
vertex_shader "lit_vertex"
pixel_shader "lit_pixel"
properties [
{ name "base_color" type Vector4 }
{ name "metallicness" type Float }
{ name "roughness" type Float }
{ name "ambient" type Float }
{ name "do_normal_mapping" type Int }
]
textures [
{ name "shadow_map0" type Texture2D }
{ name "shadow_map1" type Texture2D }
{ name "shadow_map2" type Texture2D }
{ name "shadow_map3" type Texture2D }
{ name "albedo_map" type Texture2D }
{ name "normal_map" type Texture2D }
{ name "skybox_map" type Cubemap }
]
}
For a shader definition you say which text files the actual source code will come from (lit_vertex
and lit_pixel
in this case, which are just .hlsl
files), as well as any properties and textures the shader takes as inputs. The engine uses this information to generate the actual shader text that will be compiled and sent to the GPU, including generating constant buffer structs and texture slots.
// ground_mtl.material
{
shader "lit"
properties [
{ name "base_color" value .Vector4 [ 1.0 1.0 1.0 1.0 ] }
{ name "metallicness" value .f32 0.0 }
{ name "roughness" value .f32 1 }
{ name "ambient" value .f32 0.1 }
]
textures [
{ name "albedo_map" asset "ground_tex" }
]
}
A material definition says which shader it is to be used with, and then provides values for any properties/textures that shader uses. The engine will then write the data to a byte array and ship it to the GPU as a constant buffer. In addition to providing values in the material asset on disk, you can also change any values at runtime.
Entity System
I did way too much rat-holing on entity systems before figuring out a structure that I like and is simple. I decided there were 4 main things I needed from the system:
- It needs to be easy to add new entities.
- It needs to be easy to iterate over all entities.
- It needs to be easy to iterate over all entities of a specific type.
- The entities need to be easily serializable.
#1. It needs to be easy to add new entities.
Adding a new entity is trivial; you simply define a normal struct and add an @entity
tag above the struct and have a base pointer as the first field, like so:
@entity
My_Cool_Entity :: struct {
using base: ^Entity, // the `using` just makes it so I can do `my_cool_entity.position` rather than `my_cool_entity.base.position`
some: f32,
extra: string,
fields: int,
}
The game has a pre-build step that scans all the source code of the game and looks for all structs with an @entity
tag. The builder then generates a tagged union with all the entity types it found, as well as some storage which I will discuss below. Here is the tagged union with all the entity types in the game:
Entity_Kind :: union {
Empty_Entity,
Point_Light,
Directional_Light,
Camera_Settings,
Level_Portal,
Register_Tile,
Operator,
Parameter,
End_Point,
Gate,
Ground,
Block,
Directional_Ground,
Multiplexer,
Player,
Pressure_Plate,
World_Text,
Puzzle_Region,
}
The actual Entity
struct has some common fields like position
and orientation
as well as this tagged union. This tagged union means it is easy to ask an Entity
what kind of entity it is, and since the different kinds of entities have a base pointer it is easy to go back the other way from derived to base. This union method means all entities have the same size so I can have all entities live in a single array, which leads me to the next goal of the entity system.
#2. It needs to be easy to iterate over all entities.
Iterating over entities is very easy because all entities are stored in a single array of a large fixed size. The reason it is a fixed size is because I don’t want any resizing to invalidate pointers in gameplay code. The fixed size might feel limiting but in reality you will run into performance problems updating and rendering all the entities before you run into memory problems so I just have a huge array and don’t worry about it. To be totally bulletproof I have considered using a virtual arena that reserves a terabyte of virtual memory or something ridiculous and then only actually commits something like a megabyte at a time, this would keep pointers valid while allowing for “resizing”. Maybe some time in the future I will do that.
Since all the entities are stored in a single array iterating over them all is very easy though a custom iterator is needed to skip over empty slots and disabled entities.
iterator := entity_iterator(&my_scene);
for entity in get_next_entity(&iterator) {
// do something with the entity
}
#3. It needs to be easy to iterate over all entities of a specific type.
To iterate over all entities of a specific type I decided to do some compile-time code generation to create storage in the Scene
for an array of pointers to each entity type. The following structure gets generated:
Entities_By_Type :: struct {
Empty_Entity: [dynamic]^Empty_Entity,
Point_Light: [dynamic]^Point_Light,
Directional_Light: [dynamic]^Directional_Light,
Camera_Settings: [dynamic]^Camera_Settings,
Level_Portal: [dynamic]^Level_Portal,
Register_Tile: [dynamic]^Register_Tile,
Operator: [dynamic]^Operator,
Parameter: [dynamic]^Parameter,
End_Point: [dynamic]^End_Point,
Gate: [dynamic]^Gate,
Ground: [dynamic]^Ground,
Block: [dynamic]^Block,
Directional_Ground: [dynamic]^Directional_Ground,
Multiplexer: [dynamic]^Multiplexer,
Player: [dynamic]^Player,
Pressure_Plate: [dynamic]^Pressure_Plate,
World_Text: [dynamic]^World_Text,
Puzzle_Region: [dynamic]^Puzzle_Region,
}
Just a bunch of dynamic arrays of pointers. Then every frame I clear these arrays, iterate over all entities in the scene, check their type and repopulate them. This means that if you wanted to iterate over all Pressure_Plate
s for example, you would do the following:
for plate in my_scene.by_type.Pressure_Plate {
// do something with the plate
}
#4. The entities need to be easily serializable.
Since an entity is just a struct I can leverage the runtime type information facilities in Odin to serialize and deserialize entities using WBML.
@entity
My_Cool_Entity :: struct {
using base: ^Entity,
some: f32,
extra: string,
fields: int,
wont_be_serialized: ^Thing "wbml_noserialize", // adding a `wbml_noserialize` tag will stop this field from being written to disk when the scene is saved
some_renamed_field_456: bool "wbml_oldname=some_renamed_field_123", // adding a `wbml_oldname` tag allows you to rename serialized fields and have the data be pulled from the old name properly
}
When you save the scene, all entities are serialized to disk. Each scene has its own folder and each entity has its own .e
file inside that folder which is just a format containing two WBML objects: a header; and the actual entity data. Each entity having its own file in an easy-to-read text format makes it dead-easy to resolve merge conflicts if two people edit the same scene (since each entity has its own file, a merge conflict would only even happen if you change the exact same entity). A binary format would be used in a final release, of course. Here is an example of a .e
file for a register entity:
{
entity_type "Register_Tile"
id 4294967360
name "Register_Tile"
}
{
id 4294967360
enabled true
name "Register_Tile"
version 11
position [
0.000
-1.000
-1.000
]
scale [
1.000
1.000
1.000
]
orientation quat 1.000 0.000 0.000 0.000
render_info {
model_offset [
0.000
0.000
0.000
]
model_scale [
1.000
1.000
1.000
]
model_id "cube_model"
color [
1.000
1.000
1.000
1.000
]
material_id "register_tile_mtl"
}
user_flags 3
user_data {
save_state_in_overworld false
reset_to_position [
0.000
-1.000
-1.000
]
}
derived .Register_Tile {
wire_offset [
0.000
1.000
0.000
]
feeders [
4294967308
4294967388
4294967389
4294967357
4294967358
4294967462
]
listeners [
8589934657
]
}
}
The format is just two WBML objects. The first being a header containing data for some pre-deserialization work that needs to be done, and the second is that entity’s state in the scene when it was saved. Each entity also has a version number, so if you change an entity structure drastically to require some custom version upgrade code, you can do that just after deserialization with something like this:
do_entity_upgrade :: proc(entity: ^Entity) {
if entity.version < 4 {
entity.version = 4;
// added a new field which is a Vector2, so combine the old x and y values
entity.some_new_field = Vector2{entity.deprecated_x, entity.deprecated_y};
}
if entity.version < 5 {
entity.version = 5;
// added a player_name field to the Player entity, so if this entity is a Player, give it a default name
#partial
switch kind in &entity.derived {
case Player: {
kind.player_name = "Default Player Name";
}
}
}
}