Tower Builder & Validator

Info

  • Engine: Custom Engine
  • Language: C++
  • Tools: ImGui, Immediate Mode Geometry tool
  • Time frame: 3 weeks
  • Team Size: Solo

Overview

I wanted to prove the validity of my Immediate Geometry Tool in a “real game” setting. I also wanted to create a game where you build something, since I really enjoy that style of game.

The goal of the game is to build a tower using different pieces. When a player finishes a floor, they unlock the next floor and get a new rule imposed on them. The rules will force the player to rebuild their tower as the game goes on. If a floor does not follow a rule, it gets marked with a red “outline” akin to the red squiggly line found in most IDE:s.

Building

Below are some constexpr, enums and structs that are helpful in understanding the building code that follows.

Defintions - TowerHelpers.h

View on GitHub >

constexpr int GRID_WIDTH{ 7 };
constexpr int GRID_DEPTH{ 7 };

enum class BlockType
{
	Void,
	Wall,
	Floor,
	Door,
	Window,
	Stairs,
	Shrine,
	Count
};

enum class Direction
{
	North,
	East,
	South,
	West,
	Count,
};

struct BlockCoordinate
{
	int row = -1;
	int column = -1;
};

struct BlockData
{
	WASD::UniqueID objectID{ INVALID_ID };
	BlockType blockType{ BlockType::Void };
	Direction direction{ Direction::North };
};

struct FloorData
{
	std::array<std::vector<BlockCoordinate>, static_cast<size_t>(BlockType::Count)> blockTypeMetaData;

	std::vector<BlockCoordinate> walkableBlocksMetaData;
	std::vector<BlockCoordinate> supportingBlocksMetaData;
	std::vector<std::vector<BlockCoordinate>> walkableRegionMetaData;
	std::vector<std::vector<BlockCoordinate>> roomMetaData;
	std::array<std::array<int, GRID_DEPTH>, GRID_WIDTH> roomIndexMetaData;


	std::array<std::array<BlockData, GRID_DEPTH>, GRID_WIDTH> blockData;

	void RemoveOldMetaData();
	void CalculateMetaData();
};

using Tower = std::vector<FloorData>;

Using my Geometry tool, I create an interactable grid of quads. When the player clicks a Quad, new data will be added to the current floor. Then, a visual representation of that data will be created using our custom engine's ECS.

Grid Drawing & Interactions - TowerSystems.cpp

View on GitHub >

void TickInteractableGrid()
{
  	InGeo::PushStyle(GRID_STYLE);
  
  	const InGeo::Vector3f gridPosition = {
  		BUILDING_BLOCK_HALF_SIZE,
  		(BUILDING_BLOCK_HEIGHT * static_cast<float>(locCurrentFloorIndex)) + INTERACTABLE_GEOMETRY_PADDING,
  		BUILDING_BLOCK_HALF_SIZE
  	};
  
  	InGeo::BeginGrid("FloorGrid", gridPosition, {}, FLOOR_GRID_SETTINGS);
  	for (int currentRow = 0; currentRow < GRID_DEPTH; ++currentRow)
  	{
  		for (int currentColumn = 0; currentColumn < GRID_WIDTH; ++currentColumn)
  		{
  			BlockData& currentBlockData = locTower[locCurrentFloorIndex].blockData[currentRow][currentColumn];
  
  			if (currentBlockData.objectID == INVALID_ID) // Tile does not have an object placed on it yet
  			{
  				Tga::Vector3f newPosition = InGeo::to_tga(InGeo::GetCurrentGridCellPosition());
  				newPosition.y -= INTERACTABLE_GEOMETRY_PADDING; // Ensure that geometry is created below grid to avoid Z-fighting
  				InGeo::NextGridCell(InGeo::GeometryType::Quad);
  
  				if (!locIsEraserActive && InGeo::IsGeometryClicked()) // Initial placement of block
  				{
  					Tga::TRSf trs;
  					trs.SetPosition(newPosition);
  
  					const WASD::SceneObjectData sceneObjectData{ .trs = trs };
  					const WASD::UniqueID newObjectID = SceneLoading::CreateRunTimeObject(to_TGO(locCurrentBlockType), sceneObjectData);
  
  					currentBlockData = {
  						.objectID = newObjectID,
  						.blockType = locCurrentBlockType,
  						.direction = Direction::North
  					};
  
  					locHasFloorChanged = true;
  				}
  			}
  			else
  			{
  				InGeo::NextGridCell(InGeo::GeometryType::None);
  			}
  		}
  
  		InGeo::NextGridRow();
  	}
  	InGeo::EndGrid();
  	InGeo::PopStyle();
  }
    

