C++17 · SDL2 · WebAssembly · Visitor Pattern

Behavior by composition,
not inheritance.

A game engine where every operation is a Visitor and every entity is a Sprite. No entity subclasses. No type switches. Just double dispatch — two vtable lookups per sprite per frame.

2 virtual calls per sprite
8 composable visitors
0 entity subclasses
0 dynamic_cast calls
Play

The engine, in your browser.

These are the same C++ games above, compiled to WebAssembly with Emscripten. No rewrite — the SDL2 backend simply slots in behind the same AbstractRenderer and AbstractInputWrapper seams the native build uses. Double dispatch, in a browser tab.

Pong — ↑/↓ or W/S move your paddle  ·  Tic Tac Toe — click a cell  ·  Quoridor — arrows (P1) & WASD (P2) race to the far row

Click the canvas first so it captures keyboard focus.

The Idea

Everything is a Visitor.
Sprites are just data.

Most game engines reach for inheritance: Enemy extends Character, FlyingEnemy extends Enemy, until the hierarchy collapses under its own weight. This engine takes the opposite stance.

Sprites hold state. Visitors hold behavior. A Sprite is nothing but position, velocity, size, and a texture path. It knows nothing about physics, rendering, or collision. All of that lives in Visitors — small, single-purpose objects that are composed at runtime by adding them to the engine.

📦

Open/Closed

Add new behavior by writing a new Visitor. Sprite, Scene, and GameEngine never change.

No RTTI

No dynamic_cast, no typeid, no if-else chains on entity type. Just vtable dispatch.

🧩

Composable

Gravity + Bounce + Draw? Three visitors, added in order. Remove gravity — just don't add it.

🔁

Swappable backends

Renderer and input are abstract. Native SDL2, SFML, or WebAssembly — chosen at build time, same game code.

Double Dispatch

Two pointer dereferences.
That's the whole engine loop.

The term "double dispatch" sounds heavy. It isn't. The entire update loop is five lines, and each frame does exactly two virtual calls per sprite — one to pick the scene, one to pick the visitor.

GameEngine.cpp
void GameEngine::update() {
    for (auto &visitor : this->sceneVisitors)
        this->scene->accept(visitor.get()); // ← dispatch #1: which scene?
}

scene->accept() is a virtual call. The vtable resolves whether this is a SimpleScene, LayeredScene, or anything else. Inside the scene:

SimpleScene.cpp
void SimpleScene::accept(Visitor* v) {
    for (auto &sprite : this->spriteList)
        v->visit(sprite.get()); // ← dispatch #2: which visitor?
}

v->visit() is the second virtual call. Each concrete Visitor implements its own visit() — physics, collision, drawing — and the vtable picks the right one. The CPU branch predictor gets very good at this loop very quickly.

Both hops hand the sprite/visitor across as a non-owning raw pointer — the scene owns the graph, the visitor only observes it — so the hot loop carries no shared_ptr reference-count traffic. The dispatch stays as light as the two vtable lookups it's meant to be.

GameEngine::update()
  ├── for each Visitor in sceneVisitors:
  │       │
  │       └── scene->accept(visitor)          ← vtable[scene]
  │                   │
  │                   └── for each Sprite in spriteList:
  │                               │
  │                               └── visitor->visit(sprite)   ← vtable[visitor]
  
    ✓  No dynamic_cast   ✓  No typeid   ✓  No if-else chains
Adding a new behavior — say, a MagnetVisitor that attracts sprites toward a point — means writing one new class with one method. Nothing else changes. The engine is open for extension and closed for modification in the most literal sense.
Architecture

The full picture.

The engine sits at the center, composing a scene with a list of visitors. The renderer and input handler are fully abstracted behind interfaces.

GameEngine
  
  ├── scene ──► AbstractScene
  │               ├── SimpleScene       (flat sprite list)
  │               └── LayeredScene      (z-ordered layers)
  
  └── visitors ──► list<Visitor>
  
                   ├── SimpleDrawingVisitor         rendering
                   ├── GridDrawingVisitor           grid → pixel coords
                   ├── BoundingBoxCollisionVisitor  AABB hit test
                   ├── ForceVisitor                 apply velocity
                   ├── BounceBoundsVisitor          reflect at boundary
                   ├── WrapBoundsVisitor            teleport at boundary
                   └── GravityVisitor               accelerate downward

AbstractRenderer ──► SDLRenderer          (or SFML; WASM uses SDL2)
AbstractInputWrapper ──► SDLInputWrapper
Backend (abstract factory) ──► SDLBackend picks the pair at build time
Visitor Catalog

Eight behaviors. Zero entity subclasses.

Every behavior in the engine is a Visitor. Compose them freely at game startup.

Visitor Category What it does
SimpleDrawingVisitor Rendering Draws each sprite at its (x, y) position using the renderer.
GridDrawingVisitor Rendering Same as above, but converts grid coordinates to pixel space first. Used by Quoridor.
BoundingBoxCollisionVisitor Collision AABB test against a single watched sprite. Accumulates hits; drain with getCollisions().
ForceVisitor Physics Moves sprites by their (dx, dy) each frame. applyForce(s, magnitude, angle) converts polar to Cartesian.
BounceBoundsVisitor Physics Reflects dx/dy when a sprite exits the scene boundary.
WrapBoundsVisitor Physics Teleports sprites from one edge to the opposite (Pac-Man style).
GravityVisitor Physics Adds a constant acceleration to dy each frame.
RayCastCollisionVisitor Collision Casts a ray from a moving sprite along its velocity and reports everything in its path (ray-vs-AABB, slab method). Pong's AI uses it to know when the ball is heading its way.
Building a Game

