The problem you're asking about is a non-problem, but the structure you've come up with is very inefficient.
Consider a simple 128x128x128 chunk. That will require 2097152 instances of Block
. You want Block
to be as small as possible. One thing that is easy to get rid of is the position. The position is implicit in the array containing the Block
instance. Nobody can retrieve the block without knowing its position. Hence, the position field of Block
is redundant and can be removed.
Another thing you want is to keep the structure fast. That's a lot of blocks per chunk you'll be working with for rendering, physics, gameplay, etc. One of the simplest things to keep in mind when working with performance is the CPU cache and memory accesses. Accessing random locations in memory is not free; they usually incur a pipeline stall in the CPU that can be quite a heavy hit on modern CPUs. The easiest way to combat this is to keep all your data contiguous in memory. Thankfully, this is what a List
or arrays do already. However, if you have a List
or array of classes, the data structure is technically only keeping references to the objects in its contiguous memory. The actual objects themselves could be strewn all over the place. C# was well-designed and includes the idea of a struct as an alternative to a class (unlike Java; Minecraft has to jump through more hoops to keep things contiguous in memory). Converting Block
to a struct will make accessing them faster. However, structs will actually cause the inheritance problem you were originally worried about, since a List<Block>
(where Block
is a struct) actually wouldn't be able to hold subclasses.
The final trick then is to simply not use classes and inheritance where they aren't needed, and this is one such place. In general, inheritance is a very, very poor tool for such problems. Use inheritance when you want to model is-a relationships, keeping in mind how a computer works/things - the computer has no reason to care that a stone block is a type of block. That's a human taxonomy problem. The computer only cares whether a stone block exposes the behavior of a block. That is, inheritance to a computer is mostly valuable only when you're modeling interfaces and behaviors.
Furthermore, inheritance starts to cause some serious issues. Say you have an ice block that has sliding behavior and a puzzle block that can be pushed and then want to add an ice puzzle block. Does it inherit from PuzzleBlock
or IceBlock
? Do you move all the common functionality up into Block
and make every single block pay the price for the excess bloat? The concept of aggregation solves this nicely. You can make an IceBlockComponent
and a PuzzleBlockComponent
and now an individual BlockType
can contain a list of these components, allowing you to mix-and-match behaviors however you want.
Alternatively, if you don't feel you need the full flexibility (and resulting complexity) of component-based design, move your inheritance structure out of Block
and into a BlockType
hierarchy. So you'd have a BlockTypeStone
or BlockTypeIce
and still only a single Block
.
Going back to the data-structure problem, not that each Block
does not need to contain all the behavior and methods of its type. In such a system, it needs only point back to its BlockType
. This can be done efficiently by storing all the BlockTypes
in an array or List
and then storing a small integer (say a byte
or a short
) in the Block
object. Now you no longer have any need for inheritance in Block
and you've reduced the size from before (no need for 4-8 byte references in your Chunk
array of blocks but rather a compact struct containing a small integer... or just the integer itself with no struct!).
The remaining hassle then is data. Thankfully, most data in these games are going to be per type rather than per block instance. That is, your IceBlockComponent
may have a slipperiness
attribute which would apply to all blocks of that type. There's no need to store the value on each individual block.
For the few bits of data you do need per block, just use a few bits (or a couple bytes). If your Block
is just an 8-bit index to a BlockType
, you could toss in another 8 or 16 bits and still have Block
stay relatively small and compact. These extra bits of data would be interpreted as needed by the block type (managing the meaning of the bits in a component/aggregation model is a little tricky, but doable).
The end result of all these is that you've got a very compact Chunk
that uses much less memory and is much faster to process than what you had before. You've also simplified your use of the type system and (if you play your cards right) made the game easier to mod and extend in the future.
StoneBlock
is-aBlock
for most intents and purposes. That said, there are much better ways to do this, I'll post a full answer. \$\endgroup\$