We've spent the last chapter learning the parts of the standard library that let us look things up by name instead of by position. This project is the place to feel why they earn their keep. We're going to build a small grid-based collecting game where almost every interesting piece of state is held in a map of some kind.
The game is simple. A 16-by-12 grid. A green square in the middle is the player, moving one cell at a time with WASD. Scattered around the world are pieces of loot — coins, gems, keys, potions, hearts — each its own color. Walk onto a cell with loot in it and you pick it up. Press TAB and the contents of your inventory print to the Output window. Press R and a fresh wave of loot scatters across the grid.
In this chapter, we will:
- Use a
std::map<std::pair<int,int>, ItemType>to store the world's loot sparsely - Use a
std::unordered_map<ItemType, SDL_Color>to look up rendering colors - Use a
std::map<ItemType, int>for the player's inventory - Use an
enum classto name our item types safely - Use structured bindings to unpack (key, value) pairs in a
forloop - Try an optional AI exercise
Includes and Configuration
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <cstdlib>
#include <ctime>
#include <map>
#include <unordered_map>
#include <utility> // std::pair
#include <vector>
const int CELL_SIZE = 50;
const int GRID_COLS = 16;
const int GRID_ROWS = 12;
const int SCREEN_W = CELL_SIZE * GRID_COLS; // 800
const int SCREEN_H = CELL_SIZE * GRID_ROWS; // 600
const int ITEM_COUNT = 20;
Naming the Item Types
enum class ItemType
{
Coin,
Gem,
Key,
Potion,
Heart
};
enum class — the scoped enum. A plain enum would let us write Coin anywhere as a free-floating constant; enum class requires ItemType::Coin, which is wordier but vastly safer. We can't accidentally compare an ItemType against a raw int.
Two Small Helpers
const char* itemName(ItemType t)
{
switch (t)
{
case ItemType::Coin: return "Coin";
case ItemType::Gem: return "Gem";
case ItemType::Key: return "Key";
case ItemType::Potion: return "Potion";
case ItemType::Heart: return "Heart";
}
return "Unknown";
}
using Cell = std::pair<int, int>;
The using Cell = std::pair<int, int>; is a type alias. From now on, Cell means std::pair<int, int>. Writing Cell everywhere reads much better.
The Three Maps
std::unordered_map<ItemType, SDL_Color> itemColors = {
{ ItemType::Coin, {230, 200, 50, 255} }, // gold
{ ItemType::Gem, { 80, 220, 220, 255} }, // cyan
{ ItemType::Key, {220, 220, 220, 255} }, // silver
{ ItemType::Potion, {180, 80, 220, 255} }, // purple
{ ItemType::Heart, {230, 70, 90, 255} }, // red
};
std::map<Cell, ItemType> world;
std::map<ItemType, int> inventory;
Cell player;
itemColors is an unordered_map because we only ever look colors up by type and don't care about iteration order. world is an ordered map because predictable iteration order is pleasant when debugging. inventory tracks the player's loot — key is item type, value is count.
Seeding the World
void seedWorld(std::map<Cell, ItemType>& world, const Cell& playerCell)
{
world.clear();
int placed = 0;
while (placed < ITEM_COUNT)
{
Cell c { rand() % GRID_COLS, rand() % GRID_ROWS };
if (c == playerCell) continue;
if (world.find(c) != world.end()) continue; // already taken
world[c] = randomItem();
++placed;
}
}
world.find(c) returns an iterator pointing at the matching entry if found, or world.end() if not. Checking != world.end() means "yes, something's there." world[c] = randomItem(); inserts the item — the square-bracket operator creates the key if it doesn't exist.
The Move and Pickup
Cell next = player;
switch (event.key.scancode)
{
case SDL_SCANCODE_W: next.second -= 1; break;
case SDL_SCANCODE_S: next.second += 1; break;
case SDL_SCANCODE_A: next.first -= 1; break;
case SDL_SCANCODE_D: next.first += 1; break;
// ...
}
// Clamp to grid bounds
if (next.first < 0) next.first = 0;
if (next.first >= GRID_COLS) next.first = GRID_COLS - 1;
if (next.second < 0) next.second = 0;
if (next.second >= GRID_ROWS) next.second = GRID_ROWS - 1;
player = next;
// Pickup check
auto it = world.find(player);
if (it != world.end())
{
ItemType picked = it->second;
inventory[picked] += 1;
world.erase(it);
SDL_Log("Picked up a %s (have %d)",
itemName(picked), inventory[picked]);
if (world.empty())
SDL_Log("All loot collected! Press R for a new world.");
}
After every move, we ask the world map whether there's anything at the player's new cell. If there is: it->second gives us the item type, inventory[picked] += 1 safely increments the count (the [] operator creates the entry with value 0 if it doesn't exist yet), and world.erase(it) removes it.
TAB — Print the Inventory
case SDL_SCANCODE_TAB:
SDL_Log("--- Inventory ---");
if (inventory.empty())
{
SDL_Log("(nothing yet)");
}
else
{
for (const auto& [type, count] : inventory)
SDL_Log(" %-7s x %d", itemName(type), count);
}
SDL_Log("Items remaining in world: %d", (int)world.size());
break;
The structured binding [type, count] unpacks each map entry cleanly. The const auto& avoids copying.
Rendering
// Draw grid lines
SDL_SetRenderDrawColor(renderer, 45, 45, 55, 255);
for (int c = 0; c <= GRID_COLS; ++c)
SDL_RenderLine(renderer,
(float)(c * CELL_SIZE), 0.0f,
(float)(c * CELL_SIZE), (float)SCREEN_H);
for (int r = 0; r <= GRID_ROWS; ++r)
SDL_RenderLine(renderer,
0.0f, (float)(r * CELL_SIZE),
(float)SCREEN_W, (float)(r * CELL_SIZE));
// Draw each item in the world
for (const auto& [cell, type] : world)
{
SDL_Color col = itemColors[type];
SDL_SetRenderDrawColor(renderer, col.r, col.g, col.b, col.a);
SDL_FRect r = {
(float)(cell.first * CELL_SIZE + 12),
(float)(cell.second * CELL_SIZE + 12),
(float)(CELL_SIZE - 24),
(float)(CELL_SIZE - 24)
};
SDL_RenderFillRect(renderer, &r);
}
// Draw the player
SDL_SetRenderDrawColor(renderer, 80, 220, 100, 255);
SDL_FRect pRect = {
(float)(player.first * CELL_SIZE + 6),
(float)(player.second * CELL_SIZE + 6),
(float)(CELL_SIZE - 12),
(float)(CELL_SIZE - 12)
};
SDL_RenderFillRect(renderer, &pRect);
SDL_RenderPresent(renderer);
Playing the Game
Hit F5. The grid appears, the green player sits in the middle, and twenty colored items dot the cells around it.
Use WASD to move. When you walk onto an item's cell, the item disappears and a message prints to Visual Studio's Output window. Press TAB to dump your full inventory. Press R to scatter a fresh world. Press Escape to quit.
Understanding the Code
Scroll back through the file and notice how much of the program is map operations. The world is a map. The inventory is a map. The color lookup is an unordered_map. The pickup is find + [] + erase. The inventory dump is for over a map with structured bindings. Even the seeding is "keep generating random cells, reject them if they're already keys, then []-assign."
This is what maps are for. Any time you have data where the natural way to talk about it is "the X for the Y" — the color for the item type, the count for the player's gem stash, the contents of cell (3, 4) — a map is the right shape of container.
Experimenting
- More items. Bump
ITEM_COUNTto 60 and watch the world get crowded. - A sixth item. Add
ItemType::Scrollto the enum, updateitemName, add a color toitemColors, and adjustrandomItemto pick from six. The map-based design makes this a five-line change. - Bigger grid. Try
CELL_SIZE = 40andGRID_COLS = 20, GRID_ROWS = 15.
Summary
You've built a small game whose data model is almost entirely maps. This chapter closes out the collections arc. You now know how to declare variables, branch on conditions, repeat work with loops, organize code into functions, manage memory by hand with pointers, and store data in arrays, vectors, and maps. That's the procedural half of C++ in full. Starting in the next chapter, we'll learn how to organize all of that into proper object-oriented code — classes, encapsulation, inheritance, polymorphism, interfaces.