feat(examples): Basic, a ball simulation with a bug to find

The example was a placeholder that printed which build it was. It is
now a headless simulation of five balls dropped onto a 10 cm floor
slab at 60 ticks a second, with a bug planted the way such bugs are
written: collision is checked only where each step lands, so the ball
dropped from 60 m, at 34 m/s, steps over the slab and falls forever.

It shows the consumer side end to end:
- Basic.DebugShim forwards Pump, Advance, QuitRequested, Log, Check,
  Emit and Sample to Crafter.Debug in debug builds and does nothing in
  plain ones, so the simulation and main have no #ifdef but the
  session's.
- Basic.DebugCommands, compiled only when Debug::Use returns true,
  registers World_Spawn, World_Reset, World_Clear and Ball_Get, and
  watches for the physics, the floor and the balls.
- The simulation checks each ball stays above the floor, emits Bounce
  and samples the fastest ball, so `pause --on-check`, `wait --event`
  and `trace --history` have something to show.

Driven over the real transport, it shows the whole loop: `log --level
warn` names the ball, `pause --on-check` stops the frame it fails,
`set physics.substeps 4` and `call World_Reset` show the fix holds
before any code changes. The plain build simulates ten seconds and
prints that one ball fell through. Its agent notes describe the app
without the bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 03:22:17 +01:00
commit 4a0d911e69
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
8 changed files with 292 additions and 9 deletions

View file

@ -1,3 +1,17 @@
# Basic
A Crafter.Debug example. Build with `crafter-build --debug-api` to include the debug server.
A Crafter.Debug example: a headless simulation of balls dropped onto a floor slab, 60 ticks a second.
- **Plain build:** `crafter-build`, run with `crafter-build -r`. It simulates ten seconds as fast as it can and prints how many balls ended up below the floor.
- **Debug build:** this project's debug flag is `--debug-api` and its define `BASIC_DEBUG_API`. The build adds Crafter.Debug, and the app runs in real time until an agent tells it to quit.
## Layout
- `interfaces/Basic.World.cppm`, `implementations/Basic.World.cpp`: the simulation.
- `interfaces/Basic.DebugShim.cppm`: Crafter.Debug's frame-loop and timeline calls, in every build. In the plain build they are stand-ins that do nothing, so the simulation and `main` need no `#ifdef`.
- `interfaces/Basic.DebugCommands.cppm`, `implementations/Basic.DebugCommands.cpp`: the commands and watches, compiled only when `Debug::Use` added Crafter.Debug.
- `implementations/main.cpp`: the loop, with the session under the define.
## Working on Crafter.Debug from here
`project.cpp` loads Crafter.Debug from this checkout (`#pragma crafter dependency "../.."`). Crafter.Network's callback API, which the transport needs, may not be on Network's `master` yet. If it isn't, build with `CRAFTER_DEBUG_NETWORK=<a Network checkout that has it>/project.cpp`.

View file

@ -0,0 +1,51 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Basic.DebugCommands;
import std;
import Crafter.Debug;
import Basic.World;
namespace Debug = Crafter::Debug;
namespace Basic {
struct SpawnArgs {
Debug::Required<float> y;
float x = 0;
float vy = 0;
float radius = 0.1f;
};
struct BallArgs {
Debug::Required<std::int32_t> id;
};
}
void Basic::RegisterDebug(World& world) {
// Commands run on the main thread, between ticks, so they touch the
// world as the simulation does.
Debug::Command("World_Spawn", "Drop a ball from height y (m), moving at vy (m/s, up is positive); returns its id.",
[&world](const SpawnArgs& args) -> Debug::Result<std::int32_t> {
if (args.radius <= 0) return Debug::Error{ "BadRadius", "radius must be above 0" };
if (*args.y - args.radius <= world.floor.top) {
return Debug::Error{ "BelowFloor", std::format("y must clear the floor's top ({} m) by the radius", world.floor.top) };
}
return world.Spawn(args.x, *args.y, args.vy, args.radius);
});
Debug::Command("World_Reset", "Remove every ball and drop the scene's five again.", [&world] { world.Reset(); });
Debug::Command("World_Clear", "Remove every ball.", [&world] { world.Clear(); });
Debug::Command("Ball_Get", "One ball's position, velocity and state.", [&world](const BallArgs& args) -> Debug::Result<Ball> {
if (const Ball* ball = world.Find(*args.id)) return *ball;
return Debug::Error{ "NoBall", std::format("no ball {}; `get balls` lists them", *args.id) };
});
Debug::Watch("physics.gravity", world.physics.gravity, { .min = -50, .max = 0, .help = "m/s^2" });
Debug::Watch("physics.restitution", world.physics.restitution, { .min = 0, .max = 1, .help = "share of speed kept by a bounce" });
Debug::Watch("physics.substeps", world.physics.substeps, { .min = 1, .max = 16, .help = "collision checks per tick" });
Debug::Watch("floor.top", world.floor.top, { .help = "m" });
Debug::Watch("floor.thickness", world.floor.thickness, { .min = 0.01, .max = 10, .help = "m" });
Debug::Watch("world.time", [&world] { return world.Time(); }, { .help = "simulated seconds since the last reset" });
Debug::Watch("world.count", [&world] { return world.Balls().size(); }, { .help = "balls in the scene" });
Debug::Watch("world.lost", [&world] { return world.Lost(); }, { .help = "balls below the floor" });
Debug::Watch("world.fastest", [&world] { return world.Fastest(); }, { .help = "m/s, last tick" });
Debug::Watch("balls", [&world] { return std::vector<Ball>(world.Balls().begin(), world.Balls().end()); });
}

