Grid-Based Placement

Convert the freeform placement to a grid-based system with snapping and single-actor cell limits

Ryan McCombe
Published

In the previous lessons, we built the core functionality for dragging, dropping, selecting, and deleting actors in our level editor. Currently, actors can be placed anywhere on the level canvas, overlapping freely. While this works for some game styles, many benefit from more structured placement.

This lesson introduces grid-based positioning. First, we'll implement grid snapping. When dragging or dropping an actor within the level, its position will automatically "snap" to the nearest grid line. This helps designers align objects precisely without tedious manual adjustments.

Then, we'll take it a step further and transform our editor into a true cell-based system. We'll modify the logic so that only one actor can occupy any given grid cell at a time. Dropping an actor onto an already occupied cell will replace the existing actor.

By the end, you'll understand:

  • How to define and configure a grid system.
  • The math behind snapping coordinates to a grid.
  • Modifying tooltip and actor placement logic for snapping.
  • Enforcing single occupancy per grid cell.

Snapping to Grid Positions

Our first step towards a grid-based system is configuring the grid itself. In Config.h, inside the Config::Editor namespace, we'll add constants defining the snapping distances. HORIZONTAL_GRID_SNAP will be 50, and VERTICAL_GRID_SNAP will be 25, reflecting the dimensions of our game's blocks.

We also need to define how many grid cells make up our level. Let's add GRID_WIDTH and GRID_HEIGHT. We'll set them to 13 and 6 for our examples, but our program will scale to any values we prefer here.

We'll use the Uint8 type for these. This is an 8-bit unsigned integer, meaning it can hold values from 0 to 255. Using a fixed-size type like Uint8 is important because we intend to serialize these dimensions to a file later; it ensures the data size is predictable. If we needed larger levels, Uint16 would be the next choice.

We'll also update the level's rendering size - LEVEL_WIDTH and LEVEL_HEIGHT - to be based on these new values. This ensures that the level's rendering area in the editor always matches the space required by the defined grid. Any changes to the grid configuration (number of cells or snap size) will automatically propagate to the level's dimensions.

// Config.h
// ...

#ifdef WITH_EDITOR
namespace Config::Editor {
// Level
inline const int HORIZONTAL_GRID_SNAP{50};
inline const int VERTICAL_GRID_SNAP{25};
inline const Uint8 GRID_WIDTH{13};
inline const Uint8 GRID_HEIGHT{6};
inline const int LEVEL_WIDTH{
  HORIZONTAL_GRID_SNAP * GRID_WIDTH};
inline const int LEVEL_HEIGHT{
  VERTICAL_GRID_SNAP * GRID_HEIGHT};
inline constexpr SDL_Color LEVEL_BACKGROUND{
  50, 50, 50, 255};

// ...
}
// ...
#endif

Now that we have the grid dimensions configured, we need a function within our Level class to perform the actual snapping calculation. This function will take an arbitrary coordinate (x, y) and return the coordinates of the top-left corner of the grid cell that contains it.

Let's declare this function in Editor/Level.h. We'll add a public member function SnapToGridPosition() that accepts two int parameters (x and y) and returns an SDL_Point, which holds the resulting snapped x and y coordinates.

// Editor/Level.h
// ...

namespace Editor {
 // ...

class Level {
 public:
  // ...
  SDL_Point SnapToGridPosition(int x, int y);
  // ...
};
}

Now let's implement SnapToGridPosition() in Editor/Source/Level.cpp.

The logic relies on integer division. When we divide the input coordinate (e.g., x) by the corresponding grid snap size (sx), integer division automatically truncates any remainder, effectively rounding down to the nearest whole number of grid steps.

Multiplying this result back by the snap size (sx) gives us the coordinate of the grid line just below or at the input coordinate. We do this for both x and y using HORIZONTAL_GRID_SNAP and VERTICAL_GRID_SNAP from our configuration, and return the result as an SDL_Point.

// Editor/Source/Level.cpp
// ...

SDL_Point Level::SnapToGridPosition(
  int x, int y
) {
  using namespace Config::Editor;
  int sx{HORIZONTAL_GRID_SNAP};
  int sy{VERTICAL_GRID_SNAP};
  return {
    (x / sx) * sx,
    (y / sy) * sy,
  };
}

Snapping the Tooltip to the Grid

Now that our Level can calculate snapped positions, let's apply this to the ActorTooltip. We want the tooltip to snap to the grid, but only when the mouse cursor is actually hovering over the level area. When the cursor is outside the level (e.g., over the actor menu or elsewhere on the screen entirely), the tooltip should follow the mouse smoothly as before.

We can achieve this conditional behavior in ActorTooltip::PositionWindow(). We already have an if statement checking ParentScene.GetLevel().HasMouseFocus().

The logic for smooth, non-snapped positioning (using the DragOffset) belongs in the else block - the case where the mouse is not over the level.

