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.
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.
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.
Add new behavior by writing a new Visitor. Sprite, Scene, and GameEngine never change.
No dynamic_cast, no typeid, no if-else chains on entity type. Just vtable dispatch.
Gravity + Bounce + Draw? Three visitors, added in order. Remove gravity — just don't add it.
Renderer and input are abstract. Native SDL2, SFML, or WebAssembly — chosen at build time, same game code.
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.
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:
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
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.
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
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. |
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.
There is no Ball class. No Paddle class. Every game object is a
Sprite constructed with a texture, position, and size.
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);
Each visitor is one responsibility. Compose them at startup:
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
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.
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();
}
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.
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.
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.
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.
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.
A native executable with a game-select menu. SDL2 + CMake; on Windows, SDL2 comes from vcpkg.
The same C++ compiled with Emscripten — one playable .html per game. No rewrite.
A multi-stage build compiles the WASM games and serves them with nginx; one command to run.
# 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.
Built as a learning project — the remaining rough edges are part of the record.
dy, which is upward in screen space. Correct behavior, counter-intuitive name.AbstractRenderer draws sprites and primitives, not text.-DBACKEND=SFML compiles.