SFML 3 Project

Building a Simple Game Engine in C++

Bob the character sprite walking across a background in the simple C++ game engine demo.
Bob moving left and right across a background — controlled at 400 pixels per second with A and D, with the game loop and rendering handled entirely inside the Engine class.

Every real game project eventually hits the same wall: one enormous source file that does everything — input, logic, rendering, asset loading — all tangled together. It compiles, but adding a feature means wading through hundreds of lines to find the right spot, and a change in one place breaks something in another.

The answer is a game engine: a layer of code that separates those concerns, gives each piece its own home, and exposes a clean interface to everything else. This project builds the simplest possible version. A Bob class owns the player character. An Engine class owns the window and the game loop, with input, update, and draw split into their own source files. main.cpp is four lines. The result runs the same as a monolithic program but scales to any size — because every project you build after this one follows the same pattern.

Project Setup

If you haven't set up SFML 3 with Visual Studio 2026 yet, follow the Setting Up Visual Studio 2026 and SFML 3 guide first. This project requires the same configuration: include path, library path, C++17 language standard, and Debug/Release linker input. Create a new Empty Project, then create the eight files listed below.

Assets

You'll need two image files in the same folder as the project's .exe (the x64\Debug folder):

Screenshot of the simple game engine running, showing Bob the character sprite on a background.
The finished project running: Bob on a background, controlled with A and D. The Engine class handles everything — the loop, the window, and the rendering order.

Architecture

The project has eight files across two classes and one entry point:

Splitting input, update, and draw into separate .cpp files is a deliberate choice. As the game grows, each file stays focused on one job. You can find the drawing code instantly; you can hand the input file to a teammate without worrying about merge conflicts in the physics code.

Bob.h

#pragma once
#include <SFML/Graphics.hpp>

using namespace sf;

class Bob
{
private:
    Vector2f m_Position;
    Sprite   m_Sprite;
    Texture  m_Texture;

    bool  m_LeftPressed  = false;
    bool  m_RightPressed = false;
    float m_Speed        = 400.f;

public:
    Bob();

    Sprite getSprite();

    void moveLeft();
    void moveRight();
    void stopLeft();
    void stopRight();

    void update(float elapsedTime);
};

All member variables are private. The only public surface is a constructor, a getter for the sprite, four movement controls, and an update function that advances Bob by the time that has passed since the last frame. Nothing in here is tied to the window or the game loop — Bob is a self-contained component.

Bob.cpp

#include "Bob.h"

Bob::Bob()
{
    m_Texture.loadFromFile("bob.png");
    m_Sprite.setTexture(m_Texture);

    m_Position.x = 500.f;
    m_Position.y = 800.f;
}

Sprite Bob::getSprite()
{
    return m_Sprite;
}

void Bob::moveLeft()  { m_LeftPressed  = true;  }
void Bob::moveRight() { m_RightPressed = true;  }
void Bob::stopLeft()  { m_LeftPressed  = false; }
void Bob::stopRight() { m_RightPressed = false; }

void Bob::update(float elapsedTime)
{
    if (m_RightPressed) m_Position.x += m_Speed * elapsedTime;
    if (m_LeftPressed)  m_Position.x -= m_Speed * elapsedTime;

    m_Sprite.setPosition(m_Position);
}

The starting position is hardcoded at (500, 800). That puts Bob in the lower-left area of a typical 1080p screen. If he starts off-screen on your monitor, adjust those values to match your background image dimensions.

update multiplies speed by elapsedTime (a fraction of a second — see Engine::start() below). This is frame-rate-independent movement: Bob travels at exactly 400 pixels per second regardless of whether the machine is running at 30 fps or 120 fps.

Engine.h

#pragma once
#include <SFML/Graphics.hpp>
#include "Bob.h"

using namespace sf;

class Engine
{
private:
    RenderWindow m_Window;

    Sprite  m_BackgroundSprite;
    Texture m_BackgroundTexture;

    Bob m_Bob;

    void input();
    void update(float dtAsSeconds);
    void draw();

public:
    Engine();
    void start();
};

The three private functions — input, update, draw — are declared here and defined in their own .cpp files. From main.cpp's perspective they don't exist; it only sees the public Engine() constructor and start(). That's the whole external API of the engine.

Main.cpp

#include "Engine.h"

int main()
{
    Engine engine;
    engine.start();
    return 0;
}

Four lines. This is the goal. As the game grows — more enemies, more levels, more systems — main.cpp stays this size. All the complexity lives inside Engine.

Engine.cpp

The constructor creates the fullscreen window and loads the background.

#include "Engine.h"

Engine::Engine()
{
    m_Window.create(VideoMode::getDesktopMode(),
                    "Simple Game Engine",
                    State::Fullscreen);

    m_BackgroundTexture.loadFromFile("background.jpg");
    m_BackgroundSprite.setTexture(m_BackgroundTexture);
}