View file

@ -0,0 +1,65 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Basic.World;
import std;
import Basic.DebugShim;
namespace Debug = Crafter::Debug;
void Basic::World::Reset() {
Clear();
constexpr std::array<float, 5> heights = { 1, 3, 8, 20, 60 };
for (std::size_t i = 0; i < heights.size(); ++i) {
Spawn(static_cast<float>(i) - 2.0f, heights[i], 0, 0.1f);
}
}
void Basic::World::Clear() {
balls.clear();
time = 0;
fastest = 0;
}
std::int32_t Basic::World::Spawn(float x, float y, float vy, float radius) {
const std::int32_t id = nextId++;
balls.push_back({ .id = id, .x = x, .y = y, .vy = vy, .radius = radius });
Debug::Log("world", "ball {} dropped from {:.1f} m", id, y);
return id;
}
void Basic::World::Tick(float dt) {
const std::int32_t steps = std::max(1, physics.substeps);
const float h = dt / static_cast<float>(steps);
const float bottom = floor.top - floor.thickness;
fastest = 0;
for (Ball& ball : balls) {
for (std::int32_t step = 0; step < steps; ++step) {
ball.vy += physics.gravity * h;
ball.x += ball.vx * h;
ball.y += ball.vy * h;
if (ball.lost) continue;
// A falling ball that overlaps the slab bounces off its top.
if (ball.vy < 0 && ball.y - ball.radius < floor.top && ball.y + ball.radius > bottom) {
const float speed = -ball.vy;
ball.y = floor.top + ball.radius;
ball.vy = speed * physics.restitution;
if (speed > 1) Debug::Emit("Bounce", BounceEvent{ .ball = ball.id, .speed = speed });
}
}
if (!ball.lost && !Debug::Check(ball.y + ball.radius >= bottom, "physics", "ball {} is below the floor: y {:.2f} m, vy {:.1f} m/s", ball.id, ball.y, ball.vy)) {
ball.lost = true;
}
fastest = std::max(fastest, std::hypot(ball.vx, ball.vy));
}
Debug::Sample("solver.fastest", fastest);
time += dt;
}
const Basic::Ball* Basic::World::Find(std::int32_t id) const {
const auto found = std::ranges::find(balls, id, &Ball::id);
return found == balls.end() ? nullptr : &*found;
}
std::int32_t Basic::World::Lost() const {
return static_cast<std::int32_t>(std::ranges::count(balls, true, &Ball::lost));
}

View file

@ -1,16 +1,58 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A headless simulation at 60 ticks a second. The plain build simulates ten
// seconds as fast as it can and prints what happened; the debug build runs in
// real time, serving Crafter.Debug, until an agent says quit.
import std;
import Basic.DebugShim;
import Basic.World;
#ifdef BASIC_DEBUG_API
import Crafter.Debug;
import Crafter.Thread;
import Basic.DebugCommands;
#endif
namespace Debug = Crafter::Debug;
using Clock = std::chrono::steady_clock;
namespace {
constexpr float dt = 1.0f / 60.0f;
#ifdef BASIC_DEBUG_API
constexpr bool realTime = true;
#else
constexpr bool realTime = false;
#endif
}
int main() {
Basic::World world;
world.Reset();
#ifdef BASIC_DEBUG_API
std::println("basic: debug build, Crafter.Debug {}", Crafter::Debug::Version());
#else
std::println("basic: plain build");
// The session's transport runs on Crafter.Thread's pool. An app that
// already uses the pool starts it as usual; this one only needs it here.
Crafter::ThreadPool::Start();
Debug::Session debug({ .name = "basic" });
Basic::RegisterDebug(world);
std::println("basic: running; drive it with ./build/crafter-debug, stop it with ./build/crafter-debug quit");
#endif
return 0;
const auto tick = std::chrono::duration_cast<Clock::duration>(std::chrono::duration<float>(dt));
Clock::time_point next = Clock::now();
while (!Debug::QuitRequested() && (realTime || world.Time() < 10.0)) {
Debug::Pump();
if (Debug::Advance()) world.Tick(dt);
if constexpr (realTime) {
next += tick;
// After a stop in a debugger, carry on from now instead of
// racing to catch up.
if (Clock::now() - next > std::chrono::seconds(1)) next = Clock::now();
std::this_thread::sleep_until(next);
}
}
std::println("basic: {:.1f} s simulated; {} balls, {} below the floor", world.Time(), world.Balls().size(), world.Lost());
for (const Basic::Ball& ball : world.Balls()) {
if (ball.lost) std::println(" ball {} fell through the floor", ball.id);
}
}

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// What an agent can call and read in a debug build. project.cpp compiles
// this module only when Debug::Use added Crafter.Debug, so the plain build
// has none of it.
export module Basic.DebugCommands;
import Basic.World;
export namespace Basic {
// Registers the commands and watches on `world`, which must outlive the
// Session.
void RegisterDebug(World& world);
}

