About This Project

This project is part of a course in my education at The Game Assembly. The goal of the course is for students to plan and execute an independent project within a set time frame - approximately 80 hours in total, which includes the time spent building this website.

For my project I chose to build a small grid-based world builder with a farm theme. I wanted to work on something I found both interesting and fun - which was hard to find, since almost everything sounds interesting when it's new. My inspiration came from a lecture in an earlier course about procedurally generated maps and dungeons. I had a thought that the same underlying logic could be applied to tile placement on a grid, and that idea stuck with me. I have always loved games like Harvest Moon and Stardew Valley, so it felt natural to take the project in that direction on a small scale.

MVP

These are the goals I set for this project. I was able to fulfil everything in my MVP list and a few from the Nice to Have list.

Grid & Tiles

  • The grid size is determined by the resolution of the window.
  • There are at least three tile types to provide visual variation.
  • When a tile type is changed, its texture is determined by its neighbours. Neighbouring cells update automatically if necessary.
  • Hovering the mouse over a cell highlights it.

Objects & Placement

  • Objects can be placed on the grid by selecting the correct tool.
  • A placed object blocks the ability to change the tile type of that cell.
  • Objects can be removed to unblock the cell.
  • Fences are the minimum object to implement. They tile in the same way as the grid.

Controls & Interaction

  • The active action can be changed by switching tools - implemented via Dear ImGui.
  • A single click completes an action.

Nice to Have

  • Holding and dragging the mouse can change tiles and place fences.
  • At least one additional object type can be placed on the grid.
  • Seeds can be planted and grown into flowers.
  • Plants must be watered before they can grow.
  • A HUD for switching tools.
  • A controllable player character.

Grid

I kept the grid setup simple and flexible. Rather than hard-coding a fixed size, the grid dimensions are calculated from the window resolution at initialisation - this way the grid always fills the available space without needing to be adjusted manually if the resolution changes.

C++ — Grid Setup
float cellsForWidth  = std::floor(aGridSizeWidth  / myCellWidth);
float cellsForHeight = std::floor(aGridSizeHeight / myCellHeight);
myGridWidth  = static_cast<int>(cellsForWidth);
myGridHeight = static_cast<int>(cellsForHeight);

float gridSizeWidth  = (cellsForWidth  * myCellWidth);
float gridSizeHeight = (cellsForHeight * myCellHeight);
float diffWidth      = aGridSizeWidth  - gridSizeWidth;
float diffHeight     = aGridSizeHeight - gridSizeHeight;

if (diffWidth > 0)
{
    myGridStartPos.x = aStartPos.x + (diffWidth / 2);
}
else
{
    myGridStartPos.x = aStartPos.x;
}
if (diffHeight > 0)
{
    myGridStartPos.y = aStartPos.y + (diffHeight / 2);
}
else
{
    myGridStartPos.y = aStartPos.y;
}
myGridEndPos = { aStartPos.x + gridSizeWidth, aStartPos.y + gridSizeHeight };

Tiling

To begin, I had to figure out how many unique tile variations I needed and which directions each one responds to. I started with the path tile, since it requires the most variations - I wanted tiles that respond to all 8 surrounding cells, both orthogonal and diagonal. However, a diagonal neighbour is only considered valid if both of its adjacent orthogonal neighbours share the same tile type.

I did some research but could not find a complete guide that suited my needs, so I mapped it out in Excel to visualise the combinations myself. The result is 47 unique tile variations in total.

Tile Variations — Excel Reference
#Directions
0Isolated
Singles
1N
2W
3E
4S
Corners
5NW
6NE
7SW
8SE
Straights
9NS
10EW
T-Crossings
11NWS
12NWE
13NES
14EWS
All Orthogonal
15NESW
Corners with 1 Diagonal
16NWNW
17NENE
18SWSW
19SESE
T-Crossings with 1 Diagonal
20NWSNW
21NWSSW
22NWENW
23NWENE
24NESNE
25NESSE
26EWSSW
27EWSSE
T-Crossings with 2 Diagonals
28NWSNWSW
29NWENWNE
30NESNESE
31EWSSWSE
All Orthogonal + All Diagonals
32NESWNWSWNESE
All Orthogonal + 1 Diagonal
33NESWNW
34NESWNE
35NESWSE
36NESWSW
All Orthogonal + 2 Diagonals
37NESWNWNE
38NESWNWSE
39NESWNWSW
40NESWNESE
41NESWNESW
42NESWSESW
All Orthogonal + 3 Diagonals
43NESWNWNESW
44NESWNWNESE
45NESWNWSWSE
46NESWNESWSE
Example — Corners with 1 Diagonal
Excel tiling example