The player can pick between several placeable blocks using a menu created with ImGui. Each BlockType corresponds to a particular object definition in the custom engine.

The player may also interact with placed blocks. For each block on the current floor, I create an interactable cuboid using my geometry tool. By interacting with the cuboid geometries, the player may replace, rotate, or delete blocks.

Block Interactions - TowerSystems.cpp

View on GitHub >

if (locIsEraserActive)
{
  	InGeo::PushStyle(INTERACTABLE_BLOCK_ERASER_STYLE);
  }
  else
  {
  	InGeo::PushStyle(INTERACTABLE_BLOCK_STYLE);
  }
  
  for (int currentRow = 0; currentRow < GRID_DEPTH; ++currentRow)
  {
  	for (int currentColumn = 0; currentColumn < GRID_WIDTH; ++currentColumn)
  	{
  		BlockData& currentBlockData = locTower[locCurrentFloorIndex].blockData[currentRow][currentColumn];
  		const WASD::UniqueID currentBlockID{ currentBlockData.objectID };
  
  		if (currentBlockID == INVALID_ID)
  		{
  			continue;
  		}
  
  		const WASD::Entity blockEntity{ currentBlockID };
  
  		auto* transformComponent = blockEntity.AccessComponent<WASD::TransformComponent>();
  		const Tga::TRSf trsCopy = transformComponent->GetTRS();
  
  		const InGeo::Vector3f currentPosition = InGeo::to_ingeo(trsCopy.GetPosition()) +
  			InGeo::Vector3f{ 0.f, BUILDING_BLOCK_HALF_HEIGHT, 0.f }; // Compensate for geometry being created AROUND position
  
  		constexpr InGeo::CuboidData interactableCubeData{
  			.width = INTERACTABLE_GEOMETRY_SIZE,
  			.depth = INTERACTABLE_GEOMETRY_SIZE,
  			.height = INTERACTABLE_GEOMETRY_HEIGHT
  		};
  
  		InGeo::CreateGeometry(std::to_string(currentBlockID), interactableCubeData, currentPosition);
  
  		if (InGeo::IsGeometryHovered()) // Also sets the whole geometry to the hover color
  		{
  			if (InGeo::IsInputStopped(InGeo::Input::Delete))
  			{
  				blockEntity.AddTag(WASD::TAG_ID_DESTROY_ENTITY);
  				currentBlockData = BlockData{};
  				locHasFloorChanged = true;
  				continue;
  			}
  
  			if (InGeo::IsInputStopped(InGeo::Input::R))
  			{
  				auto& trs = transformComponent->AccessTRS();
  				trs.RotateWithPitchYawRoll({ 0.f, 90.f, 0.f });
  				currentBlockData.direction = getNextDirection(currentBlockData.direction);
  				locHasFloorChanged = true;
  			}
  		}
  
  		if (InGeo::IsGeometryClicked())
  		{
  			blockEntity.AddTag(WASD::TAG_ID_DESTROY_ENTITY);
  
  			if (locIsEraserActive)
  			{
  				currentBlockData = BlockData{};
  			}
  			else
  			{
  				const WASD::SceneObjectData sceneObjectData{ .trs = trsCopy };
  				const WASD::UniqueID newObjectID = SceneLoading::CreateRunTimeObject(to_TGO(locCurrentBlockType), sceneObjectData);
  
  				currentBlockData = {
  					.objectID = newObjectID,
  					.blockType = locCurrentBlockType,
  					.direction = currentBlockData.direction
  				};
  
  			}
  
  			locHasFloorChanged = true;
  		}
  	}
  }
  InGeo::PopStyle();
  

When the player is forced to return to a previous floor to rebuild, they may hide floors above to make it easier to see what they are currently building.

Floor Hiding & Showing - TowerSystems.cpp

View on GitHub >