View file

@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Crafter.Debug's frame-loop and timeline calls, in every build: the real
// ones under --debug-api, and in a plain build stand-ins that do nothing, so
// the simulation and main need no #ifdef around them. This is one of the
// app's own sources, so the define Debug::Use adds reaches it.
export module Basic.DebugShim;
import std;
#ifdef BASIC_DEBUG_API
export import Crafter.Debug;
#else
export namespace Crafter::Debug {
inline void Pump() {}
inline bool Advance() { return true; }
inline bool Paused() { return false; }
inline std::uint32_t TakeSteps() { return 0; }
inline bool QuitRequested() { return false; }
template<class... A> void Log(A&&...) {}
template<class... A> void LogWarn(A&&...) {}
template<class... A> void LogError(A&&...) {}
template<class... A> bool Check(bool ok, A&&...) { return ok; }
template<class... A> void Emit(A&&...) {}
template<class T> void Sample(std::string_view, const T&) {}
}
#endif

View file

@ -0,0 +1,66 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Balls dropped onto a floor slab, in two dimensions, at a fixed step. y is
// up, in metres; the slab's top is at floor.top and it is floor.thickness
// thick.
export module Basic.World;
import std;
export namespace Basic {
struct Ball {
std::int32_t id = 0;
float x = 0;
float y = 0;
float vx = 0;
float vy = 0;
float radius = 0.1f;
// Below the floor, and so out of the scene for good.
bool lost = false;
};
struct Physics {
float gravity = -9.81f;
// The share of its speed a ball keeps when it bounces.
float restitution = 0.6f;
// Collision checks per tick.
std::int32_t substeps = 1;
};
struct Floor {
float top = 0;
float thickness = 0.1f;
};
// What `wait --event Bounce` waits for, sent for bounces faster than 1 m/s.
struct BounceEvent {
std::int32_t ball = 0;
float speed = 0;
};
class World {
public:
Physics physics;
Floor floor;
// Removes every ball and drops the scene's five.
void Reset();
void Clear();
// Returns the new ball's id.
std::int32_t Spawn(float x, float y, float vy, float radius);
void Tick(float dt);
const std::deque<Ball>& Balls() const { return balls; }
const Ball* Find(std::int32_t id) const;
double Time() const { return time; }
std::int32_t Lost() const;
// The fastest ball's speed in the last tick, in m/s.
float Fastest() const { return fastest; }
private:
std::deque<Ball> balls;
std::int32_t nextId = 1;
double time = 0;
float fastest = 0;
};
}

View file

@ -24,10 +24,15 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
for (const std::string_view arg : args) {
if (arg != "--debug-api") depArgs.emplace_back(arg);
}
Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", .define = "BASIC_DEBUG_API", .args = depArgs });
const bool debugApi = Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", .define = "BASIC_DEBUG_API", .args = depArgs });
std::array<fs::path, 0> interfaces = {};
std::array<fs::path, 1> implementations = { "implementations/main" };
std::vector<fs::path> interfaces = { "interfaces/Basic.DebugShim", "interfaces/Basic.World" };
std::vector<fs::path> implementations = { "implementations/Basic.World", "implementations/main" };
// The agent's commands and watches, in debug builds only.
if (debugApi) {
interfaces.emplace_back("interfaces/Basic.DebugCommands");
implementations.emplace_back("implementations/Basic.DebugCommands");
}
cfg.GetInterfacesAndImplementations(interfaces, implementations);
return cfg;
}