Level Editor Starting Point

Establish the core structure for our level editor, including window, scene, and asset management.

Ryan McCombe
Updated

Large games tend to be complex projects, developed over many years. Because of this, developers often spend as much time creating the tools that help build the game as they do creating the things 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 game. 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 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.

Application Loop

As usual, our starting point is main.cpp, which orchestrates the application's lifecycle in much the same way we did in previous projects. Our project doesn't just include source code - it will include assets and other supplemental files associated. To deal with this, we'll adopt the common convention of having our source files inside a /src directory.

Our main function in src/main.cpp begins by initializing the necessary SDL subsystems using SDL_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.

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h>

#include "Config.h"

#ifdef WITH_EDITOR
#include "Editor/Scene.h"
#include "Editor/Window.h"
#endif

int main(int argc, char** argv) {
  if (!SDL_Init(SDL_INIT_VIDEO)) {
    CheckSDLError("SDL_Init");
    return 1;
  }

  if (!TTF_Init()) {
    CheckSDLError("TTF_Init");
    return 1;
  }

#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_EVENT_QUIT ||
        E.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED
      ) {
        TTF_Quit();
        SDL_Quit();
        return 0;
      }
    }

    Uint64 CurrentTick{SDL_GetPerformanceCounter()};
    float DeltaTime{
      static_cast<float>(CurrentTick - LastTick) /
        static_cast<float>(SDL_GetPerformanceFrequency())
    };
    LastTick = CurrentTick;

#ifdef WITH_EDITOR
    EditorScene.Tick(DeltaTime);
    EditorWindow.Render();
    EditorScene.Render(EditorWindow.GetSurface());
    EditorWindow.Update();
#endif
  }

  return 0;
}

Preprocessor Definition: WITH_EDITOR

We use , 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 core game code, once we add it.

This approach is fairly common, and it means the build we use internally when making the game has our level editor and any additional suporting tools we might add in the future, whilst the build we ship to players has all of those ancillary tools removed.

Tick Function: 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.

We covered earlier in the course.

Quit Event

Our quit detection is a little more elaborate than previous projects, because our level editor will have . In this project, we'll have our main window, and a small utility window for displaying tooltips. Our final project will add yet another window to the mix, which will be used for our main game.

By default, SDL only pushes an SDL_EVENT_QUIT even when every window is closed. To change this, we've modified our application loop to end when any window is closed. We do this by checking not just for SDL_EVENT_QUIT events, but also for SDL_EVENT_WINDOW_CLOSE_REQUESTED events.

Window Class

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.

src/Editor/Window.h

#pragma once
#include <SDL3/SDL.h>
#include "Config.h"

namespace Editor{
class Window {
public:
  Window() {
    SDLWindow = SDL_CreateWindow(
      Config::Editor::WINDOW_TITLE.c_str(),
      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
    };

    const auto* Fmt = SDL_GetPixelFormatDetails(
      GetSurface()->format
    );

    SDL_FillSurfaceRect(
      GetSurface(), nullptr,
      SDL_MapRGB(Fmt, nullptr, r, g, b));
  }

  void Update() {
    SDL_UpdateWindowSurface(SDLWindow);
  }

  SDL_Surface* GetSurface() const {
    return SDL_GetWindowSurface(SDLWindow);
  }

   bool HasMouseFocus() const {
    return SDL_GetMouseFocus() == SDLWindow;
  }

  SDL_Point GetPosition() const {
    int x, y;
    SDL_GetWindowPosition(SDLWindow, &x, &y);
    return {x, y};
  }

  SDL_Point GetSize() const {
    int w, h;
    SDL_GetWindowSize(SDLWindow, &w, &h);
    return {w, h};
  }
  
  SDL_Window* GetRaw() const {
    return SDLWindow;
  }

private:
  SDL_Window* SDLWindow{nullptr};
};
}

Getters

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

The HasMouseFocus() Function

In 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_MouseMotionEvents, correspond to.

We've used the the SDL_GetMouseFocus() function for this, which we covered in more detail .

Scene Class

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, Scene::HasMouseFocus() just offloads the work to the equivalent function on the ParentWindow.

src/Editor/Scene.h

#pragma once
#include <SDL3/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;
};
}

Image Class

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_DestroySurface() in its destructor.

We covered image handling in detail .

As with other editor components, it resides in the Editor namespace:

#pragma once
#include <SDL3/SDL.h>
#include <SDL3_image/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_BlitSurfaceScaled(
      ImageSurface, nullptr, Surface, &Rect,
      SDL_SCALEMODE_LINEAR);
  }

  Image(Image&& Other) noexcept
  : ImageSurface(Other.ImageSurface) {
    Other.ImageSurface = nullptr;
  }

  ~Image() {
    if (ImageSurface) {
      SDL_DestroySurface(ImageSurface);
    }
  }

  Image(const Image&) = delete;
  Image& operator=(const Image&) = delete;

 private:
  SDL_Surface* ImageSurface{nullptr};
};
}

