You're trying to satisfy competing goals. You can't have both, a rigid static model of blocks and a dynamic extensible block type. So you need to choose. Fortunately, OCaml provides solutions for both, and even for something between, but as always for the middle-ground solutions, they kind of bad in both. So let's try.
Rigid static hierarchy using ADT
We can use sum types to represent a static hierarchy of objects. In this case, it would be easy for us to add new methods, but hard to add new types of objects. As the base type, we will use a polymorphic record, that is parametrized with a concrete block type (the concrete block type could be polymorphic itself, and this will allow us to build the third layer of hierarchy and so on).
type pos = {x : int; y : int}
type 'a block = {pos : pos; info = 'a}
type block_info = Container of container | Input of facing | Air | Solid
where info
is an additional concrete block specific payload, i.e., a value of type block_info
. This solution allows us to write polymorphic functions that accept different blocks, e.g.,
let distance b1 b2 =
sqrt ((float (b1.x - b2.x))**2. + (float (b1.y - b2.y)) **2.)
The distance
function has type 'a blk -> 'b blk -> float
and will compute the distance between two blocks of any type.
This solution has several drawbacks:
Hard to extend. Adding a new kind of block is hard, you basically need to design beforehand what blocks do you need and hope that you do not need to add a new block in the future. It looks like that you're expecting that you will need to add new block types, so this solution might not suit you. However, I believe that you will actually need a very small number of block kinds if you will treat each block as a syntactical element of the world grammar, you will soon notice that the minimal set of block kinds is quite small. Especially, if you will make your blocks recursive, i.e., if you will allow block composition (i.e., mixtures of different blocks in the same block).
You can't put blocks of different kinds in the same container. Because to do this, you need to forget the type of block. If you will do this, you will eventually end up with a container of positions. We will try to alleviate this in our middle-ground solution by using existential types.
Your type model doesn't impose right constraints. The world constraint is that the world is composed of blocks, and each coordinate either has a block or doesn't have one (i.e., it is the void). In your case, two blocks may have the same coordinates.
Not so rigid hierarchy using GADT
We may relax few restrictions of the previous solution, by using existential GADT. The idea of the existential is that you can forget the kind of a block, and later recover it. This is essentially the same as a variant type (or dynamic type in C#). With existentials, you can have an infinite amount of block kinds and even put them all in the same container. Essentially, an existential is defined as a GADT that forgets its type, e.g., the first approximation
type block = Block : block_info -> {pos : pos; info : block_info}
So now we have a unified block type, that is locally quantified with the type of block payload. You may even move further, and make the block_info
type extensible, e.g.,
type block_info = ..
type block_info += Air
Instead of building an existential representation by yourself (it's a nice exercise in GADT), you may opt to use some existing libraries. Search for "universal values" or "universals" in the OPAM repositories, there are a couple of solutions.
This solution is more dynamic and allows us to store values of the same type in the same container. The hierarchy is extensible. This comes with a price of course, as now we can't have one single point of definition for a particular method, in fact, method definitions would be scattered around your program (kind of close to the Common Lisp CLOS model). However, this is an expected price for an extensible dynamic system. Also, we lose the static property of our model, so we will use lots of wildcard in pattern matching, and we can't rely on the type system to check that we covered all possible combinations. And the main problem is still there our model is not right.
Not so rigid structure with OO
OCaml has the Object Oriented Layer (hence the name) so you can build classical OO hierarchies. E.g.,
class block x y = object
val x = x
val y = y
method x = x
method y = y
method with_x x = {< x = x >}
method with_y y = {< y = y >}
end
class input_block facing = object
inherit block
val facing = facing
method facing = facing
method with_facing f = {< facing = f >}
end
This solution is essentially close to the first solution, except that your hierarchy is now extensible at the price that the set of methods is now fixed. And although you can put different blocks in the same container by forgetting a concrete type of a block using upcasting, this won't make much sense since OCaml doesn't have the down-cast operator, so you will end up with a container of coordinates. And we still have the same problem - our model is not right.
Dynamic world structure using Flyweights
This solution kills two bunnies at the same time (and I believe that this should be the way how it is implemented in Minecraft). Let's start with the second problem. If you will represent every item in your world with a concrete record that has all attributes of that item you will end up with lots of duplicates and extreme memory consumption. That's why in real-world applications a pattern called Flyweight is used. So, if you think about scalability you will still end up in using this approach. The idea of the Flyweight pattern is that your objects share attributes by using finite mapping, and objects itself are represented as identifiers, e.g.,
type block = int
type world = {
map : pos Int.Map.t;
facing : facing Int.Map.t;
air : Int.Set.t;
}
where 'a Int.Map.t
is a mapping from int
to 'a
, and Int.Set.t
is a set of integers (I'm using the Core
library here).
In fact, you may even decide that you don't need a closed world type, and just have a bunch of finite mappings, where each particular module adds and maintains its own set of mappings. You can use abstract types to store this mapping in a central repository.
You may also consider the following representation of the block type, instead of one integer, you may use two integers. The first integer denotes an identity of a block and the second denotes its equality.
type block = {id : int; eq : int}
The idea is that every block in the game will have a unique id
that will distinguish it from other blocks even if they are equal "as two drops of water". And eq
will denote structural equality of two blocks, i.e., two blocks with exact same attributes will have the same eq
number. This solution is hard to implement if you world structure is not closed though (as in this case the set of attributes is not closed).
The main drawback of this solution is that it is so dynamic that it sorts of leaving the OCaml type system out of work. That's a reasonable penalty, in fact you can't have a dynamic system that is fully verified in static time. (Unless you have a language with dependent types, but this is a completely different story).
To summarize, if I were devising such kind of game, I will use the last solution. Mainly because it scales well to a large number of blocks, thanks to hashconsing (another name for Flyweight). If scalability is not an issue, then I will build a static structure of blocks with different composition operators, e.g.,
type block =
| Regular of regular
| ...
| Compose of compose_kind * block * block
type compose_kind = Horizontal | Vertical | Inplace
And now the world
is just a block. This solution is pure mathematical though, and doesn't really scale to larger worlds.