To generate a unique index for each tile variation, each of the 8 neighbours is assigned a specific bit value. Checking all neighbours produces a bitmask that maps to a unique integer - this is then used to look up the correct sprite in a cached vector. The lookup table is initialised once at startup and maps every valid bit combination to its corresponding tile index.

C++ — Calculate which tile sprite to use
void Grid::CalculateTileToPlace(GridCell& aCell, bool aIsNeighbour)
{
    int index = 0;
    constexpr int size = 8; // = aCell.neighbours.size()
    bool nIsSame[size];

    for (int i = 0; i < size; i++)
    {
        // A negative index means aCell is at the grid edge
        // no valid neighbour exists in this direction.
        if (aCell.neighbours[i] < 0)
        {
            nIsSame[i] = false;
            continue;
        }
        // true if neighbour shares the same tile type
        nIsSame[i] = (myCells[aCell.neighbours[i]].tileType == aCell.tileType);
    }

    // Build a bitmask from the 8 neighbours
    if (nIsSame[0]) index |=   1;
    if (nIsSame[1]) index |=   2;
    if (nIsSame[2]) index |=   4;
    if (nIsSame[3]) index |=   8;
    if (nIsSame[4]) index |=  16;
    if (nIsSame[5]) index |=  32;
    if (nIsSame[6]) index |=  64;
    if (nIsSame[7]) index |= 128;

    // Recursively update neighbours (only on the initial call)
    if (!aIsNeighbour)
    {
        for (int i = 0; i < aCell.neighbours.size(); i++)
        {
            if (nIsSame[i])
            {
                CalculateTileToPlace(myCells[aCell.neighbours[i]], true);
            }
        }
    }

    aCell.tileIndex = myTileLUT[index];
    aCell.backgroundSprite.sharedData = GetATileSprite(aCell.tileType, aCell.tileIndex);
}
C++ — Init Tile Lookup Table
int Grid::InitTileLookupTable(int aMask)
{
    // Normalise mask: a diagonal is only valid if both orthogonal neighbours are true
    bool north = aMask & 2;
    bool east  = aMask & 16;
    bool south = aMask & 64;
    bool west  = aMask & 8;
    if (!(north && west))  aMask &= ~1;
    if (!(north && east))  aMask &= ~4;
    if (!(south && west))  aMask &= ~32;
    if (!(south && east))  aMask &= ~128;

    int  mask      = aMask;
    bool n  = mask & 2,  w  = mask & 8;
    bool e  = mask & 16, s  = mask & 64;
    bool nw = mask & 1,  ne = mask & 4;
    bool sw = mask & 32, se = mask & 128;
    int edgeCount = (n?1:0) + (w?1:0) + (e?1:0) + (s?1:0);
    int diagCount = (nw?1:0) + (ne?1:0) + (sw?1:0) + (se?1:0);

    if (edgeCount == 0) return 0;
    if (edgeCount == 1)
    {
        if (n) return 1;  if (w) return 2;
        if (e) return 3;  return 4;
    }
    if (edgeCount == 2)
    {
        if (n && s) return 5;  if (e && w) return 6;
        if (n && w && !nw) return 7;   if (n && e && !ne) return 8;
        if (s && w && !sw) return 9;   if (s && e && !se) return 10;
        if (n && w && nw) return 20;  if (n && e && ne) return 21;
        if (s && w && sw) return 22;  if (s && e && se) return 23;
    }
    if (edgeCount == 3)
    {
        if (diagCount == 0)
        {
            if (!s) return 11; if (!e) return 12;
            if (!w) return 13; if (!n) return 14;
        }
        if (diagCount == 1)
        {
            if (!s && nw) return 24; if (!s && ne) return 25;
            if (!e && nw) return 26; if (!e && sw) return 27;
            if (!w && ne) return 28; if (!w && se) return 29;
            if (!n && sw) return 30; if (!n && se) return 31;
        }
        if (diagCount == 2)
        {
            if (!s) return 16; if (!e) return 17;
            if (!w) return 18; if (!n) return 19;
        }
    }
    if (edgeCount == 4)
    {
        if (diagCount == 0) return 15;
        if (diagCount == 1)
        {
            if (nw) return 32; if (ne) return 33;
            if (sw) return 34; if (se) return 35;
        }
        if (diagCount == 2)
        {
            if (nw && ne) return 36; if (ne && se) return 37;
            if (se && sw) return 38; if (nw && sw) return 39;
            if (nw && se) return 40; if (ne && sw) return 41;
        }
        if (diagCount == 3)
        {
            if (nw && ne && sw) return 42; if (nw && ne && se) return 43;
            if (nw && sw && se) return 44; if (ne && sw && se) return 45;
        }
        if (diagCount == 4) return 46;
    }
    return 0;
}

