Tower builder & validator

Info

  • Engine: Custom C++ Engine (Spare Parts)
  • Language: C++
  • Tools: ImGui, Immediate mode geometry tool
  • Time frame: 3 weeks
  • Team Size: Solo

Overview

I wanted to test the usefulness of my immediate mode geometry tool in a real gameplay setting while also creating a grid-based building game.

The game challenges players to build a tower floor by floor using different block types. Each completed floor unlocks the next and introduces new building rules, forcing players to adapt and sometimes rebuild earlier floors.

To support this, I built systems for building, rule validation, and error highlighting in our custom ECS-based engine.

Key achievements

  • ● Built an interactive tower-building system using my Immediate mode geometry tool integrated with ECS.
  • ● Developed a rule validation system for floor-based building constraints.
  • ● Optimized tower validation using metadata to reduce unnecessary iteration and recalculation.
  • ● Built a modular rule system for easily adding new building constraints.
  • ● Implemented error highlighting and floor visibility tools to improve usability.

Building

I used my immediate mode geometry tool to generate an interactive grid for each floor. Clicking on a grid cell updates the floor's block data, which then spawns or updates the corresponding ECS components in the world.

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();
  }
    

Players can choose between different block types through an ImGui menu, with each block mapped to a specific object definition.

Placed blocks are also fully interactable. For every block on the active floor, I generate interactive cuboid geometry that enables rotation, replacement, and deletion.

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();
  

To improve usability when rebuilding lower floors, I also implemented floor visibility controls so players could temporarily hide floors above their current working level.

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

A core challenge of the project was efficiently validating tower rules as the structure changed. Some rules only affect a single floor, while others depend on relationships between multiple floors. This meant that changing one floor could affect the validity of floors above and below it.

To avoid repeatedly iterating over every tile for every rule, I store floor metadata that is recalculated only when a floor changes. This metadata stores useful information such as block coordinates by type, connected room regions, and walkability data. This allowed rule checks to query precomputed data instead of reprocessing the full floor every time.

Metadata Calculation - TowerHelpers.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);
  		}
  	}
  }
  

Validation only runs when the tower changes. Each rule writes its validation result to a shared floor violation data structure, which is then used to determine whether error highlights should be rendered.

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
  	}
  }
  
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;
  		}
  	}
  }
  

Error visualization

To make rule violations easy to understand, I implemented an error highlighting system inspired by the red squiggly line commonly seen in IDEs.

Each floor stores its own error state, which is then used to generate error geometry through my immediate mode geometry tool. This gives players clear feedback on which floors need to be rebuilt while keeping the error system lightweight and easy to extend.

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();
  }
  

Take aways

This project was a great way to test and improve my immediate mode geometry tool. It exposed flaws in the interaction system that helped me refine the tool further.

One feature I wanted to implement was error highlights at specific coordinates. While I made it functional, the visual result was not as clear as I wanted, so I chose not to include it in the final version.