Chapter 15  ·  Project

Loot Grid

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:

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.

Loot Grid at startup: green player, 20 randomly-placed colored items on a 16x12 grid
Loot Grid at startup: green player, 20 randomly-placed colored items on a 16×12 grid.

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.

Loot Grid mid-game: half the items collected, player navigating to the next
Loot Grid mid-game: half the items collected, player navigating to the next.

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

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.