void HideAboveFloorBlocks()
{
  	for (int floorIndex = locCurrentFloorIndex + 1; floorIndex < locTower.size(); floorIndex++)
  	{
  		FloorData& currentFloorData = locTower[floorIndex];
  
  		for (int currentRow = 0; currentRow < GRID_DEPTH; ++currentRow)
  		{
  			for (int currentColumn = 0; currentColumn < GRID_WIDTH; ++currentColumn)
  			{
  				const BlockData& currentBlockData = currentFloorData.blockData[currentRow][currentColumn];
  
  				if (currentBlockData.objectID == INVALID_ID) // No object occupies block
  				{
  					continue;
  				}
  
  				const WASD::Entity blockEntity{ currentBlockData.objectID };
  				auto* modelComponent = blockEntity.AccessComponent<WASD::ModelComponent>();
  				WASD::AddFlag(modelComponent->flags, WASD::ModelFlag::Hidden);
  			}
  		}
  	}
  
  	locIsHidingGeometry = true;
  }
  
  void ShowAboveFloorBlocks()
  {
  	for (int floorIndex = locCurrentFloorIndex + 1; floorIndex < locTower.size(); floorIndex++)
  	{
  		const FloorData& currentFloorData = locTower[floorIndex];
  
  		for (int currentRow = 0; currentRow < GRID_DEPTH; ++currentRow)
  		{
  			for (int currentColumn = 0; currentColumn < GRID_WIDTH; ++currentColumn)
  			{
  				const BlockData& currentBlockData = currentFloorData.blockData[currentRow][currentColumn];
  
  				if (currentBlockData.objectID == INVALID_ID)  // No object occupies block
  				{
  					continue;
  				}
  
  				const WASD::Entity blockEntity{ currentBlockData.objectID };
  				auto* modelComponent = blockEntity.AccessComponent<WASD::ModelComponent>();
  				WASD::RemoveFlag(modelComponent->flags, WASD::ModelFlag::Hidden);
  			}
  		}
  	}
  
  	locIsHidingGeometry = false;
  }
  

Rule validation

Each time a floor changes, for example through placing a block, new metadata is calculated for that floor.

If the code didn't calculate metadata, it would need to iterate through all the floor tiles, per floor, per rule. Rules would need to iterate through the whole floor every time just to find the particular blocks.

Metadata Calculation - TowerSystems.cpp

View on GitHub >

void FloorData::RemoveOldMetaData()
{
  	for (std::vector<BlockCoordinate>& blockCoordinates : blockTypeMetaData)
  	{
  		blockCoordinates.clear();
  	}
  
  	for (int currentRow = 0; currentRow < GRID_DEPTH; currentRow++)
  	{
  		for (int currentColumn = 0; currentColumn < GRID_WIDTH; currentColumn++)
  		{
  			roomIndexMetaData[currentRow][currentColumn] = -1;
  		}
  	}
  
  	walkableBlocksMetaData.clear();
  	supportingBlocksMetaData.clear();
  
  	walkableRegionMetaData.clear();
  	roomMetaData.clear();
  }
  
  void FloorData::CalculateMetaData()
  {
  	RemoveOldMetaData();
  
  	for (int currentRow = 0; currentRow < GRID_DEPTH; ++currentRow)
  	{
  		for (int currentColumn = 0; currentColumn < GRID_WIDTH; ++currentColumn)
  		{
  			const BlockData& currentBlockData{ blockData[currentRow][currentColumn] };
  			blockTypeMetaData[static_cast<int>(currentBlockData.blockType)].emplace_back(currentRow, currentColumn);
  
  			if (IsBlockTypeWalkable(currentBlockData.blockType))
  			{
  				walkableBlocksMetaData.emplace_back(currentRow, currentColumn);
  			}
  
  			if (IsBlockTypeSupporting(currentBlockData.blockType))
  			{
  				supportingBlocksMetaData.emplace_back(currentRow, currentColumn);
  			}
  		}
  	}
  
  	bool visitedRegionCoordinates[GRID_DEPTH][GRID_WIDTH] = {};
  	for (const auto& walkableBlock : walkableBlocksMetaData)
  	{
  		std::vector<BlockCoordinate> currentRegion;
  
  		std::queue<BlockCoordinate> coordinatesToVisit;
  		coordinatesToVisit.push(walkableBlock);
  
  		while (!coordinatesToVisit.empty())
  		{
  			const BlockCoordinate currentCoordinate = coordinatesToVisit.front();
  			coordinatesToVisit.pop();
  
  			const int currentRow = currentCoordinate.row;
  			const int currentColumn = currentCoordinate.column;
  
  			if (visitedRegionCoordinates[currentRow][currentColumn])
  			{
  				continue;
  			}
  
  			visitedRegionCoordinates[currentRow][currentColumn] = true;
  			currentRegion.push_back(currentCoordinate);
  
  			for (int directionIndex = 0; directionIndex < static_cast<int>(Direction::Count); ++directionIndex)
  			{
  				const Direction currentDirection = static_cast<Direction>(directionIndex);
  
  				if (const std::optional<BlockCoordinate> nextCoordinate = CanWalkInDirection(*this, currentCoordinate, currentDirection);
  					nextCoordinate.has_value())
  				{
  					const BlockCoordinate nextCoordinateValue = nextCoordinate.value();
  					coordinatesToVisit.push(nextCoordinateValue);
  				}
  			}
  		}
  
  		if (currentRegion.empty())
  		{
  			continue;
  		}
  
  		walkableRegionMetaData.push_back(currentRegion);
  	}
  
  	bool visitedRoomCoordinates[GRID_DEPTH][GRID_WIDTH] = {};
  	for (auto& walkableRegion : walkableRegionMetaData)
  	{
  		for (const BlockCoordinate& walkableCoordinate : walkableRegion)
  		{
  			{
  				const BlockData& walkableBlockData = blockData[walkableCoordinate.row][walkableCoordinate.column];
  				if (walkableBlockData.blockType == BlockType::Door)
  				{
  					continue;
  				}
  			}
  
  			std::vector<BlockCoordinate> roomCoordinates;
  			std::queue<BlockCoordinate> coordinatesToVisit;
  			coordinatesToVisit.push(walkableCoordinate);
  
  			while (!coordinatesToVisit.empty())
  			{
  				const BlockCoordinate currentCoordinate = coordinatesToVisit.front();
  				coordinatesToVisit.pop();
  
  				const int currentRow = currentCoordinate.row;
  				const int currentColumn = currentCoordinate.column;
  
  				if (visitedRoomCoordinates[currentRow][currentColumn])
  				{
  					continue;
  				}
  
  				visitedRoomCoordinates[currentRow][currentColumn] = true;
  				roomCoordinates.push_back(currentCoordinate);
  				roomIndexMetaData[currentRow][currentColumn] = static_cast<int>(roomMetaData.size());
  
  				for (int directionIndex = 0; directionIndex < static_cast<int>(Direction::Count); ++directionIndex)
  				{
  					const Direction currentDirection = static_cast<Direction>(directionIndex);
  
  					if (const std::optional<BlockCoordinate> nextCoordinate = CanWalkInDirection(*this, currentCoordinate, currentDirection);
  						nextCoordinate.has_value())
  					{
  						const BlockCoordinate nextCoordinateValue = nextCoordinate.value();
  						const BlockData& nextCoordinateBlockData = blockData[nextCoordinateValue.row][nextCoordinateValue.column];
  
  						if (nextCoordinateBlockData.blockType == BlockType::Door)
  						{
  							continue;
  						}
  
  						coordinatesToVisit.push(nextCoordinateValue);
  					}
  				}
  			}
  
  			if (roomCoordinates.empty())
  			{
  				continue;
  			}
  
  			roomMetaData.push_back(roomCoordinates);
  		}
  	}
  }
  

After metadata is calculated for the current floor, the system tries to validate the tower. This means that rules are only validated when the tower changes. If the tower adheres to all rules, the next floor is unlocked

Tower Validation - TowerSystems.cpp

View on GitHub >

void TickTowerValidation()
{
  	if (!locHasFloorChanged)
  	{
  		return;
  	}
  
  	locTower[locCurrentFloorIndex].CalculateMetaData();
  
  	for (const std::unique_ptr<Rule>& rule : locActiveRules)
  	{
  		rule->SetIsValid(true); // Reset values to for reevaluation
  	}
  
  	locFloorViolationData.clear();
  
  	for (int floorIndex = 0; floorIndex < locTower.size(); ++floorIndex)
  	{
  		locFloorViolationData.emplace_back();
  
  		for (const auto& rule : locActiveRules)
  		{
  			rule->Evaluate(locTower, locFloorViolationData.back(), floorIndex);
  		}
  	}
  
  	bool isTowerValid = true;
  	for (const std::unique_ptr<Rule>& rule : locActiveRules)
  	{
  		if (!rule->IsValid())
  		{
  			isTowerValid = false;
  			break;
  		}
  	}
  
  	locHasFloorChanged = false;
  
  	if (isTowerValid)
  	{
  		if (locPendingRules.empty())
  		{
  			return;
  		}
  
  		locActiveRules.push_back(std::move(locPendingRules.front()));
  		locPendingRules.pop();
  		locTower.emplace_back();
  
  		if (locIsHidingGeometry)
  		{
  			ShowAboveFloorBlocks();
  		}
  
  		locCurrentFloorIndex = static_cast<int>(locTower.size()) - 1;
  		locHasFloorChanged = true; // Just to trigger reevaluation of rules
  	}
  }
  

Rules themselves can be relatively small, since they have fast access to relevant data. This makes it easy to add more rules quickly.

Support Rule - TowerRules.cpp

View on GitHub >

