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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ build engineering, and honest performance evidence in a codebase a reviewer can

## What it demonstrates

The collision system supports **bidirectional categories and inclusion masks**.
Give each body one category bit and the categories it accepts; an overlap is
reported only when both bodies include the other. A zero mask disables matching
without hiding, deleting or stopping the entity. Sprite rendering layers remain
independent. The strict scene-v2 grammar stores the filters, with explicit v1
default migration and filter-inclusive replay fingerprints.

Run `forge2d_arena assets/collision-layers.scene out/layers` to see a fictional
player/character/pickup-zone example. The actual CLI writes initial/final matched
pair CSV, canonical v2 scene, replay and rendered PPM. See [format/API](docs/asset-format.md)
and [validation/limits](docs/reports/practical-workflows-20261109.md). These are
overlap queries; physical object collision response and trigger callbacks remain
application responsibilities.

- C++20 engine core with strict ownership and no runtime dependency.
- C17 linear arena as a narrow, tested cross-language/platform boundary.
- Generation-safe entity handles and sparse-set component storage with expected O(1) access,
Expand Down
29 changes: 28 additions & 1 deletion apps/forge2d_arena.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <sstream>
#include <stdexcept>
#include <string>
Expand Down Expand Up @@ -65,13 +66,37 @@ int main(int argc, char** argv) { // NOLINT(bugprone-exception-escape)
argc > 2 ? std::filesystem::path{argv[2]} : std::filesystem::path{"out/demo"};
const auto scene = forge2d::parse_scene(read_text(scene_path));
auto world = forge2d::World::from_scene(scene);
const auto initial_pairs = world.collision_pairs();
const auto replay = build_autopilot(360U);
for (const auto frame : replay.frames) {
world.step(frame);
}
const auto final_pairs = world.collision_pairs();
forge2d::Image image{640U, 360U};
forge2d::render_world(image, world);
std::filesystem::create_directories(output_directory);
std::ofstream contacts{output_directory / "collision-pairs.csv"};
contacts.exceptions(std::ios::badbit | std::ios::failbit);
contacts << "snapshot,tick,first_index,first_generation,second_index,second_generation,"
"first_category,first_mask,second_category,second_mask\n";
std::map<forge2d::Entity, forge2d::CollisionFilter> filters;
for (const auto& entity : world.snapshot())
filters.emplace(entity.entity, entity.body.filter);
const auto write_pairs = [&](const char* snapshot, std::uint64_t tick, const auto& pairs) {
for (const auto& pair : pairs) {
const auto& first = filters.at(pair.first);
const auto& second = filters.at(pair.second);
contacts << snapshot << ',' << tick << ',' << pair.first.index << ','
<< pair.first.generation << ',' << pair.second.index << ','
<< pair.second.generation << ',' << first.category << ',' << first.mask
<< ',' << second.category << ',' << second.mask << '\n';
}
};
write_pairs("initial", 0U, initial_pairs);
write_pairs("final", world.tick(), final_pairs);
std::ofstream canonical{output_directory / "scene-v2.scene"};
canonical.exceptions(std::ios::badbit | std::ios::failbit);
canonical << forge2d::serialize_scene(scene);
image.write_ppm(output_directory / "arena-final.ppm");
std::ofstream{output_directory / "autopilot.replay"} << forge2d::serialize_replay(replay);

Expand All @@ -83,7 +108,9 @@ int main(int argc, char** argv) { // NOLINT(bugprone-exception-escape)
<< " \"engine_version\": \"" << FORGE2D_VERSION << "\",\n"
<< " \"ticks\": " << world.tick() << ",\n"
<< " \"entities\": " << world.entity_count() << ",\n"
<< " \"collisions\": " << world.collision_pairs().size() << ",\n"
<< " \"collisions\": " << final_pairs.size() << ",\n"
<< " \"initial_filtered_pairs\": " << initial_pairs.size() << ",\n"
<< " \"canonical_scene_schema\": 2,\n"
<< " \"state_hash\": " << world.state_hash() << ",\n"
<< " \"pixel_hash\": " << image.pixel_hash() << ",\n"
<< " \"arena_peak_bytes\": " << arena.peak << "\n"
Expand Down
8 changes: 8 additions & 0 deletions assets/collision-layers.scene
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FORGE2D_SCENE 2
bounds 0 0 10000 10000
entities 5
entity player 750 750 0 0 220 220 16711935 10 1 1 6
entity character 1000 750 0 0 200 200 4278190335 5 0 2 1
entity pickup-zone 1000 750 0 0 300 300 65535 4 0 4 1
entity decoration 1000 750 0 0 300 300 2493757695 2 0 8 0
entity isolated-character 1000 750 0 0 200 200 4294902015 3 0 2 0
13 changes: 13 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ remaining fraction for presentation interpolation; interpolation does not mutate

## Collision

`CollisionFilter {category, mask}` belongs to each `KinematicBody` and raw
`CollisionItem`. `category` is exactly one of32 bits, including `0x80000000`;
zero/multi-bit categories are rejected before query/spawn/serialization. `mask`
may contain any32-bit combination; zero matches nothing. Both directions must
allow the other's category before geometry is tested. Defaults(category1, all
mask bits) preserve legacy inclusion behavior. Sprite.layer affects drawing only.
Raw queries operate on supplied handles; only the World owns generation validity.

Filters do not apply forces, block movement, remove entities or infer callbacks.
World passes each body's filter to the existing spatial query. State fingerprints
now include category/mask, so historic numeric fingerprints change even when
v1 scenes retain equivalent default behavior. Replay input grammar remains v1.

`SpatialHash(cell_size)::query(items)` performs broadphase candidate generation and exact AABB
filtering. `brute_force_collisions(items)` is the O(n²) reference oracle. Edge contact alone is not
penetration. Cell size and item half extents must be positive. Queries accept at most 100,000 items,
Expand Down
21 changes: 20 additions & 1 deletion docs/asset-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ entity <name> <x> <y> <vx> <vy> <half-x> <half-y> <rgba> <layer> <player>
The repository-authored [arena scene](../assets/arena.scene) contains only primitive/color data and
has no external or copyrighted art dependency.

## Scene version 2

```text
FORGE2D_SCENE 2
bounds <min-x> <min-y> <max-x> <max-y>
entities <count>
entity <name> <x> <y> <vx> <vy> <half-x> <half-y> <rgba> <layer> <player> <category> <mask>
```

All v1 constraints remain. Category/mask are unsigned32-bit decimal fields;
category must be a nonzero single bit, mask may be zero. Negative/overflowing,
missing or extra fields fail closed. Version1 inputs remain readable with
category1/all mask bits. Scene writers now emit canonical version2. The
[layer example](../assets/collision-layers.scene) demonstrates two-way inclusion
and an intentionally disabled mask. This affects reported AABB pairs only.

## Replay version 1

```text
Expand All @@ -40,6 +56,9 @@ random number generator.

## Compatibility

Writers always emit version one. A changed grammar or semantic interpretation requires a new
Scene writers emit version2 and replay writers version1. Legacy scene readers migrate only the
documented defaults, with no field loss. State hashing includes the two filter words, so old
numeric state hashes are intentionally not compatible with this new fingerprint layout.
A changed grammar or semantic interpretation requires a new
header version and migration test; silently accepting new fields would make replay evidence
ambiguous. Unknown future versions fail closed.
Loading
Loading