An experiment in spec-driven AI development of a complex game engine.
This project explores what happens when you give Claude detailed specifications and let it build a Noita-style falling sand engine from scratch. The goal is to understand both the capabilities and failure modes of AI-assisted development on non-trivial systems.
Every component of this engine was designed through specification documents and implemented by Claude. Human involvement is limited to:
- Writing specifications that describe what to build
- Reviewing and approving implementation plans
- Catching AI mistakes before they compound
- Documenting failure patterns for future reference
The codebase serves as both a functional game engine and a record of AI development patterns—both successful and disastrous.
A Bevy plugin that handles the hard parts of pixel simulation games:
- Infinite streaming worlds that load chunks around the camera
- Cellular automata simulation with four aggregate states: solid, powder, liquid, gas
- Data-driven materials - define physics and interactions in TOML
- Automatic collision meshes generated from pixel data via marching squares
- Destructible pixel bodies - rigid bodies made of pixels that take damage
- Full persistence - save and load worlds on native and WASM (via OPFS)
Physics backends: Avian2D or Rapier2D.
See docs/llm-cases/ for documented cases where Claude made decisions that hurt the codebase.
These aren't fixable through better prompting—they're artifacts of how LLMs work. The goal is to accumulate enough concrete examples to inform a better methodology for spec-driven LLM development.
# Run the example game
just run # or: cargo run -p game --release
# Development mode (dynamic linking)
just dev # or: cargo run -p game --features dev
# Run tests
just test # or: cargo test -p bevy_pixel_world
# WASM development server
just serve # or: cd crates/game && trunk serve
# Build NoiseTool (required for noise profile editing in level editor)
just build-noise-tool| Input | Action |
|---|---|
| LMB | Paint |
| RMB | Erase |
| Scroll | Brush size |
| Ctrl+S | Save |
| / | Toggle console |
| Command | Description |
|---|---|
tp <x> <y> |
Teleport player to coordinates |
time <value> |
Set time of day (e.g. 6am, 18, 14:30) |
spawn <object> |
Spawn object above player (bomb, box, femur) |
creative |
Toggle creative mode (paint/erase pixels) |
- Infinite streaming chunks around camera
- Cellular automata: solid, powder, liquid, gas
- Data-driven materials (TOML)
- Marching squares collision meshes
- Destructible pixel bodies with physics
- Full persistence (native/WASM OPFS)
Core plugin for infinite cellular automata simulation.
app.add_plugins(PixelWorldPlugin::new(
PersistenceConfig::at("world.save").with_seed(42)
));Convenience bundle adding all sub-plugins (bodies, buoyancy, diagnostics).
app.add_plugins(
PixelWorldFullBundle::new(PersistenceConfig::at("world.save"))
.submersion(SubmersionConfig { threshold: 0.5, ..default() })
.buoyancy(BuoyancyConfig::default())
);Command to spawn a pixel world with a chunk seeder.
commands.spawn(SpawnPixelWorld::new(MaterialSeeder::new(42)));Marker component for cameras that drive chunk streaming.
commands.spawn((Camera2d, StreamingCamera));Returns pixel at world position. None if chunk not loaded/seeded.
Sets pixel at world position. Returns true if successful.
fn paint_system(mut worlds: Query<&mut PixelWorld>) {
let mut world = worlds.single_mut();
let pos = WorldPos::new(100, 200);
let pixel = Pixel::new(material_ids::SAND, ColorIndex(128));
world.set_pixel(pos, pixel, ());
}Swaps two pixels atomically. Works across chunk boundaries.
Returns heat value (0-255) at position's heat cell.
Sets heat value at position's heat cell.
Command to spawn a destructible physics body from an image.
| Parameter | Type | Description |
|---|---|---|
path |
impl Into<String> |
Asset path relative to assets/ |
material |
MaterialId |
Material for all pixels |
position |
Vec2 |
World spawn position |
commands.queue(SpawnPixelBody::new(
"sprites/crate.png",
material_ids::WOOD,
Vec2::new(100.0, 200.0),
));Adds extra components to the spawned entity.
commands.queue(
SpawnPixelBody::new("box.png", material_ids::WOOD, pos)
.with_extra(|entity| {
entity.insert(Bomb {
damage_threshold: 0.03,
blast_radius: 120.0,
blast_strength: 60.0,
detonated: false,
});
})
);Destructible physics object. Key methods:
| Method | Returns | Description |
|---|---|---|
width() |
u32 |
Pixel grid width |
height() |
u32 |
Pixel grid height |
is_solid(x, y) |
bool |
Whether pixel belongs to body |
get_pixel(x, y) |
Option<&Pixel> |
Pixel at local coords |
solid_count() |
usize |
Number of solid pixels |
is_empty() |
bool |
True if fully destroyed |
Radial ray-cast explosion from center point.
world.blast(&BlastParams {
center: Vec2::new(100.0, 200.0),
strength: 60.0,
max_radius: 120.0,
heat_radius: 80.0,
}, |pixel, pos| {
if pixel.material() == material_ids::STONE {
BlastHit::Hit { pixel: Pixel::void(), cost: 2.0 }
} else {
BlastHit::Hit { pixel: Pixel::void(), cost: 1.0 }
}
});Callback return value controlling ray behavior:
| Variant | Effect |
|---|---|
Skip |
Continue ray, no energy cost |
Hit { pixel, cost } |
Replace pixel, consume energy |
Stop |
Terminate ray immediately |
Procedural terrain seeder with noise-based material placement.
let seeder = MaterialSeeder::new(42);
commands.spawn(SpawnPixelWorld::new(seeder));Implement to create custom procedural generation:
impl ChunkSeeder for MySeeder {
fn seed(&self, pos: ChunkPos, chunk: &mut Chunk) {
for y in 0..CHUNK_SIZE {
for x in 0..CHUNK_SIZE {
chunk.pixels.set(x, y, Pixel::new(material_ids::STONE, ColorIndex(128)));
}
}
}
}Tracks initialization progress:
| State | Description |
|---|---|
Initializing |
Reading save file index |
LoadingChunks |
Initial chunks loading/seeding |
Ready |
Gameplay can begin |
Run condition for gameplay systems.
app.add_systems(Update, player_movement.run_if(world_is_ready));Loading screen metrics:
| Field | Type | Description |
|---|---|---|
chunks_ready |
usize |
Loaded chunk count |
chunks_total |
usize |
Total chunks needed |
fraction() |
f32 |
Progress 0.0-1.0 |
Triggers a manual save operation.
fn save_hotkey(mut persistence: ResMut<PersistenceControl>, keys: Res<ButtonInput<KeyCode>>) {
if keys.just_pressed(KeyCode::F5) {
persistence.save();
}
}Pause/resume simulation:
simulation_state.paused = true; // Freeze CA simulation| Type | Description |
|---|---|
WorldPos |
Absolute pixel position (i64, i64) |
ChunkPos |
Chunk index (i32, i32) |
LocalPos |
Pixel within chunk (0..128, 0..128) |
WorldRect |
AABB with x, y, width, height |
let world_pos = WorldPos::new(1000, 2000);
let (chunk, local) = world_pos.to_chunk_and_local();Materials defined in TOML (assets/config/materials.toml):
[[materials]]
name = "sand"
physics_state = "powder"
density = 1.5
friction = 0.3
blast_resistance = 0.5
colors = [[194, 178, 128], [189, 174, 124]]Access via material_ids:
use bevy_pixel_world::material_ids;
let sand = Pixel::new(material_ids::SAND, ColorIndex(0));
let water = Pixel::new(material_ids::WATER, ColorIndex(0));crates/
├── bevy_pixel_world/ # Core plugin
├── game/ # Example game
└── sim2d_noise/ # Noise utilities (WASM)
docs/
├── architecture/ # How things work internally
└── implementation/ # Development methodology
This repository contains code under multiple licenses:
| Path | License |
|---|---|
crates/bevy_pixel_world/ |
MIT |
crates/game/ |
MIT |
crates/noise_ipc/ |
MIT |
crates/sim2d_noise/ |
MIT |
assets/ |
MIT |
assets/sprites/cc0/ |
CC0 (OpenGameArt) |
docs/ |
MIT |
scripts/ |
MIT |
workers/ |
MIT |
vendor/bevy_crt/ |
GPL-3.0-or-later |
The CRT shader code in vendor/bevy_crt/ is derived from guest.r's crt-guest-advanced-hd shaders (GPL-3.0-or-later). This component is isolated in vendor/ and does not affect the licensing of the rest of the codebase.