Pong in 40 lines of setup.

Pong is the flagship demo. It shows how the visitor composition model maps directly to a real game: entities are plain sprites, behaviors are visitors wired into the engine, and the game loop is just ge->update() plus collision response.

1 — Sprites are data

There is no Ball class. No Paddle class. Every game object is a Sprite constructed with a texture, position, and size.

Pong.cpp — sprite declarations
auto player1     = make_shared<Sprite>(tex, MINX+100, MAXY/2, paddleW, paddleH);
auto player2     = make_shared<Sprite>(tex, MAXX-100, MAXY/2, paddleW, paddleH);
auto player1Goal = make_shared<Sprite>(tex, MINX+10,  0,      5,       MAXY*2);
auto player2Goal = make_shared<Sprite>(tex, MAXX-10,  0,      5,       MAXY*2);
auto ball        = make_shared<Sprite>(tex, MAXX/2,   MAXY/2, 10,      10);

2 — Visitors are behavior

Each visitor is one responsibility. Compose them at startup:

Pong.cpp — visitor setup
auto bounce = make_shared<BounceBoundsVisitor>(MINX, MAXX, MINY, MAXY);
auto force  = make_shared<ForceVisitor>();
auto collide = make_shared<BoundingBoxCollisionVisitor>();
auto draw   = make_shared<SimpleDrawingVisitor>(renderer);

auto ge = make_shared<GameEngine>();

// sprites into scene
ge->addSprite(player1);  ge->addSprite(player2);
ge->addSprite(player1Goal); ge->addSprite(player2Goal);
ge->addSprite(ball);

// visitors run in order — draw must come last
ge->addVisitor(collide);
ge->addVisitor(force);
ge->addVisitor(bounce);
ge->addVisitor(draw);

collide->setWatched(ball);        // track ball against everything else
force->applyForce(ball, 10, rand()); // kick off at a random angle

3 — Game loop

ge->update() runs all visitors over all sprites. After that, drain the collision list and respond. The engine knows nothing about paddles or goals — that logic lives in game code, where it belongs.

Pong.cpp — game loop
while (draw->isOpen()) {
    ge->update(); // runs every visitor across every sprite

    // input
    for (int key : input->getKeyPresses()) {
        if (key == 73) force->applyForce(player1, 1, 0);   // up
        if (key == 74) force->applyForce(player1, 1, 180); // down
    }

    // collision response
    for (auto &hit : collide->getCollisions()) {
        if (hit == player1Goal) { p2score++; resetRound(); }
        if (hit == player2Goal) { p1score++; resetRound(); }
        if (hit == player1) {
            // angle off the paddle face based on where the ball hit
            ball->setDXY(
                fabs(ball->getDX()),
                SPRING * ((ball->getY() - player1->getY()) / paddleH - 0.5f)
            );
        }
    }

    // raycast AI: chase only when the ball's ray is pointed at our goal
    bool incoming = false;
    for (auto *hit : raycast->getCollisions())
        if (hit == player2Goal.get()) incoming = true;
    player2->setXY(player2->getX(), incoming ? ball->getY() - paddleH/2
                                             : MAXY/2 - paddleH/2);

    draw->draw();
}
The paddle angle-response math — SPRING * (hit_offset - 0.5) — is the most interesting moment in Pong. Hit the top of the paddle: ball goes up. Hit the center: straight. Hit the bottom: ball goes down. All without any knowledge of a "paddle type" anywhere in the engine.
Games

Three demos. One engine.

🏓 Pong Complete

Two paddles, a ball, goals. Player 1 on keyboard; player 2 is a RayCastCollisionVisitor AI that only chases when the ball is actually aimed at it — so it's beatable. Scoring and round resets included.

♟ Quoridor Playable

Full 9×9 board with walls. Move by arrow keys or tapping an adjacent cell; drop a wall by clicking a grid line (long-press on touch). A BFS rule forbids any wall that would trap a player. First to the far row wins.

❌ Tic Tac Toe Playable

Click to place. Turn tracking, win/draw detection, and X/O textures swapped onto cell sprites at runtime — all on a visible 3×3 grid via GridDrawingVisitor.

Ship Anywhere

One codebase. Two first-class targets.

The engine renders and reads input through AbstractRenderer / AbstractInputWrapper, built by the Backend abstract factory. Flip the backend at build time; the game code never changes. The SDL2 backend powers both the native desktop build and — through Emscripten — the WebAssembly demos above.

🖥️

Windows / macOS / Linux

A native executable with a game-select menu. SDL2 + CMake; on Windows, SDL2 comes from vcpkg.

🌐

WebAssembly

The same C++ compiled with Emscripten — one playable .html per game. No rewrite.

🐳

Docker

A multi-stage build compiles the WASM games and serves them with nginx; one command to run.

build
# Desktop (SDL2 + CMake)
cmake -B build -DBACKEND=SDL2 && cmake --build build -j4
./build/twove        # menu: 0 TicTacToe · 1 Pong · 2 Quoridor

# Windows: point CMake at your vcpkg toolchain
./scripts/build-native.ps1 -Toolchain C:\vcpkg\scripts\buildsystems\vcpkg.cmake

# Web (Emscripten) — or run the whole thing in Docker
docker build -t vge-web . && docker run --rm -p 8080:80 vge-web

Per-platform dependencies, packaging, and how to ship the binaries are in PACKAGING.md.

Known Limitations

Built as a learning project — the remaining rough edges are part of the record.