Let's refactor the code. We'll move the lines that calculate the position using DragOffset and the corresponding SDL_SetWindowPosition() call inside the else block.

// Editor/Source/ActorTooltip.cpp
// ...

void ActorTooltip::PositionWindow() {
  int x, y;
  SDL_GetGlobalMouseState(&x, &y);
  
  auto [DragOffsetX, DragOffsetY]{
    DragActor->GetDragOffset()
  };

  SDL_SetWindowPosition(
    SDLWindow,
    x - DragOffsetX,
    y - DragOffsetY
  );

  if (ParentScene.GetLevel().HasMouseFocus()) {
    SDL_SetWindowOpacity(SDLWindow, 1);
    SDL_SetCursor(SDL_GetDefaultCursor());
  } else {
    auto [DragOffsetX, DragOffsetY]{
      DragActor->GetDragOffset()
    };

    SDL_SetWindowPosition(
      SDLWindow,
      x - DragOffsetX,
      y - DragOffsetY
    );
    SDL_SetWindowOpacity(SDLWindow, 0.5);
    SDL_SetCursor(DenyCursor);
  }
}

Now, let's add the code to the if block for when the tooltip should snap. We face a slight complication: the tooltip's position is set using global screen coordinates - SDL_SetWindowPosition() - but our SnapToGridPosition() function works with coordinates relative to the level area within our main window.

To bridge this, we first need the screen coordinates of our main window's top-left corner. We can get this from the ParentScene, storing the result in WinX and WinY.

Next, we calculate the mouse's position relative to this window by subtracting WinX and WinY from the global mouse coordinates (x, y). We pass these relative coordinates (x - WinX, y - WinY) to SnapToGridPosition().

This gets what the grid-snapped tooltip position would be if our main window was positioned at (0, 0) within the overall screen. We'll store those values at GridX, GridY.

However, our main window is unlikely to be positioned at (0, 0) so, when setting the final position of our tooltip, we need to add the window's actual position back to our final result. So, we call SDL_SetWindowPosition() , passing WinX + GridX and WinY + GridY.

// Editor/Source/ActorTooltip.cpp
// ...

void ActorTooltip::PositionWindow() {
  int x, y;
  SDL_GetGlobalMouseState(&x, &y);

  if (ParentScene.GetLevel().HasMouseFocus()) {
    SDL_SetWindowOpacity(SDLWindow, 1);
    SDL_SetCursor(SDL_GetDefaultCursor());
    auto [WinX, WinY]{
      ParentScene.GetWindow().GetPosition()
    };

    auto [GridX, GridY]{
      ParentScene.GetLevel().SnapToGridPosition(
        x - WinX, y - WinY
      )
    };

    SDL_SetWindowPosition(
      SDLWindow, WinX + GridX, WinY + GridY
    );
  } else {
} }

Don't worry if this transformation between coordinate systems doesn't entirely make sense at this point. We have dedicated lessons on coordinate systems, and transformations between them, coming later in the course.

If we run our program now, we should see our tooltip snapping to grid positions as we drag our actor over the level. However, once we drop the actor, its position in the level is not snapped to that same grid, so let's address that next.

Snapping Actors to the Grid

Our final step for snapping is to ensure the actors themselves land on the grid coordinates when dropped. This requires updating the Level::HandleDrop() function.

We previously used the mouse position and a drag offset to determine the drop location. Now, we want to ignore the drag offset and use only the snapped grid position.

We'll modify HandleDrop() to delete the code that gets DragOffset. After getting the window-relative MouseX and MouseY, we'll call SnapToGridPosition() to calculate GridX and GridY.

We'll then update both SetPosition() calls (inside the if for cloned actors and the else for moved actors) to use GridX, GridY as the target coordinates.

// Editor/Source/Level.cpp
// ...

void Level::HandleDrop(Actor* DragActor) {
  DragActor->SetIsVisible(true);
  if (!HasMouseFocus()) {
    return;
  }

  int MouseX, MouseY;
  SDL_GetMouseState(&MouseX, &MouseY);
  auto [GridX, GridY]{
    SnapToGridPosition(
      MouseX, MouseY
    )
  };

  using enum ActorLocation;
  if (DragActor->GetLocation() == Menu) {
    ActorPtr NewActor{DragActor->Clone()};
    NewActor->SetPosition(GridX, GridY);
    SelectedActor = NewActor.get();
    AddToLevel(std::move(NewActor));
  } else {
    DragActor->SetPosition(GridX, GridY);
    SelectedActor = DragActor;
  }
}

void Level::AddToLevel(ActorPtr NewActor) {
  NewActor->SetLocation(ActorLocation::Level);
  Actors.push_back(std::move(NewActor));
}

bool Level::HasMouseFocus() const {
  if (!ParentScene.HasMouseFocus()) {
    return false;
  }
  int x, y;
  SDL_GetGlobalMouseState(&x, &y);
  auto [WinX, WinY]{
    ParentScene.GetWindow().GetPosition()
  };

  if (
    x >= WinX + Config::Editor::LEVEL_WIDTH ||
    y >= WinY + Config::Editor::LEVEL_HEIGHT
  ) {
    return false;
  }
  return true;
}

