Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
push:
pull_request:

jobs:
linux:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- name: asan
flags: -DPANCAKE_ASAN=ON
- name: release
flags: -DCMAKE_BUILD_TYPE=Release
name: linux (${{ matrix.name }})
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev \
libgl1-mesa-dev libgl1-mesa-dri libasound2-dev xvfb

- name: Configure
# The bundled dependencies declare old CMake minimums, which newer CMake rejects without this.
run: cmake -S . -B build -DGLFW_BUILD_WAYLAND=OFF -DCMAKE_POLICY_VERSION_MINIMUM=3.5 ${{ matrix.flags }}

- name: Build
run: cmake --build build -j

- name: Test
env:
LIBGL_ALWAYS_SOFTWARE: "1"
run: ctest --test-dir build --output-on-failure
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ build
Makefile
CMakeFiles
CMakefiles
dependencies/glad/CMakeFiles
dependencies/glad/CMakeFilesimgui.ini
77 changes: 77 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

Pancake is a small 2D C++17 game engine built as a **static library** (`pancake`). Games are normally built from a separate template project (https://github.com/RohanFredriksson/PancakeTemplate) that adds this repo as a CMake subdirectory and links `pancake`. This repo has its own tests and a demo sandbox in `examples/demo.cpp`. Dependencies: GLFW, GLM, glad (vendored), stb (vendored), nlohmann/json, Dear ImGui, SoLoud (author's fork).

## Build and test

```bash
git submodule update --init --recursive
cmake -S . -B build -DGLFW_BUILD_WAYLAND=OFF # add -DPANCAKE_ASAN=ON for AddressSanitizer
cmake --build build -j
ctest --test-dir build --output-on-failure # all tests
ctest --test-dir build -L unit # only the tests that need no window
ctest --test-dir build -R test_physics # one test executable
./build/tests/test_physics # run directly to see each case
./build/examples/pancake_demo [--frames N]
```

- On Linux, install `libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl1-mesa-dev libasound2-dev xvfb`. With CMake 4+, add `-DCMAKE_POLICY_VERSION_MINIMUM=3.5`, because the bundled glad/glm/json declare old minimums.
- Options `PANCAKE_BUILD_TESTS` and `PANCAKE_BUILD_EXAMPLES` default to on only when this is the top level project. `PANCAKE_ASAN` applies to `pancake` publicly, so test and demo binaries get it too.
- CI (`.github/workflows/ci.yml`) runs an ASan build and a Release build, both on ubuntu-24.04 under Xvfb.

### Tests

There is no external test framework. `tests/test.hpp` provides `TEST("name") {...}`, `CHECK`, `CHECK_NEAR` and `RUN_TESTS()`. Each `tests/test_*.cpp` is its own executable, registered in `tests/CMakeLists.txt`:
- **Unit tests** (`pancake_test`) build physics bodies and a `World` directly, with no window and no scene. Use `tests/helpers.hpp` (`PancakeTest::Bodies`), which owns the entities. LeakSanitizer runs with `tests/lsan.supp`, which suppresses leaks inside audio and GL drivers.
- **Engine tests** (`pancake_engine_test`, and `demo_smoke` in `examples/`) run the real loop in a window, wrapped in `xvfb-run` when it is available. Leak detection is off for these, because Mesa leaks from libraries it unloads, which can't be suppressed by name. `tests/test_engine.cpp` scripts the loop from a `Driver` component. Physics runs in real time, so it waits for a condition (the box settling) and then times later steps relative to that, never to absolute frame numbers.

## Architecture

**Engine/Window loop** (`core/engine.cpp`, `core/window.cpp`). `Pancake::*` free functions are a thin facade over the `Window` namespace, which holds all global state (GLFW window, current `Scene*`, shaders, entity-picking framebuffer).
- Calls such as `load`, `save`, `reset`, `title`, and `size` only **set flags**, which the main loop applies at the top of the next frame. `load(fn)` runs the init function against the current scene, and `reset()` replaces the scene with an empty one.
- A component that resets the scene deletes itself, so tests and demos must `load` a new driver in the same frame.
- Each frame does `scene->update(dt)`, then renders twice: once into an RGB32F framebuffer with the entity shader (sprite entity IDs for `Window::readPixel`) and once to the screen. Then `DebugDraw::render()` runs, followed by ImGui (`Console::render`, `Inspector::render`).
- Shutdown deletes the scene, then the GL resources, then ImGui.

**Scene → Entity → Component** (`core/`). `Scene` owns `Entity*`s plus a `Camera`, a `Renderer` (sprite batches), a physics `World`, and a `SpatialHashGrid<Entity*>`. That grid is refreshed every frame for entities whose bounds (transform plus rigidbody) changed, and it backs `getEntitiesIn/Near/At`. Entities own `Component*`s. IDs come from static counters, and loading from JSON bumps the counter past loaded IDs. Lifecycle:
- `start()` runs when the entity is added to a started scene. Most components self-register with scene systems in `start()` through the global `Window::getScene()` (e.g. `SpriteRenderer` → `getRenderer()->add`, `Rigidbody` → `getPhysics()->add`) and unregister in `end()`.
- `kill()` marks a component dead and calls `end()`. The owning update loop deletes dead entities and components afterwards.
- `Scene::~Scene` kills every entity before deleting any systems, so components can unregister while the systems still exist.
- `TransformableComponent` adds a position/size/rotation offset relative to the entity.

**Serialisation / reflection** (`core/factory.hpp`). `REGISTER(Base, Derived)` registers a default constructor under the class name. `FACTORY(Component).create(type)` is used by `Entity::load`, and `FACTORY(Collider)` by `Rigidbody::load`. Every serialisable component needs a default constructor, a `serialise()`/`load(json)` pair that chains to its base class, and a `REGISTER` line. New fields added to existing components should be optional on load, so older save files keep loading (see `gravityScale`). `Scene::serialise` also writes gravity and the asset pools (fonts, spritesheets, sprites, audio).

**Physics** (`physics/`). `Rigidbody` is a component holding non-component `Collider`s (`BoxCollider`, `CircleCollider`), which it owns. Mass, centroid, moment and bounds are lazily recomputed via dirty flags. A body with zero total collider mass is static.
- Rigidbodies and `ForceGenerator`s keep a `World*` (set by `World::add`/`addForceGenerator`) and unregister through it, not through the global scene. The `World` never deletes them, because their entities own them.
- `World::fixedUpdate` runs at 1/60 s, at most 5 steps per frame:
1. Force generators add forces.
2. `integrateVelocity` adds forces and world gravity × `gravityScale`.
3. Pairs are found through the `SpatialHashGrid<Rigidbody*>` and ordered by id.
4. Narrow phase runs (`collision.cpp`) and `CollisionListener` components are notified.
5. Velocities are solved with warm-started sequential impulses, using a contact cache keyed by body id pair.
6. `integratePosition` moves the bodies.
7. Positions are corrected once.

This order matters: integrating positions before solving made stacks jitter and collapse.
- Collision features use one convention: the normal points from the first collider to the second, depth is the full penetration, and the point lies midway between the surfaces.
- Forces are opt-in per body: `rigidbody->addForceGenerator("Gravity")` subscribes the body to the generator component of that type name.
- `World::raycast(ray, maxDistance)` walks grid cells along the ray.
- Physics debug drawing is off by default (`World::setDebugDraw`, console `debug`).

**Rendering** (`graphics/`). `Renderer` groups `SpriteRenderer`s into `RenderBatch`es by z-index (max 1000 quads and 7 textures per batch). `TextRenderer` and `Animation` create child `SpriteRenderer`s. Shader sources are constants in `asset/shaders.hpp`, and the default font (Pixellari) is embedded in `asset/fonts.hpp`. Assets are cached by name in the namespaces in `asset/assetpool.hpp`. `DebugDraw` draws immediate-mode lines with a lifetime in frames.

**Tools** (`core/console.cpp`, `core/inspector.cpp`). The console has a command registry (`Console::registerCommand`, `Console::execute`). The inspector picks the clicked entity with the picking texture, falling back to scene bounds for entities without sprites. It stores the selection as an id so it never dereferences a deleted entity, and it draws each component's `imgui()`.

**Audio** (`audio/`). A SoLoud wrapper. `AudioEngine::init/destroy` wraps `Window::start()` in `Pancake::start()`.

## Conventions

- Namespace `Pancake`. Internal helpers live in anonymous namespaces inside `.cpp` files. Headers include their dependents at the *bottom* (e.g. `component.hpp` includes `entity.hpp` after the class) to break include cycles.
- Include `<glad/glad.h>` before anything that pulls in GLFW (including `pancake/core/listener.hpp`), or glad's header guard errors.
- Prefer fully qualified `std::`/`glm::` names in new code. Some headers still export `using std::string; using glm::vec2;` etc., and other code relies on that.
- Single-line `if (...) {...}` braces; British spelling (`serialise`, `colour`).
- Errors are printed as `ERROR::AREA::DETAIL` to stdout. Load functions return `false`/`nullptr` on malformed JSON instead of throwing.
46 changes: 34 additions & 12 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
cmake_minimum_required(VERSION 3.14)

project(pancake)
project(pancake CXX C)

# Only build the tests and examples by default when this is the top level project.
if (CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR)
set(PANCAKE_TOP_LEVEL ON)
else()
set(PANCAKE_TOP_LEVEL OFF)
endif()

option(PANCAKE_BUILD_TESTS "Build the Pancake tests" ${PANCAKE_TOP_LEVEL})
option(PANCAKE_BUILD_EXAMPLES "Build the Pancake examples" ${PANCAKE_TOP_LEVEL})
option(PANCAKE_ASAN "Build Pancake and everything linking it with AddressSanitizer" OFF)

add_subdirectory(dependencies/glfw/)
add_subdirectory(dependencies/glad/)
Expand All @@ -24,25 +35,27 @@ target_include_directories(imgui PUBLIC dependencies/imgui/backends)
if (WIN32)
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c11 -Wall -g")
set(CMAKE_EXE_LINKER_FLAGS "-static-libgcc -static-libstdc++")
set(CMAKE_VERBOSE_MAKEFILE ON)
else()
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c11 -Wall -g")
set(CMAKE_EXE_LINKER_FLAGS "-static-libgcc -static-libstdc++ -fsanitize=address")
set(CMAKE_VERBOSE_MAKEFILE ON)
if (NOT PANCAKE_ASAN)
set(CMAKE_EXE_LINKER_FLAGS "-static-libgcc -static-libstdc++")
endif()
endif()

include_directories(include)
include_directories(dependencies)

file(GLOB_RECURSE source_files src/*.cpp)
file(GLOB_RECURSE source_files CONFIGURE_DEPENDS src/*.cpp)
add_library(${PROJECT_NAME} ${source_files})

target_include_directories(${PROJECT_NAME} PUBLIC glad)
target_include_directories(${PROJECT_NAME} PUBLIC glfw)
target_include_directories(${PROJECT_NAME} PUBLIC glm)
target_include_directories(${PROJECT_NAME} PUBLIC nlohmann_json)
target_include_directories(${PROJECT_NAME} PUBLIC imgui)
target_include_directories(${PROJECT_NAME} PUBLIC soloud)
target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17)
set_target_properties(${PROJECT_NAME} PROPERTIES CXX_EXTENSIONS OFF)

if (PANCAKE_ASAN)
target_compile_options(${PROJECT_NAME} PUBLIC -fsanitize=address -fno-omit-frame-pointer -g)
target_link_options(${PROJECT_NAME} PUBLIC -fsanitize=address)
endif()

target_include_directories(${PROJECT_NAME} PUBLIC include/)
target_include_directories(${PROJECT_NAME} PUBLIC dependencies/)

Expand All @@ -51,4 +64,13 @@ target_link_libraries(${PROJECT_NAME} PUBLIC glfw)
target_link_libraries(${PROJECT_NAME} PUBLIC glm)
target_link_libraries(${PROJECT_NAME} PUBLIC nlohmann_json)
target_link_libraries(${PROJECT_NAME} PUBLIC imgui)
target_link_libraries(${PROJECT_NAME} PUBLIC soloud)
target_link_libraries(${PROJECT_NAME} PUBLIC soloud)

if (PANCAKE_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()

if (PANCAKE_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
57 changes: 52 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ cmake ..
make
```

On Linux, GLFW and SoLoud need the X11, OpenGL and ALSA development packages:
```
sudo apt-get install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl1-mesa-dev libasound2-dev
```

## Building the Engine on its Own
The engine can also be built directly, which builds its tests and a demo sandbox.
```
git submodule update --init --recursive
cmake -S . -B build
cmake --build build -j
ctest --test-dir build --output-on-failure
./build/examples/pancake_demo
```

| Option | Default | Description |
| --- | --- | --- |
| `PANCAKE_BUILD_TESTS` | On when top level | Build the unit and engine tests. |
| `PANCAKE_BUILD_EXAMPLES` | On when top level | Build the demo sandbox in `examples/`. |
| `PANCAKE_ASAN` | Off | Build the engine, and everything linking it, with AddressSanitizer. |

The engine tests open a real window. On a machine without a display, they run inside `xvfb-run` when it is installed.

## Example
```c++
#include "pancake/pancake.hpp"
Expand Down Expand Up @@ -65,7 +88,6 @@ void TestInit(Scene* scene) {
Entity* entity;
MousePanHandler* mousepanhandler;
Rigidbody* rigidbody;
CircleCollider* circle;
BoxCollider* box;

// Set the scene name
Expand All @@ -77,7 +99,10 @@ void TestInit(Scene* scene) {
entity->addComponent(mousepanhandler);
scene->addEntity(entity);

// Ground
// Gravity applies to every body by default. It can be changed per scene, or per body with setGravityScale.
scene->getPhysics()->setGravity(vec2(0.0f, -10.0f));

// Ground. A body with no mass is static.
entity = new Entity(vec2(0.0f, -5.0f), vec2(1.0f, 1.0f), 0.0f);
rigidbody = new Rigidbody();
box = new BoxCollider();
Expand Down Expand Up @@ -110,13 +135,35 @@ int main(int argc, char* argv[]) {
## Features
The engine provides the following features:
- 2D graphics rendering using OpenGL.
- 2D rigidbody physics engine supporting arbitrary collider shapes.
- 2D rigidbody physics with box and circle colliders, combinable into compound bodies, solved with warm started sequential impulses so stacks stay stable.
- World gravity, and force generator components for custom forces that act only on the bodies that opt in.
- Collision listeners, sensors and raycasts.
- Spatial queries to find entities in an area, near a point or under the mouse.
- Audio playback and management using SoLoud.
- Input management for keyboard, mouse and gamepad.
- Input management for keyboard and mouse.
- Entity-component system for managing game objects.
- Resource loading from file system.
- JSON serialization and deserialization for game data.
- JSON serialization and deserialization for game data, including custom components.
- An in-game console with built-in commands (`help`, `save`, `load`, `reset`, `debug`, `gravity`, `entities`, `inspector`) and support for your own.
- An entity inspector: click an entity to view and edit its components.
- Debug drawing for collision shapes and other debugging purposes.

## Debugging Tools
```c++
Pancake::console(true); // Open the console. Pancake::console() toggles it.
Pancake::inspector(true); // Open the inspector. Pancake::inspector() toggles it.
Pancake::debug(true); // Draw physics colliders, bounds and contacts.

// Add a console command.
Console::registerCommand("spawn", "spawn <x> <y>: Spawn a crate.", [](std::vector<std::string> args) {
// ...
});
```

## Upgrading
- Physics scenes now have world gravity, defaulting to `(0, -10)`. If you previously added a `Gravity` force generator to every body to get gravity, remove it or set the world gravity to zero with `scene->getPhysics()->setGravity(vec2(0.0f, 0.0f))`.
- The collision solver was rewritten, so restitution and friction may feel slightly different. Bounces below 1 unit per second are no longer applied, so resting bodies don't jitter.
- `KeyListener::isKeyBeginDown` and `MouseListener::isMouseBeginDown` are now true for the whole frame a key or button was pressed in, rather than only for the first caller.

## Contributions
Contributions are welcome! If you find any bugs or have suggestions for improvements, feel free to open an issue or submit a pull request on GitHub.
14 changes: 14 additions & 0 deletions examples/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
add_executable(pancake_demo demo.cpp)
target_link_libraries(pancake_demo PRIVATE pancake)

# Run the demo for a few hundred frames as a smoke test of the whole engine.
if (PANCAKE_BUILD_TESTS)
find_program(XVFB_RUN xvfb-run)
if (XVFB_RUN)
add_test(NAME demo_smoke COMMAND ${XVFB_RUN} -a -s "-screen 0 1024x768x24" $<TARGET_FILE:pancake_demo> --frames 300)
else()
add_test(NAME demo_smoke COMMAND pancake_demo --frames 300)
endif()
set_tests_properties(demo_smoke PROPERTIES ENVIRONMENT "ASAN_OPTIONS=detect_leaks=0" LABELS engine TIMEOUT 120
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
endif()
Loading
Loading