Italian Brainrot Survivors | Play Game


Overview

This 2D survival action game was built using the self-made game framework in collaboration with a game artist, featuring a Vampire Survivors style clone with Italian Brainrot memes and serving as an experiment to evaluate the framework’s scalability and reusability.

View Repository
Watch Demo


Game Showcase

Players must survive endless waves of enemies, with a final boss appearing after five minutes. Defeating enemies grants experience to upgrade weapons and power-ups, helping to maximize survival. Power-ups can also be purchased before the game starts to boost early survival.


Core Features

⚠️ Note: All code snippets are simplified versions. Full implementation in the repository

1. CSV-Based Data Management

All game-related data (mobs, weapons, power-ups, etc.) are managed via CSV files, making it easy to balance and expand content by editing the CSV files without modifying the code.

Upon program startup, a CDataLoader instance is created and initialized in CEngine::Init(), loading all CSV data files and storing the datasets.

void CDataLoader::LoadAllMobData()
{
	std::string filePath = CPathManager::GetInst()->FindPath(DATA_PATH);
	filePath += "GameData\\Mob_Data.csv";
	
	std::ifstream file(filePath);
	if (!file.is_open())
	{
		std::cerr << "Cannot open file at: " << filePath << "\n";
		return;
	}
	
	std::string line;
	while (std::getline(file, line))
	{
		if (line[0] == '#')
			continue;
			
		std::vector<std::string> row = Split(line, ',');
		
		FRegularMobData data;
		data.type = static_cast<ERegularMobType>(std::stoi(row[0]));
		data.baseHP     = std::stof(row[1]);
		data.baseAttack = std::stof(row[2]);
		data.baseSpeed  = std::stof(row[3]);
		data.baseExp    = std::stof(row[4]);
		
		CGameDataManager::GetInst()->GetMobDataManager()->AddMobData(data);
		
		row.clear();
	}
	file.close();
}
Through this approach, developers can flexibly adjust game balance using only data, without modifying the code. This structure can be considered a core aspect of data-driven design, taking both maintainability and scalability into account.


2. Scrollable Environment System

The map scrolls to appear endless with just a single texture as the camera moves. Objects are repositioned only when they exceed the snap distance, keeping the map and environment naturally synchronized and efficiently creating the scrolling effect.


The image uses four combined textures for illustration, though the actual implementation relies on a single texture. It visually illustrates the concepts of consistent spacing between environment objects, map unit placement, and snap distance for position correction.


CScrollMapComponent Class

The map does not necessarily have to scroll, it was implemented as a component. Therefore, the map used in the game is implemented by adding the CScrollMapComponent.

void CScrollMapComponent::Render(SDL_Renderer* renderer)
{
	SDL_Rect viewRect = mCamera->GetViewRect();
	viewRect.x = (int)roundf(viewRect.x);
	viewRect.y = (int)roundf(viewRect.y);
	
	const SDL_Rect& texRect = mTexture->GetTextureFrame();
	const FVector2D& mapScale = mTransform->GetWorldScale();
	
	// Camera offset within the texture
	int offsetX = viewRect.x % (int)mapScale.x;
	int offsetY = viewRect.y % (int)mapScale.y;
	if (offsetX < 0) offsetX += (int)mapScale.x;
	if (offsetY < 0) offsetY += (int)mapScale.y;
	
	// Texture coordinate ratio relative to world scale
	float texW = texRect.w / mapScale.x;
	float texH = texRect.h / mapScale.y;
	
	// Check if the camera view exceeds the texture boundary
	bool overX = offsetX + viewRect.w > (int)mapScale.x;
	bool overY = offsetY + viewRect.h > (int)mapScale.y;
	
	// Calculate inner and overflow regions
	int innerW = overX ? (int)mapScale.x - offsetX : viewRect.w;
	int innerH = overY ? (int)mapScale.y - offsetY : viewRect.h;
	int outerW = viewRect.w - innerW;
	int outerH = viewRect.h - innerH;
	
	SDL_Rect src, dst;
	
	// Top-left 
	src.x = (int)roundf(offsetX * texW);
	src.y = (int)roundf(offsetY * texH);
	src.w = (int)roundf(innerW * texW);
	src.h = (int)roundf(innerH * texH);
	dst = { 0, 0, innerW, innerH };
	SDL_RenderCopy(renderer, mTexture->GetTexture(), &src, &dst);
	// Top-right
	if (outerW > 0)
	{
		src.x = 0;
		src.y = (int)roundf(offsetY * texH);
		src.w = (int)roundf(outerW * texW);
		src.h = (int)roundf(innerH * texH);
		dst = { innerW, 0, outerW, innerH };
		SDL_RenderCopy(renderer, mTexture->GetTexture(), &src, &dst);
	}
	// Bottom-left
	if (outerH > 0)
	{
		src.x = (int)roundf(offsetX * texW);
		src.y = 0;
		src.w = (int)roundf(innerW * texW);
		src.h = (int)roundf(outerH * texH);
		dst = { 0, innerH, innerW, outerH };
		SDL_RenderCopy(renderer, mTexture->GetTexture(), &src, &dst);
	}
	// Bottom-right
	if (outerW > 0 && outerH > 0)
	{
		src.x = 0;
		src.y = 0;
		src.w = (int)roundf(outerW * texW);
		src.h = (int)roundf(outerH * texH);
		dst = { innerW, innerH, outerW, outerH };
		SDL_RenderCopy(renderer, mTexture->GetTexture(), &src, &dst);
	}
	
	CComponent::Render(renderer);
}
The map works by rendering only a portion of a single texture based on the camera’s position. When the camera is within the texture, only that area is displayed. If it crosses the boundary, the texture is split into two or four parts depending on the direction, creating a scrolling map effect that appears endless with a single texture.


CScrollEnvObj Class

This is a class for environment objects in the scroll map. Initially considered separating this logic into a component, but since multiple components like sprites and colliders move together, handling it at the object level was deemed simpler and clearer.

void CScrollEnvObj::Update(float deltaTime)
{
    CObject::Update(deltaTime);
	
    const FVector2D& cameraPos = mCamera->GetLookAt();
    const FVector2D& objPos = GetTransform()->GetWorldPos();
    FVector2D delta = cameraPos - objPos;
	
    float snapDistance = delta.Length();
    if (snapDistance >= mSnapThreshold)
    {
        int directionX = (int)roundf(delta.x / mMapScale.x);
        int directionY = (int)roundf(delta.y / mMapScale.y);
		
        float offsetX = mMapScale.x * directionX;
        float offsetY = mMapScale.y * directionY;
		
        GetTransform()->SetWorldPos(objPos + FVector2D(offsetX, offsetY));
    }
}
CScrollEnvObj references the camera, and when an environment object exceeds the snap distance, it calculates the relative direction, normalizes it in x and y, multiplies by the map scale, and efficiently updates the position in a single move.


3. Mob Spawning and Respawning System

The CMobSpawner class randomly spawns mobs within a set range around the camera. The area is divided into four zones, one of which is selected for a random spawn, ensuring enemies appear around the player with equal probability.


Mobs that move beyond a certain distance from the player are respawned, ensuring enemies always surround the player, maintaining consistent tension.


CMobSpawner Class - Key Methods

Method #1: Mob Spawner Update Function

void CMobSpawner::Update(float deltaTime)
{
	if (!mScene->GetPlayer())
		return;
 
	mRegularSpawnTime -= deltaTime;
	mSubBossSpawnTime -= deltaTime;
 
	SpawnMob();
	RespawnMob();
}
Called every frame to manage mob spawning and respawning, it gradually reduces the time interval and executes SpawnMob() and RespawnMob() when conditions are met. If the player doesn’t exist, it skips unnecessary calculations and exits immediately.


Method #2: Mob Spawn Function