SDL_Point Level::SnapToGridPosition(
  int x, int y
) {
  using namespace Config::Editor;
  int sx{HORIZONTAL_GRID_SNAP};
  int sy{VERTICAL_GRID_SNAP};
  return {
    (x / sx) * sx,
    (y / sy) * sy,
  };
}

Running our program, we should now see we can easily position our actors in an organized grid:

One Actor Per Grid Position

Our editor now snaps actors to a grid, but it still allows multiple actors to occupy the same grid cell. For many tile or grid-based games, this is undesirable; each cell should hold at most one actor. We need to enforce this constraint.

The desired behavior is: when an actor is dropped onto a grid cell, any actor already present in that cell should be removed first. However, if the user simply clicks an actor and releases (effectively dropping it back into its original cell), we shouldn't delete and replace it.

To implement this, we'll add a helper function to the Level class specifically for deleting an actor at a given grid position, with an exception for the actor being dropped.

Let's declare DeleteAtPosition() in the public section of Editor/Level.h. The Unless parameter will hold a pointer to the actor currently being dragged/dropped, preventing it from deleting itself if dropped in its own previous location.

// Editor/Level.h
// ...

namespace Editor {
// ...

class Level {
 public:
  // ...
  void DeleteAtPosition(
    int x, int y, const Actor* Unless);
  // ...
};
}

Now, let's implement DeleteAtPosition() in Editor/Source/Level.cpp. We need to search through our Actors vector to find if any actor's position matches the provided x and y.

We can use a traditional for loop with an index. Inside the loop, we'll get the position (ax, ay) of the current actor Actors[i]. We'll check if ax == x and ay == y. We'll check if the raw pointer Actors[i].get() is not equal to the Unless pointer.

If all conditions are true, we've found a different actor at the target location that needs to be removed. We use Actors.erase() to remove it and immediately break from the loop, as there can be at most one actor per position now.

// Editor/Source/Level.cpp
// ...

void Level::DeleteAtPosition(
  int x, int y, const Actor* Unless
) {
  for (size_t i{0}; i < Actors.size(); ++i) {
    auto [ax, ay]{Actors[i]->GetPosition()};
    if (ax == x && ay == y
        && Actors[i].get() != Unless
    ) {
      Actors.erase(Actors.begin() + i);
      break;
    }
  }
}

Finally, we integrate the deletion logic into the drop handling process. In Level::HandleDrop(), right after calculating the target grid coordinates GridX, GridY, we need to call our new deletion function.

We'll add our call to DeleteAtPosition() to ensure that, before we either clone DragActor to (GridX, GridY) or move DragActor to (GridX, GridY), any other actor currently occupying that cell is removed.

The DragActor pointer is passed as the Unless argument, preventing self-deletion when moving an actor back to its original cell or just clicking it.

// Editor/Source/Level.cpp
// ...

void Level::HandleDrop(Actor* DragActor) {
  DragActor->SetIsVisible(true);
  if (!HasMouseFocus()) {
    return;
  }

  int MouseX, MouseY;
  SDL_GetMouseState(&MouseX, &MouseY);
  auto [GridX, GridY]{
    SnapToGridPosition(
      MouseX,
      MouseY
    )
  };
  DeleteAtPosition(GridX, GridY, DragActor);
  
}

If we compile and run our program, we should now verify that, when we drop an actor into a grid position, any other actor that was previously in that grid position gets replaced.

Complete Code

Our updated Level class and Config.h file are provided below:

We also updated the PositionWindow() function of our ActorTooltip:

Summary

This lesson transitioned our level editor from freeform placement to a structured grid-based system. We implemented grid snapping for precise alignment and enforced a rule allowing only one actor per grid cell, replacing any existing actor upon dropping a new one.

Key steps:

  • Defined grid parameters in configuration.
  • Used fixed-width integers (Uint8) for grid dimensions to prepare for serialization.
  • Implemented a SnapToGridPosition() function using integer division math: (coord/snap)×snap(\text{coord} / \text{snap}) \times \text{snap}.
  • Updated ActorTooltip::PositionWindow() to handle coordinate space conversions (global mouse -> window relative -> snapped -> window relative -> screen) for snapped tooltip positioning over the level.
  • Modified Level::HandleDrop() to use snapped coordinates and ignore the drag offset.
  • Added Level::DeleteAtPosition() to remove existing actors from a target cell before placing a new one.
  • Integrated the deletion call into HandleDrop() to enforce single occupancy per cell.
Next Lesson
Lesson 94 of 129

Saving Your Editor Levels

Implement footer buttons and binary serialization to save and load your custom game levels to disk.

Have a question about this lesson?
Purchase the course to ask your own questions