Text Class

The Editor::Text class provides functionality for rendering text using SDL3_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:

src/Editor/Text.h

#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/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(), (float)FontSize);
    CheckSDLError("Opening Font");
    SetText(InitialText);
  }

  ~Text() {
    if (!SDL_WasInit(SDL_INIT_VIDEO)) {
      return;
    }
    if (TextSurface) {
      SDL_DestroySurface(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_DestroySurface(TextSurface);
    }
    TextSurface = TTF_RenderText_Blended(Font,
      Content.c_str(), 0, 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};
};
}

Button Class

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 currently loaded) or a Disabled state, where the button becomes unresponsive to mouse events and changes color to indicate unavailability.

src/Editor/Button.h

#pragma once
#include <SDL3/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_EVENT_MOUSE_MOTION, 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.

src/Editor/Button.cpp

#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_EVENT_MOUSE_BUTTON_DOWN &&
      E.button.button == SDL_BUTTON_LEFT &&
      State == Hover
  ) {
    HandleLeftClick();
  } else if (
    E.type == SDL_EVENT_MOUSE_MOTION &&
    ParentScene.HasMouseFocus()
  ) {
    SDL_Point Pos{int(E.motion.x), int(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)]};

  const auto* Fmt = SDL_GetPixelFormatDetails(
      Surface->format
    );

  SDL_FillSurfaceRect(Surface, &Rect, SDL_MapRGB(
    Fmt, nullptr, r, g, b
  ));

  ButtonText.Render(Surface, &Rect);
}

The Config Namespace

We 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 <SDL3/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 const std::string BASE_PATH{
  SDL_GetBasePath()};
inline const std::string FONT{
  BASE_PATH + "Assets/Rubik-SemiBold.ttf"};
inline constexpr SDL_Color FONT_COLOR{
  255, 255, 255, 255};
}

#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
}

Asset Manager

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.

src/Editor/AssetManager.h

#pragma once
#include "Image.h"

namespace Editor {
struct AssetManager {
  Image BlueBlock{
    Config::BASE_PATH + "Assets/Brick_Blue_A.png"};
  Image GreenBlock{
    Config::BASE_PATH + "Assets/Brick_Green_A.png"};
  Image CyanBlock{
    Config::BASE_PATH + "Assets/Brick_Cyan_A.png"};
  Image OrangeBlock{
    Config::BASE_PATH + "Assets/Brick_Orange_A.png"};
  Image RedBlock{
    Config::BASE_PATH + "Assets/Brick_Red_A.png"};
  Image YellowBlock{
    Config::BASE_PATH + "Assets/Brick_Yellow_A.png"};
};
}

Assets and DLL Files

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:

CMakeLists.txt

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.

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
set(CMAKE_CXX_STANDARD 20)
project(Editor VERSION 1.0.0)

add_executable(Editor
  "src/main.cpp"
  "src/Editor/Button.cpp"

  # These files will be added later
  # "src/Editor/Blocks.cpp"
  # "src/Editor/Level.cpp"
  # "src/Editor/Actor.cpp"
  # "src/Editor/ActorTooltip.cpp"
)

target_compile_definitions(
  Editor PUBLIC
  WITH_EDITOR
  CHECK_ERRORS
)

target_include_directories(
  Editor PUBLIC ${PROJECT_SOURCE_DIR}/src
)

set(SDLTTF_VENDORED ON)
set(VENDOR_DIR "${PROJECT_SOURCE_DIR}/vendor")
add_subdirectory(${VENDOR_DIR}/SDL)
add_subdirectory(${VENDOR_DIR}/SDL_image)
add_subdirectory(${VENDOR_DIR}/SDL_ttf)

target_link_libraries(Editor PRIVATE
  SDL3::SDL3
  SDL3_image::SDL3_image
  SDL3_ttf::SDL3_ttf
)

set(AssetDirectory "${PROJECT_SOURCE_DIR}/Assets")
add_custom_command(
  TARGET Editor POST_BUILD
  COMMAND
  ${CMAKE_COMMAND} -E copy_if_different
    "$<TARGET_FILE:SDL3::SDL3>"
    "$<TARGET_FILE:SDL3_image::SDL3_image>"
    "$<TARGET_FILE:SDL3_ttf::SDL3_ttf>"
    "$<TARGET_FILE_DIR:Editor>"

  COMMAND
  ${CMAKE_COMMAND} -E copy_directory_if_different
    "${AssetDirectory}"
    "$<TARGET_FILE_DIR:Editor>/Assets"
  VERBATIM
)

Summary

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. Our project now compiles and runs, displaying a blank window, ready for the next steps.

Next Lesson
Lesson 88 of 90

Building the Actor Menu

This lesson focuses on creating the UI panel for Actors and adding the first concrete Actor type.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate