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:
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,
};
}
The formula (x / sx) * sx
works because of how integer division behaves in C++ (and many other languages). When you divide two integers, the result is also an integer, and any fractional part is simply discarded (truncated towards zero).
For example, let’s imagine x
is 20
and our horizontal snapping value sx
is 50
. In that scenario, (x / sx) * sx
becomes (20 / 50) * 50)
. (20 / 50)
returns 0
, and 0 * 50
returns 0
.
So, if our original x
value was 20
, then the x
returned from SnapToGridPosition()
will be 0
. Here are some more examples, so we can see the grid snapping behaviour where values are returned in multiples of 50:
x
is 49
, then 49 / 50
is 0
, and 0 * 50
returns 0
x
is 51
, then 51 / 50
is 1
, and 1 * 50
returns 50
x
is 75
, then 75 / 50
is 1
, and 1 * 50
returns 50
x
is 124
, then 124 / 50
is 2
, and 2 * 50
returns 100
As you can see, this process finds the largest multiple of the snap size (sx
) that is less than or equal to the input coordinate (x
).
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.
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:
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;
}
}
}
std::erase_if()
and LambdasSolving this problem using the std::erase_if()
and lambda approach we introduced in the previous lesson would look like this:
// Editor/Source/Level.cpp
// ...
void Level::DeleteAtPosition(
int x, int y, const Actor* Unless
) {
std::erase_if(Actors,
[&](const ActorPtr& Actor){
auto [ax, ay]{Actor->GetPosition()};
return
ax == x &&
ay == y &&
Actor.get() != Unless;
}
);
}
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.
Our updated Level
class and Config.h
file are provided below:
#pragma once
#include <iostream>
#include <SDL.h>
#include <string>
#include <vector>
namespace UserEvents{
#ifdef WITH_EDITOR
inline Uint32 ACTOR_DRAG{
SDL_RegisterEvents(1)};
#endif
}
namespace Config {
inline const std::vector BUTTON_COLORS{
SDL_Color{15, 15, 15, 255}, // Normal
SDL_Color{15, 155, 15, 255}, // Hover
SDL_Color{225, 15, 15, 255}, // Active
SDL_Color{60, 60, 60, 255} // Disabled
};
inline constexpr SDL_Color FONT_COLOR{
255, 255, 255, 255};
inline const std::string FONT{
"Assets/Rubik-SemiBold.ttf"};
}
#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};
// ActorMenu
inline const int ACTOR_MENU_WIDTH{70};
inline const int ACTOR_MENU_POSITION_X{LEVEL_WIDTH};
inline const SDL_Color ACTOR_MENU_BACKGROUND{
15, 15, 15, 255};
inline const int PADDING{10};
// Window
inline const std::string WINDOW_TITLE{"Editor"};
inline const int WINDOW_WIDTH{
LEVEL_WIDTH + ACTOR_MENU_WIDTH};
inline const int WINDOW_HEIGHT{LEVEL_HEIGHT + 50};
inline const SDL_Color WINDOW_BACKGROUND{
35, 35, 35, 255};
}
#endif
inline void CheckSDLError(
const std::string& Msg) {
#ifdef CHECK_ERRORS
const char* error = SDL_GetError();
if (*error != '\0') {
std::cerr << Msg << " Error: "
<< error << '\n';
SDL_ClearError();
}
#endif
}
#pragma once
#include <SDL.h>
#include <memory>
#include <vector>
#include "Actor.h"
namespace Editor {
class Scene;
using ActorPtr = std::unique_ptr<Actor>;
using ActorPtrs = std::vector<ActorPtr>;
class Level {
public:
Level(Scene& ParentScene)
: ParentScene{ParentScene} {}
void HandleEvent(const SDL_Event& E);
void HandleDrop(Actor* DragActor);
void Tick(float DeltaTime);
void Render(SDL_Surface* Surface);
bool HasMouseFocus() const;
void AddToLevel(ActorPtr NewActor);
SDL_Point SnapToGridPosition(int x, int y);
void DeleteAtPosition(
int x, int y, const Actor* Unless);
private:
Scene& ParentScene;
ActorPtrs Actors;
Actor* SelectedActor{nullptr};
SDL_Rect Rect{
0, 0,
Config::Editor::LEVEL_WIDTH,
Config::Editor::LEVEL_HEIGHT
};
};
}
#include <ranges>
#include "Editor/Level.h"
#include "Editor/Scene.h"
using namespace Editor;
void Level::HandleEvent(const SDL_Event& E) {
using namespace std::views;
for (ActorPtr& A : reverse(Actors)) {
if (A->HandleEvent(E)) {
break;
}
}
if (
E.type == SDL_MOUSEBUTTONDOWN &&
E.button.button == SDL_BUTTON_LEFT
) {
SelectedActor = nullptr;
} else if (
E.type == SDL_KEYDOWN &&
E.key.keysym.sym == SDLK_DELETE &&
SelectedActor
) {
std::erase_if(Actors,
[&](ActorPtr& Actor){
return Actor.get() == SelectedActor;
}
);
SelectedActor = nullptr;
}
}
void Level::Tick(float DeltaTime) {
for (ActorPtr& A : Actors) {
A->Tick(DeltaTime);
}
}
void Level::Render(SDL_Surface* Surface) {
auto [r, g, b, a]{
Config::Editor::LEVEL_BACKGROUND
};
SDL_FillRect(Surface, &Rect, SDL_MapRGB(
Surface->format, r, g, b));
for (ActorPtr& A : Actors) {
if (SelectedActor &&
A.get() == SelectedActor &&
SelectedActor->GetIsVisible()
) {
auto [x, y, w, h]{
SelectedActor->GetRect()
};
SDL_Rect Rect{x - 1, y - 1, w + 2, h + 2};
SDL_FillRect(Surface, &Rect, SDL_MapRGB(
Surface->format, 255, 255, 255)
);
}
A->Render(Surface);
}
}
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);
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,
};
}
void Level::DeleteAtPosition(
int x, int y, const Actor* Unless
) {
std::erase_if(Actors,
[&](const ActorPtr& Actor){
auto [ax, ay]{Actor->GetPosition()};
return
ax == x &&
ay == y &&
Actor.get() != Unless;
}
);
}
We also updated the PositionWindow()
function of our ActorTooltip
:
#include "Editor/ActorTooltip.h"
#include "Editor/Scene.h"
using namespace Editor;
ActorTooltip::ActorTooltip(Scene& ParentScene)
: ParentScene{ParentScene} {
SDLWindow = SDL_CreateWindow(
"Tooltip",
SDL_WINDOWPOS_UNDEFINED,
SDL_WINDOWPOS_UNDEFINED,
100,
100,
SDL_WINDOW_HIDDEN
| SDL_WINDOW_TOOLTIP
| SDL_WINDOW_BORDERLESS
| SDL_WINDOW_SKIP_TASKBAR
| SDL_WINDOW_ALWAYS_ON_TOP
);
CheckSDLError("Creating Tooltip Window");
DenyCursor = SDL_CreateSystemCursor(
SDL_SYSTEM_CURSOR_NO
);
CheckSDLError("Creating DenyCursor");
}
ActorTooltip::~ActorTooltip() {
if (!SDL_WasInit(SDL_INIT_VIDEO)) return;
if (SDLWindow) {
SDL_DestroyWindow(SDLWindow);
}
if (DenyCursor) {
SDL_FreeCursor(DenyCursor);
}
}
void ActorTooltip::Render() {
if (!isVisible) return;
DragActor->GetArt().Render(
GetSurface(),
SDL_Rect{
0, 0,
DragActor->GetRect().w,
DragActor->GetRect().h
});
SDL_UpdateWindowSurface(SDLWindow);
}
void ActorTooltip::Tick(float DeltaTime) {
if (!isVisible) return;
auto Buttons{
SDL_GetGlobalMouseState(
nullptr, nullptr)};
if (!(Buttons & SDL_BUTTON_LEFT)) {
SetIsVisible(false);
ParentScene.GetLevel().HandleDrop(DragActor);
} else {
PositionWindow();
}
}
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 {
auto [DragOffsetX, DragOffsetY]{
DragActor->GetDragOffset()
};
SDL_SetWindowPosition(
SDLWindow, x - DragOffsetX, y - DragOffsetY
);
SDL_SetWindowOpacity(SDLWindow, 0.5);
SDL_SetCursor(DenyCursor);
}
}
void ActorTooltip::HandleEvent(
const SDL_Event& E) {
using namespace UserEvents;
if (E.type == ACTOR_DRAG) {
DragActor = static_cast<Actor*>(
E.user.data1
);
SDL_SetWindowSize(
SDLWindow,
DragActor->GetRect().w,
DragActor->GetRect().h
);
SetIsVisible(true);
}
}
void ActorTooltip::SetIsVisible(bool Visible) {
isVisible = Visible;
if (isVisible) {
SDL_ShowWindow(SDLWindow);
} else {
SDL_HideWindow(SDLWindow);
SDL_SetCursor(SDL_GetDefaultCursor());
SDL_SetWindowOpacity(SDLWindow, 1);
}
}
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:
Uint8
) for grid dimensions to prepare for serialization.SnapToGridPosition()
function using integer division math: .ActorTooltip::PositionWindow()
to handle coordinate space conversions (global mouse -> window relative -> snapped -> window relative -> screen) for snapped tooltip positioning over the level.Level::HandleDrop()
to use snapped coordinates and ignore the drag offset.Level::DeleteAtPosition()
to remove existing actors from a target cell before placing a new one.HandleDrop()
to enforce single occupancy per cell.Convert the freeform placement to a grid-based system with snapping and single-actor cell limits
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games