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):
- bob.png — the character sprite used in this tutorial. Right-click and save, or use your own.
- background.jpg — a full-screen background image. Any large JPEG will work; ideally match it to your monitor's resolution so it fills the window without gaps.
Architecture
The project has eight files across two classes and one entry point:
- Bob.h / Bob.cpp — the player character: position, sprite, texture, and movement state.
- Engine.h / Engine.cpp — constructor and
start(): creates the window and runs the game loop. - Input.cpp —
Engine::input(): handles OS events and keyboard polling. - Update.cpp —
Engine::update(): advances game state by the elapsed time. - Draw.cpp —
Engine::draw(): clears, draws, displays. - Main.cpp — creates an
Engineand callsstart().
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 readingVideoMode::getDesktopMode().widthand.height. In SFML 3,VideoModestores its size as aVector2uin a.sizefield (.size.x,.size.y), so the old.width/.heightaccessors no longer exist. For a fullscreen window, usingVideoMode::getDesktopMode()directly is simpler and correct.Style::FullscreenbecameState::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. ThepollEventloop usingstd::optionalis also new — SFML 2 usedEvent 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
- Screen-wrap Bob. Add a check in
Bob::update(): ifm_Position.xdrops below 0 reset it to the screen width, and vice versa. You'll need to pass the screen width into theBobconstructor, or query it from a shared constant. - Add vertical movement. Add
m_UpPressed/m_DownPressedbooleans, wire W and S inInput.cpp, and add a Y component toBob::update(). The pattern is identical to the horizontal case. - Gravity. Add a
float m_Gravityconstant and afloat m_VerticalVelocityto Bob. Each frame inupdate(), addm_Gravity * elapsedTimeto the velocity, then add the velocity tom_Position.y. Clamp at ground level. You've just written a physics system. - Add an enemy class. Create
Enemy.h/Enemy.cppalongside Bob. Give it its own sprite, position, and anupdate(float). Add anEnemy m_EnemytoEngine.h, callm_Enemy.update(dtAsSeconds)inUpdate.cpp, and draw it inDraw.cpp. The multi-file architecture already handles it — no restructuring needed. - Multiple Bobs. Change
Bob m_Bobtostd::vector<Bob> m_Bobsand loop over them in update and draw. TheBobclass doesn't change at all. This is why encapsulation matters.