void Engine::start()
{
    Clock clock;

    while (m_Window.isOpen())
    {
        Time  dt          = clock.restart();
        float dtAsSeconds = dt.asSeconds();

        input();
        update(dtAsSeconds);
        draw();
    }
}

VideoMode::getDesktopMode() returns the current desktop resolution as a VideoMode. Passing it with State::Fullscreen creates a fullscreen window that exactly matches the display — no black bars, no scaling artefacts.

clock.restart() returns the time elapsed since the last call and resets the clock. The result is divided by 1000 (via asSeconds()) to give a small float — typically 0.016 for 60 fps — that gets multiplied into movement in update. This is the delta-time pattern: every moving thing scales its speed by this value, and the game runs at the same apparent speed on every machine.

SFML 2 note. The old code used VideoMode(resolution.x, resolution.y) after reading VideoMode::getDesktopMode().width and .height. In SFML 3, VideoMode stores its size as a Vector2u in a .size field (.size.x, .size.y), so the old .width / .height accessors no longer exist. For a fullscreen window, using VideoMode::getDesktopMode() directly is simpler and correct. Style::Fullscreen became State::Fullscreen.

Input.cpp

#include "Engine.h"

void Engine::input()
{
    // Drain the OS event queue — required for the window to stay responsive
    while (const auto event = m_Window.pollEvent())
    {
        if (event->is<Event::Closed>())
            m_Window.close();
    }

    // Close on Escape
    if (Keyboard::isKeyPressed(Keyboard::Key::Escape))
        m_Window.close();

    // Move Bob
    if (Keyboard::isKeyPressed(Keyboard::Key::A))
        m_Bob.moveLeft();
    else
        m_Bob.stopLeft();

    if (Keyboard::isKeyPressed(Keyboard::Key::D))
        m_Bob.moveRight();
    else
        m_Bob.stopRight();
}

This file does two distinct things. The pollEvent loop at the top drains the OS event queue — without it, Windows will eventually decide the window has stopped responding and offer to kill it. We handle Event::Closed here so the title-bar X button works.

Below that, Keyboard::isKeyPressed is a polling function: it checks the keyboard state right now, every single frame, without going through the event queue. This is the right tool for movement — you want to know "is A held down this frame?", not "did A just go from up to down?". The else branches on the A and D checks call stopLeft() / stopRight() immediately when the key is released, so Bob stops the moment you let go.

SFML 2 note. In SFML 2, keyboard keys were accessed as Keyboard::A, Keyboard::Escape, etc. In SFML 3 they moved into a nested enum: Keyboard::Key::A, Keyboard::Key::Escape. The pollEvent loop using std::optional is also new — SFML 2 used Event event; while (window.pollEvent(event)) { ... }.

Update.cpp

#include "Engine.h"

void Engine::update(float dtAsSeconds)
{
    m_Bob.update(dtAsSeconds);
}

Right now this is a single line, but it's worth having its own file. When you add enemies, bullets, a score counter, or a physics system, each one gets its update call here. Keeping update logic out of input and draw means you can add a pause feature by simply skipping the update() call in start() — the input and rendering keep running without touching this file.

Draw.cpp

#include "Engine.h"

void Engine::draw()
{
    m_Window.clear(Color::White);

    m_Window.draw(m_BackgroundSprite);
    m_Window.draw(m_Bob.getSprite());

    m_Window.display();
}

Draw order is painter's order: whatever you draw last appears on top. The background goes first; Bob goes on top of it. m_Window.display() swaps the back buffer to the screen — nothing appears until that call.

Common Issues

Black screen — background or Bob doesn't appear. The image files aren't where the program looks for them. At runtime, relative paths resolve from the folder that contains the .exe. Put bob.png and background.jpg directly in x64\Debug alongside the exe and the SFML DLLs. loadFromFile fails silently if the file isn't found — check the Visual Studio Output window for any SFML error messages.

Bob starts off-screen. The constructor sets the starting position to (500, 800). On a monitor shorter than 800 pixels tall, Bob will be below the visible area. Change the y value in Bob::Bob() to something within your screen height. A safe default is m_Position.y = 500.f.

Window appears but immediately becomes "Not Responding". The OS event queue isn't being drained. Make sure the pollEvent loop in Input.cpp is present and is actually called every frame from Engine::start(). Without it, Windows marks the window as hung after a few seconds.

Compiler error: 'width' is not a member of 'sf::VideoMode'. You're using SFML 2 code with SFML 3. Replace VideoMode::getDesktopMode().width with VideoMode::getDesktopMode().size.x (and .height with .size.y). For a fullscreen window, use VideoMode::getDesktopMode() directly with State::Fullscreen as shown in the article.

Compiler error: 'A' is not a member of 'sf::Keyboard'. Key names moved in SFML 3. Replace Keyboard::A with Keyboard::Key::A (and the same for every other key name throughout the file).

Linker error or crash about missing stdafx. Remove any #include "stdafx.h" lines. That's a precompiled header from old Visual Studio project templates. The Empty Project template in Visual Studio 2026 doesn't generate one, and SFML projects don't need it.

Where to Take It Next