Once the tile logic was in place, applying the same system to fences was straightforward. The main difference is that fences only check the four orthogonal neighbours, not the diagonals, making it a simpler 4-bit check.

C++ — Calculate which fence sprite to use
std::shared_ptr<Tga::SpriteSharedData> Fence::CalculateFenceToPlace(int aCellIndex, bool aIsNeighbour)
{
    auto gameScene = GameWorld::GetInstance().GetCurrentGameScene().lock();
    auto& grid      = gameScene->GetGrid();
    int  gridWidth  = grid.GetGridWidth();
    int  column     = aCellIndex % gridWidth;
    int  row        = aCellIndex / gridWidth;

    constexpr int size = 4;
    int neighbourIndices[size];
    neighbourIndices[0] = (aCellIndex + gridWidth) % gridWidth == column ? (aCellIndex + gridWidth) : -1; // N
    neighbourIndices[1] = (aCellIndex + 1) / gridWidth == row    ? (aCellIndex + 1) : -1;         // E
    neighbourIndices[2] = (aCellIndex - gridWidth) % gridWidth == column ? (aCellIndex - gridWidth) : -1; // S
    neighbourIndices[3] = (aCellIndex - 1) / gridWidth == row    ? (aCellIndex - 1) : -1;         // W

    int  index  = 0;
    bool nSame[size];
    for (int i = 0; i < size; i++)
    {
        if (neighbourIndices[i] < 0) { nSame[i] = false; continue; }
        nSame[i] = (grid.GetGridCell(neighbourIndices[i]).cellType == CellType::Fence);
    }
    // 4-bit bitmask: N=1, E=2, S=4, W=8
    if (nSame[0]) index |= 1;
    if (nSame[1]) index |= 2;
    if (nSame[2]) index |= 4;
    if (nSame[3]) index |= 8;

    if (!aIsNeighbour)
    {
        for (int i = 0; i < size; i++)
        {
            if (nSame[i])
            {
                auto& otherObjects = gameScene->GetOtherGameObjects();
                auto it = std::find_if(otherObjects.begin(), otherObjects.end(),
                    [&](const std::shared_ptr<GameObject2D>& obj)
                    { return obj->GetGridIndex() == neighbourIndices[i]; });
                if (it != otherObjects.end())
                    (*it)->SetSpriteSharedData(CalculateFenceToPlace(neighbourIndices[i], true));
            }
        }
    }
    return myCachedFenceSprites[myFenceLUT[index]];
}

Conclusion

I'm very glad I chose this project for my Specialisation. I learned more than I initially expected and had a lot of fun throughout the process.

During the development of this project, I encountered a number of technical problems along the way, which I really enjoyed solving. It felt rewarding to take an idea and gradually turn it into something functional. If I had more time, I would love to continue expanding the world and adding more features such as a player and interactive mechanics.