Large games tend to be complex projects, developed over many years. Because of this, developers often spend as much time building the tools that help build the game as they do building anything that players will directly see.
In this project, we apply the techniques we learned in the previous few chapters around window and mouse management and serialization and deserialization to create a basic level editor tool
On the right of our tool, we’ll have a menu where users have a list of objects - or "actors" - they can choose from. They can drag and drop actors from this menu onto the level on the left to add instances of them to the level. The footer will also allow them to save levels to their hard drive, and load levels they previously saved
The techniques we cover will be applicable to a huge range of tools but, as the project continues, we’ll direct it more towards creating levels for a breakout game. Later in the course, we’ll add the game that loads and plays these levels
In this introduction, we’ll quickly add a lot of files and classes to serve as a starting point. There’s a lot of code here, but it’s all files and techniques we’re already familiar with from previous projects.
We’ll give a quick tour of the starting point in this lesson, and then slow down in future sections as we start to implement newer concepts.
As usual, our starting point is main.cpp
, which orchestrates the application's lifecycle in much the same way we did in previous projects. It begins by initializing the necessary SDL subsystems using SDL_Init()
, IMG_Init()
, and TTF_Init()
, along with error checking.
Next, it instantiates the main Editor::Window
and the corresponding Editor::Scene
. The primary while loop continuously polls for events, calculates frame timing (DeltaTime
), updates the editor's state via EditorScene.Tick()
, and handles rendering through the EditorWindow
and EditorScene
.
#include <SDL.h>
#include <SDL_image.h>
#include <SDL_ttf.h>
#include "Config.h"
#ifdef WITH_EDITOR
#include "Editor/Scene.h"
#include "Editor/Window.h"
#endif
int main(int argc, char** argv) {
SDL_Init(SDL_INIT_VIDEO);
CheckSDLError("SDL_Init");
IMG_Init(IMG_INIT_PNG);
CheckSDLError("IMG_Init");
TTF_Init();
CheckSDLError("TTF_Init");
#ifdef WITH_EDITOR
Editor::Window EditorWindow;
Editor::Scene EditorScene{EditorWindow};
#endif
Uint64 LastTick{SDL_GetPerformanceCounter()};
SDL_Event E;
while (true) {
while (SDL_PollEvent(&E)) {
#ifdef WITH_EDITOR
EditorScene.HandleEvent(E);
#endif
if (
E.type == SDL_QUIT ||
E.type == SDL_WINDOWEVENT &&
E.window.event == SDL_WINDOWEVENT_CLOSE
) {
TTF_Quit();
IMG_Quit();
SDL_Quit();
return 0;
}
}
Uint64 CurrentTick{SDL_GetPerformanceCounter()};
float DeltaTime{
static_cast<float>(CurrentTick - LastTick) /
SDL_GetPerformanceFrequency()
};
LastTick = CurrentTick;
#ifdef WITH_EDITOR
EditorScene.Tick(DeltaTime);
EditorWindow.Render();
EditorScene.Render(EditorWindow.GetSurface());
EditorWindow.Update();
#endif
}
return 0;
}
WITH_EDITOR
We use preprocessor directives, specifically #ifdef WITH_EDITOR
, to manage which parts of the code get compiled. This project structure anticipates having both the editor and the actual game coexist within the same codebase eventually. We’ll add the game as a later project in the course.
The editor itself is a tool for developers or designers, not part of the final product shipped to players. Therefore, we enclose all editor-specific initialization, includes, and logic within these #ifdef
blocks.
If the WITH_EDITOR
symbol is defined when compiling, the editor code is included. If it's not defined, the compiler skips these sections, resulting in a build that contains only the game logic (once we add it).
DeltaTime
Our main loop calculates DeltaTime
, representing the time elapsed between frames, using SDL_GetPerformanceCounter()
and SDL_GetPerformanceFrequency()
. This value is passed to the EditorScene.Tick()
function, which will eventually pass it along to all the objects it is managing.
In this initial stage of the editor project, none of our components actively use DeltaTime
for frame-rate independent movement or animation. We're primarily dealing with static UI elements and event-driven interactions.
However, we include the DeltaTime
calculation and pass it through our Tick()
functions anyway. It anticipates potential future needs, either for custom editor features you might add or for the game logic we'll integrate later, which will require time-based updates.
Our quit detection is a little more elaborate than previous projects, because our level editor will have multiple windows. We’ll have our main window, and a small utility window for displaying tooltips.
By default, SDL only pushes an SDL_QUIT
even when every window is closed. Users will have no way to directly close our tooltip window, so we’ve modified our application loop to end when any window is closed. We do this by checking not just for SDL_QUIT
events, but also for SDL_WINDOWEVENT_CLOSE
events
We covered utility windows, multiple-window applications, and the SDL_WINDOWEVENT_CLOSE
in our earlier lesson:
The Editor/Window.h
file introduces our Window
class, designed specifically for the editor's main window. Our Window
class is very similar to what we have been using previously. It handles creating the SDL_Window
in its constructor and destroying it in the destructor, managing the resource lifecycle.
To keep the editor code distinct and well-organized, we've placed this class (and others related to the editor) inside an Editor
namespace. Correspondingly, the header and source files reside in an Editor/
subdirectory within our project structure.
#pragma once
#include <SDL.h>
#include "Config.h"
namespace Editor{
class Window {
public:
Window() {
SDLWindow = SDL_CreateWindow(
Config::Editor::WINDOW_TITLE.c_str(),
SDL_WINDOWPOS_UNDEFINED,
SDL_WINDOWPOS_UNDEFINED,
Config::Editor::WINDOW_WIDTH,
Config::Editor::WINDOW_HEIGHT,
0
);
CheckSDLError("Creating Editor Window");
}
~Window() {
if (SDLWindow && SDL_WasInit(SDL_INIT_VIDEO)) {
SDL_DestroyWindow(SDLWindow);
}
}
Window(const Window&) = delete;
Window& operator=(const Window&) = delete;
void Render() {
auto [r, g, b, a]{
Config::Editor::WINDOW_BACKGROUND
};
SDL_FillRect(
GetSurface(), nullptr,
SDL_MapRGB(GetSurface()->format, r, g, b));
}
void Update() {
SDL_UpdateWindowSurface(SDLWindow);
}
SDL_Surface* GetSurface() const {
return SDL_GetWindowSurface(SDLWindow);
}
bool HasMouseFocus() const {
int x, y, w, h, MouseX, MouseY;
SDL_GetWindowPosition(SDLWindow, &x, &y);
SDL_GetWindowSize(SDLWindow, &w, &h);
SDL_GetGlobalMouseState(&MouseX, &MouseY);
if (
MouseX < x ||
MouseX > x + w ||
MouseY < y ||
MouseY > y + h
) {
return false;
}
return true;
}
SDL_Point GetPosition() const {
int x, y;
SDL_GetWindowPosition(SDLWindow, &x, &y);
return {x, y};
}
SDL_Point GetSize() const {
int x, y;
SDL_GetWindowSize(SDLWindow, &x, &y);
return {x, y};
}
private:
SDL_Window* SDLWindow{nullptr};
};
}
Two helpful additions to the Editor::Window
class are the GetPosition()
and GetSize()
methods. They act as convenient wrappers around the underlying SDL functions SDL_GetWindowPosition()
and SDL_GetWindowSize()
.
This information is exposed because other parts of our editor system, particularly those dealing with mouse input relative to the window or UI element placement, will need access to the window's current state.
They return the requested information packaged within an SDL_Point
structure. This built-in SDL type is a basic container for two integers, x
and y
. We’ll use this type any time a function needs to return two such integers but, of course, we can design our API in any way we want
HasMouseFocus()
FunctionIn the future, our project will contain multiple main windows - one for the level editor, and one for the game. Because of this, we need a way to check which window certain events, such as SDL_MouseMotionEvent
s, correspond to.
Ideally, we’d prefer to use the SDL_GetMouseFocus()
function to understand which window has mouse focus. However, when we have utility windows such as a tooltip, the tooltip window may have mouse focus rather than our main window.
As such, our HasMouseFocus()
window, and some of our future code, uses indirect ways to determine if the mouse is hovering over a specific window.
We’d prefer to solve this problem by preventing our utility windows from gaining mouse focus, but this is not an option in SDL2. We’ll suggest some ways of doing this using platform-specific APIs later in the project, when we add our utility window.
The Editor::Scene
class acts as the main container and manager for the content displayed within our Editor::Window
. It holds references to assets and will eventually manage all the UI elements and actors within the editor.
It includes the standard HandleEvent()
, Tick()
, and Render()
methods, forming the core interface for interacting with the scene from the main application loop. It also holds an AssetManager
instance to provide access to shared resources like images.
It also keeps a reference to its parent Window
. This allows the scene to query the window for information, such as whether it currently has mouse focus via the scene's own HasMouseFocus()
method.
Our scene takes up the full area of its parent window, so if our ParentWindow
has mouse focus, then the scene inherently also has mouse focus. As such, Sceen::HasMouseFocus()
just offloads the work to the equivalent function on the ParentWindow
.
#pragma once
#include <SDL.h>
#include "AssetManager.h"
#include "Window.h"
namespace Editor{
class Scene {
public:
Scene(Window& ParentWindow)
: ParentWindow{ParentWindow}
{}
void HandleEvent(const SDL_Event& E) {
}
void Tick(float DeltaTime) {
}
void Render(SDL_Surface* Surface) {
}
AssetManager& GetAssets() {
return Assets;
}
bool HasMouseFocus() const {
return ParentWindow.HasMouseFocus();
}
Window& GetWindow() const {
return ParentWindow;
}
private:
Window& ParentWindow;
AssetManager Assets;
};
}
The Editor::Image
class is our wrapper around SDL_Surface
for loading and rendering image files. It's functionally very similar to image classes we have used in previous projects, handling image loading via IMG_Load()
in its constructor and cleanup via SDL_FreeSurface()
in its destructor.
As with other editor components, it resides in the Editor
namespace. One subtle but important change is in the Render()
method's signature: the SDL_Rect Rect
parameter is now passed by copy, not by reference (SDL_Rect*
or SDL_Rect&
).
This change is necessary because SDL_BlitScaled()
can modify the destination rectangle pointer (&Rect
in this case) if the blit operation results in clipping. By passing the SDL_Rect
by copy, we ensure that the original rectangle object used by the caller of Render()
remains unchanged, preventing unexpected side effects.
We covered blitting and this cropping behaviour in detail here:
#pragma once
#include <SDL.h>
#include <SDL_image.h>
#include <string>
#include "Config.h"
namespace Editor{
class Image {
public:
Image() = default;
Image(const std::string& Path)
: ImageSurface{IMG_Load(Path.c_str())
} {
CheckSDLError("Loading Image");
}
void Render(
SDL_Surface* Surface, SDL_Rect Rect
) const {
SDL_BlitScaled(
ImageSurface, nullptr, Surface, &Rect);
}
Image(Image&& Other) noexcept
: ImageSurface(Other.ImageSurface) {
Other.ImageSurface = nullptr;
}
~Image() {
if (ImageSurface) {
SDL_FreeSurface(ImageSurface);
}
}
Image(const Image&) = delete;
Image& operator=(const Image&) = delete;
private:
SDL_Surface* ImageSurface{nullptr};
};
}
The Editor::Text
class provides functionality for rendering text using SDL_ttf
. It's very similar to text rendering classes we've implemented before, managing a TTF_Font
and an SDL_Surface
to hold the rendered text.
Its constructor takes the initial string and font size. It uses the font file path and color defined in our Config
namespace (Config::FONT
and Config::FONT_COLOR
) to load the font and render the initial text surface using TTF_RenderText_Blended()
.
The Render()
method takes a destination SDL_Surface
and an SDL_Rect*
. It calculates the position needed to center the text surface within the provided rectangle and then blits the text using SDL_BlitSurface()
. A SetText()
method allows updating the displayed text dynamically.
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
#include <string>
#include "Config.h"
namespace Editor{
class Text {
public:
Text(
const std::string& InitialText,
int FontSize
) : Content(InitialText) {
Font = TTF_OpenFont(
Config::FONT.c_str(), FontSize);
CheckSDLError("Opening Font");
SetText(InitialText);
}
~Text() {
if (!SDL_WasInit(SDL_INIT_VIDEO)) {
return;
}
if (TextSurface) {
SDL_FreeSurface(TextSurface);
}
if (Font) {
TTF_CloseFont(Font);
}
}
Text(const Text&) = delete;
Text& operator=(const Text&) = delete;
void SetText(const std::string& NewText) {
Content = NewText;
if (TextSurface) {
SDL_FreeSurface(TextSurface);
}
TextSurface = TTF_RenderText_Blended(Font,
Content.c_str(), Config::FONT_COLOR);
CheckSDLError("Creating Text Surface");
}
void Render(
SDL_Surface* Surface, SDL_Rect* Rect
) {
if (!TextSurface) return;
int TextW{TextSurface->w};
int TextH{TextSurface->h};
// Center the text
SDL_Rect Destination {
Rect->x + (Rect->w - TextW) / 2,
Rect->y + (Rect->h - TextH) / 2,
TextW, TextH
};
SDL_BlitSurface(
TextSurface, nullptr,
Surface, &Destination
);
}
private:
std::string Content;
TTF_Font* Font{nullptr};
SDL_Surface* TextSurface{nullptr};
};
}
Editor/Button.h
defines a general-purpose Button class for our editor's interface. Each button has a defined rectangular area (Rect
), a text label managed by an Editor::Text
instance, and a visual state represented by the ButtonState
enum.
The button automatically manages hover detection, switching between ButtonState::Normal
and ButtonState::Hover
. A click action is tied to the virtual HandleLeftClick()
function, designed to be overridden by specific button types.
Furthermore, the button's state can be explicitly set using SetState()
. This allows us to visually indicate an Active
state (e.g., a button corresponding to the level that is currenty loaded) or a Disabled
state, where the button becomes unresponsive to mouse events and changes color to indicate unavailability.
#pragma once
#include <SDL.h>
#include <string>
#include "Text.h"
enum class ButtonState {
Normal = 0,
Hover = 1,
Active = 2,
Disabled = 3
};
namespace Editor{
class Scene;
class Button {
public:
Button(
Scene& ParentScene,
const std::string& Text,
SDL_Rect Rect
) : ButtonText{Text, 20},
Rect{Rect},
ParentScene{ParentScene} {}
virtual void HandleLeftClick() {}
void HandleEvent(const SDL_Event& E);
void Render(SDL_Surface* Surface);
void Tick(float DeltaTime) {}
ButtonState GetState() const {
return State;
}
void SetState(ButtonState NewState) {
State = NewState;
}
private:
Scene& ParentScene;
ButtonState State{ButtonState::Normal};
Text ButtonText;
SDL_Rect Rect;
};
}
The behaviour of our Button
class is implemented in Editor/Source/Button.cpp
. The Render()
function is straightforward: it looks up the appropriate color for the current ButtonState
in Config::BUTTON_COLORS
, fills the button's rectangle (Rect
) with that color, and overlays the text label using ButtonText.Render()
.
The HandleEvent()
method filters incoming SDL events. It calls HandleLeftClick()
on a left mouse click, but only if the button's state is currently Hover
. When handling SDL_MOUSEMOTION
, it performs an important preliminary check: ParentScene.HasMouseFocus()
.
This ensures that hover effects are only triggered when the mouse cursor is genuinely interacting with the scene containing the button, preventing incorrect highlighting if multiple windows are present. If the scene has focus, it then tests if the mouse position falls within the button's Rect
to update the State
between Normal
and Hover
.
#include "Editor/Button.h"
#include "Editor/Scene.h"
using namespace Editor;
void Button::HandleEvent(const SDL_Event& E) {
using enum ButtonState;
if (E.type == SDL_MOUSEBUTTONDOWN &&
E.button.button == SDL_BUTTON_LEFT &&
State == Hover
) {
HandleLeftClick();
} else if (
E.type == SDL_MOUSEMOTION &&
ParentScene.HasMouseFocus()
) {
SDL_Point Pos{E.motion.x, E.motion.y};
bool Hovering(SDL_PointInRect(&Pos, &Rect));
if (State == Normal && Hovering) {
State = Hover;
} else if (State == Hover && !Hovering) {
State = Normal;
}
}
}
void Button::Render(SDL_Surface* Surface) {
using namespace Config;
auto [r, g, b, a]{
BUTTON_COLORS[static_cast<int>(State)]};
SDL_FillRect(Surface, &Rect, SDL_MapRGB(
Surface->format, r, g, b
));
ButtonText.Render(Surface, &Rect);
}
Config
NamespaceWe use a Config.h
file, placed in the project root, to centralize various configuration values and helper functions used throughout the application. Constants like button colors (BUTTON_COLORS
), font settings (FONT
, FONT_COLOR
), and window properties are defined here using inline
variables or constexpr
.
Notice that editor-specific settings, like the window title and dimensions, are nested within a Config::Editor
namespace. This separates configuration relevant only to the editor tool from potentially shared configuration (like font choices) that the game might also use later. These editor-specific values are also wrapped in #ifdef WITH_EDITOR
.
The file also includes the CheckSDLError()
helper function that we’ve been using in previous projects. Wrapped in an #ifdef CHECK_ERRORS
, this function provides a way to check for and log SDL errors after critical SDL function calls during development builds, without adding overhead to release builds.
#pragma once
#include <iostream>
#include <SDL.h>
#include <string>
#include <vector>
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 {
inline const std::string WINDOW_TITLE{
"Editor"};
inline const int WINDOW_WIDTH{730};
inline const int WINDOW_HEIGHT{300};
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
}
The Editor/AssetManager.h
file defines a simple AssetManager
struct within the Editor
namespace. Its purpose is to load and hold shared Image
resources needed by the editor UI.
Instead of having multiple different UI elements each load their own copy of "Assets/Brick_Blue_A.png", they can all access the single BlueBlock
instance stored in the AssetManager
. This avoids redundant memory usage for identical SDL_Surface
objects and centralizes asset loading.
#pragma once
#include "Image.h"
namespace Editor {
struct AssetManager {
Image BlueBlock{"Assets/Brick_Blue_A.png"};
Image GreenBlock{"Assets/Brick_Green_A.png"};
Image CyanBlock{"Assets/Brick_Cyan_A.png"};
Image OrangeBlock{"Assets/Brick_Orange_A.png"};
Image RedBlock{"Assets/Brick_Red_A.png"};
Image YellowBlock{"Assets/Brick_Yellow_A.png"};
};
}
As with previous projects, we need to ensure the SDL DLL files are provided alongside our executable file. We also need to ensure that the image files listed in Editor::AssetManager
and the font specified in Config::FONT
are also in that location.
The Config
and AssetManager
files above, as well as the screenshots within the lessons, are using the following assets:
Our starting point should compile and run successfully, opening a blank window:
If you’re using CMake, a CMakeLists.txt
file is provided below that will automatically copy the DLL files to the same location as the executable. Files stored in an "Assets" directory within the project folder will also be copied to an "Assets" directory in the same location as the executable.
cmake_minimum_required(VERSION 3.16)
set(CMAKE_CXX_STANDARD 20)
project(Editor VERSION 1.0.0)
add_executable(Editor
"main.cpp"
"Editor/Source/Button.cpp"
# These files will be added later
# "Editor/Source/Blocks.cpp"
# "Editor/Source/Level.cpp"
# "Editor/Source/Actor.cpp"
# "Editor/Source/ActorTooltip.cpp"
)
target_compile_definitions(
Editor PUBLIC
WITH_EDITOR
CHECK_ERRORS
)
target_include_directories(
Editor PUBLIC ${PROJECT_SOURCE_DIR}
)
add_subdirectory(external/SDL)
add_subdirectory(external/SDL_image)
add_subdirectory(external/SDL_ttf)
target_link_libraries(Editor PRIVATE
SDL2
SDL2_image
SDL2_ttf
)
if (WIN32)
target_link_libraries(
Editor PRIVATE SDL2main
)
endif()
set(AssetDirectory "${PROJECT_SOURCE_DIR}/Assets")
add_custom_command(
TARGET Editor POST_BUILD
COMMAND
${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:SDL2>"
"$<TARGET_FILE:SDL2_image>"
"$<TARGET_FILE:SDL2_ttf>"
"$<TARGET_FILE_DIR:Editor>"
COMMAND
${CMAKE_COMMAND} -E copy_directory_if_different
"${AssetDirectory}"
"$<TARGET_FILE_DIR:Editor>/Assets"
VERBATIM
)
In this lesson, we laid the essential groundwork for our level editor. We established the main application structure, including SDL initialization and the core event/update/render loop in main.cpp
.
We created foundational classes within an Editor namespace:
Window
: Manages the main editor window.Scene
: Orchestrates the content within the window.Image & Text
: Handle graphical and text rendering.Button
: Provides a base for interactive UI elements.AssetManager
: Loads and shares image resources.We also set up a Config.h
for centralized settings and introduced conditional compilation (#ifdef WITH_EDITOR
) to separate editor code from potential future game code. Techniques for handling multiple windows, like the HasMouseFocus()
check and specific quit conditions, were implemented. Our project now compiles and runs, displaying a blank window, ready for the next steps.
Establish the core structure for our level editor, including window, scene, and asset management.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games