SupportRule::SupportRule()
{
  	myDescription = "Each supporting piece must have a supporting piece adjacent on the floor below"_tgaid;
  }
  
  void SupportRule::Evaluate(const Tower& aTower, FloorViolationData& aViolationData, int aFloorIndex)
  {
  	if (aFloorIndex == 0)
  	{
  		return;
  	}
  
  	const FloorData& currentFloorData = aTower[aFloorIndex];
  
  	if (currentFloorData.supportingBlocksMetaData.empty())
  	{
  		return;
  	}
  
  	const FloorData& belowFloorData = aTower[aFloorIndex - 1];
  	for (const BlockCoordinate& supportingCoordinate : currentFloorData.supportingBlocksMetaData)
  	{
  		bool hasSupport = false;
  		const BlockData& belowBlock = belowFloorData.blockData[supportingCoordinate.row][supportingCoordinate.column];
  
  		if (IsBlockTypeSupporting(belowBlock.blockType))
  		{
  			hasSupport = true;
  		}
  
  		for (int i = 0; i < static_cast<int>(Direction::Count); ++i)
  		{
  			const BlockCoordinate adjacentCoordinate = GetAdjacentCoordinate(supportingCoordinate, static_cast<Direction>(i));
  
  			if (!IsCoordinateWithinBounds(adjacentCoordinate))
  			{
  				continue;
  			}
  
  			const BlockData& belowAdjacentBlock = belowFloorData.blockData[adjacentCoordinate.row][adjacentCoordinate.column];
  
  			if (IsBlockTypeSupporting(belowAdjacentBlock.blockType))
  			{
  				hasSupport = true;
  			}
  		}
  
  		if (!hasSupport)
  		{
  			myIsValid = false;
  			aViolationData.hasFloorViolation = true;
  		}
  	}
  }
  

Errors

A rule's evaluation function also takes a FloorViolationData reference as an argument. If a rule finds an error, it will mark the current floor with a floor violation. This violation is later used to determine if an error highlight should be drawn. The highlight is also drawn using my Immediate Geometry Tool.

Floor Violation Drawing - TowerSystems.cpp

View on GitHub >

void DrawFloorViolations()
{
  	const InGeo::Style errorStyle{ ERROR_VISIBLE_COLOR };
  	InGeo::PushStyle(errorStyle);
  
  	for (int i = 0; i < locFloorViolationData.size(); i++)
  	{
  		const FloorViolationData& floorViolationData = locFloorViolationData[i];
  
  		if (!floorViolationData.hasFloorViolation)
  		{
  			continue;
  		}
  
  		const std::string errorId = "FloorError" + std::to_string(i);
  
  		constexpr InGeo::CuboidData xAlignedBlock{ GRID_WIDTH * BUILDING_BLOCK_SIZE + 0.75f, 0.25f, 0.25f };
  		const InGeo::Vector3f bottomPosition{ (GRID_WIDTH * BUILDING_BLOCK_SIZE) * 0.5f, static_cast<float>(i) * BUILDING_BLOCK_HEIGHT, -0.25f };
  		const InGeo::Vector3f topPosition{ (GRID_WIDTH * BUILDING_BLOCK_SIZE) * 0.5f, static_cast<float>(i) * BUILDING_BLOCK_HEIGHT, (GRID_DEPTH * BUILDING_BLOCK_SIZE) + 0.25f };
  		InGeo::CreateGeometry(errorId + "Bottom", xAlignedBlock, bottomPosition);
  		InGeo::CreateGeometry(errorId + "Top", xAlignedBlock, topPosition);
  
  		constexpr InGeo::CuboidData zAlignedBlock{ 0.25f, GRID_DEPTH * BUILDING_BLOCK_SIZE + 0.25f, 0.25f };
  		const InGeo::Vector3f leftPosition{ -0.25f, static_cast<float>(i) * BUILDING_BLOCK_HEIGHT, GRID_DEPTH * BUILDING_BLOCK_SIZE * 0.5f };
  		const InGeo::Vector3f rightPosition{ GRID_WIDTH * BUILDING_BLOCK_SIZE + 0.25f, static_cast<float>(i) * BUILDING_BLOCK_HEIGHT, GRID_DEPTH * BUILDING_BLOCK_SIZE * 0.5f };
  		InGeo::CreateGeometry(errorId + "Left", zAlignedBlock, leftPosition);
  		InGeo::CreateGeometry(errorId + "Right", zAlignedBlock, rightPosition);
  	}
  
  	InGeo::PopStyle();
  }
  

Conclusion

It was fun to push the capabilities of my Immediate Geometry Tool, and this piece did force me to rewrite code to make it more robust and flexible.

I would have liked to also mark the specific blocks or coordinates where an error occured, but with the alloted time I chose to do a simpler implementation