void CMobSpawner::SpawnMob()
{
	// SPAWN REGULAR MOB
	if (mRegularSpawnTime <= 0.0f)
	{
		mRegularSpawnTime = CONST_REGULAR_MOB_SPAWN_INTERVAL;
		CEnemy* mob = SpawnRegularMob(mUnlockedRegIdx);
		mob->GetTransform()->SetWorldPos(GetRandomSpawnPos(1.1f));
 
		// INDEX CONTROL
		mRegSpawnAmount--;
		if (mRegSpawnAmount <= 0)
		{
			mRegSpawnAmount = CONST_REGULAR_MOB_SPAWN_AMOUNT;
			mUnlockedRegIdx = std::min(mUnlockedRegIdx + 1, (int)ERegularMobType::MAX - 1);
		}
	}
 
	// SPAWN SUB BOSS
	if (mSubBossSpawnTime <= 0.0f)
	{
		mSubBossSpawnTime = CONST_SUBBOSS_MOB_SPAWN_INTERVAL;
		CEnemy* mob = SpawnSubBossMob(mUnlockedBosIdx);
		mob->GetTransform()->SetWorldPos(GetRandomSpawnPos(1.1f));
 
		// INDEX CONTROL
		mUnlockedBosIdx = std::min(mUnlockedBosIdx + 1, (int)ESubBossMobType::MAX - 1);
	}
}
Spawns regular mobs and sub bosses at regular intervals. Mobs appear at random positions around the camera calculated by GetRandomSpawnPos(). Over time new mob types are unlocked, gradually introducing stronger enemies.


Method #3: Mob Respawn Function

void CMobSpawner::RespawnMob()
{
	for (size_t i = mSpawnedMobs.size(); i > 0; i--)
	{
		CEnemy* mob = mSpawnedMobs[i - 1];
		
		if (!mob->GetActive())
		{
			std::swap(mSpawnedMobs[i - 1], mSpawnedMobs.back());
			mSpawnedMobs.pop_back();
			
			continue;
		}
		
		FVector2D delta = mob->GetTransform()->GetWorldPos() - mScene->GetPlayer()->GetTransform()->GetWorldPos();
		if (delta.Length() >= mDespawnThreshold)
		{
			mob->GetTransform()->SetWorldPos(GetRandomSpawnPos(1.1f));
		}
	}
}
Mobs that are off-screen and beyond a certain distance are respawned at positions calculated by GetRandomSpawnPos(), ensuring enemies are always present around the player.


4. Player: Correlation of Stats, Inventory, and Weapons

The player essentially has a Stats Component and an Inventory Component.

  • Stats Component:
    • Manages the player’s base stats and stats gained from items.
  • Inventory Component:
    • Manages the various weapon components the player owns.
  • Weapon Component:
    • Creates the projectiles fired by the weapon and sets their initial state.

Projectile Firing and Damage Calculation Process

0. Correlation Diagram

A concise diagram showing the correlation between the player and weapons. When attacking, the weapon creates a projectile and applies total damage from both the player’s and weapon’s attack damage.


1. Projectile Creation and Damage Setup

void CPistolComponent::Shoot()
{
	CSoundManager::GetInst()->GetSound<CSFX>("SFX_Bullet")->Play();
	
	CBullet* bullet = InstantiateObject<CBullet>("Bullet");
	bullet->SetDamage(mOwner->GetAttack() + mPistolAttack);
}
When attacking with the weapon, it first plays a sound effect, then creates and initializes a projectile. Applying the combined damage of the owner and the weapon power to the projectile. This ensures fired projectiles aren’t affected by later power-up changes.


2. Player Attack Damage Calculation

float CPlayer::GetAttack() const
{
	int mightLevel = mStatus->GetMenuPowerUpLvl(EPowerUpType::MIGHT)
		+ mInventory->GetPowerUpLevel(EPowerUpType::MIGHT);
	float mightBonus = mStatus->GetStatModifier(EPowerUpType::MIGHT);
	float mightAttack = mStatus->GetBaseAttack() * mightLevel * mightBonus;
	
	return mStatus->GetBaseAttack() + mightAttack;
}
The player’s attack damage is calculated by summing item stat bonuses with the base attack. The advantage of this approach is that debugging and maintenance are easier, and new weapons or stat systems can be added with minimal changes to existing code.


Conclusion

While developing a game using the self-made game framework, unexpected issues were encountered and addressed, allowing the framework to develop a more stable and reliable structure. This project was meaningful, as it involved improving the my game framework while creating a game with it.