u/CryptFallGame

▲ 11 r/bevy+1 crossposts

I Replaced baked pixel-art dungeon props with real-time SDF shaders in Bevy — before/after, looking for feedback & ideas

Hey all — working solo on CryptFall, a 2D roguelite dungeon crawler in Bevy 0.15, and just wrapped an experiment I'd love feedback on.

Why I started this: a lot of my world props and decor just didn't feel like they belonged in the world — they read as pasted-on clutter rather than objects that actually lived in the room, which made the dungeon feel flatter and less alive than I wanted. Digging into why, I traced it back to a technical ceiling.

The problem: my "hero" props (pillars, barrels, tagged-room furniture) were procedurally-generated pixel art, baked to 32×32 PNGs at build time by a custom generator, then loaded as ordinary Sprites. No matter how much I tuned the CPU-side lighting math, a small object's cross-section only had a handful of texels to carry a shading gradient across — that pixel budget was a hard ceiling I kept slamming into, and it's a big part of why nothing quite sold as "a real object sitting in this room."

What I did instead: moved these props onto a Material2d/AsBindGroup/WGSL pipeline and draw them as signed-distance-field shapes directly in the fragment shader — circles for pillars, capsules for barrels, and hand-composited unions of line-segment + disc primitives for the tagged furniture (weapon rack, treasure pile, bedroll, chain cage). Antialiasing comes from an fwidth()-based smoothstep over the distance field, and lighting is a per-pixel cosine/Lambertian term computed live instead of anything baked in. Continuous math instead of a fixed grid — no more texel ceiling.

Before/after screenshots attached.

This image is of before the changes.

This is an image of after the changes. Currently only affecting a few of the objects.

Still very much WIP (this is a feature branch, not merged): furniture placement needed a couple of follow-up passes to actually read as "resting against a wall" instead of floating in the middle of the room, and I haven't touched the ~24 plain floor-debris decor variants or the floor/wall tiles themselves yet — that last one's the scary one, since it's 35k+ tiles and can't be one draw call per tile the way these props are, so it'll need a single big procedural quad instead.

Curious what this community thinks:

  • Anyone pushed SDF shape rendering this far in a 2D Bevy game before? Pitfalls I should know about before I commit to this for floor/walls too?
  • Techniques worth stealing to make the shading feel more "in the world" — next up I'm looking at reacting to actual nearby torch position/color instead of a fixed light direction.
  • Honest gut-check on whether this reads as an improvement — I've been staring at it too long to trust my own eyes.

Happy to share more shader code if it's useful to anyone.

reddit.com
u/CryptFallGame — 9 days ago

CryptFall — a solo-dev Rust roguelite (built with Bevy), free on itch.io

CryptFall is a bullet-hell dungeon-crawler roguelite I've been building solo in Rust, using the Bevy engine — pick a class, fight through swarms of enemies, build a run out of relics and weapon upgrades, and push deeper through rotating biomes toward whatever boss is waiting. Runs are seeded, so a good (or brutal) layout can be replayed or shared with a friend.

Some of the scope, for context on what one person + Rust can get through in a few months of steady work:

- 4 playable classes, 5 weapons, 20 relics, 3 unique bosses, and a whole risk/reward system layered across relics and level-ups

- Procedural dungeon generation across 8 biome themes, with dynamic per-tile lighting and real shadow casting

- Local 2-player co-op with fully independent per-player progression

- Everything content-related (bosses, relics, weapons, enemies) is data-driven off small struct definitions rather than hand-written per-item logic, which has made adding new content stay cheap even as the game's grown a lot

It's currently free and in early access on itch.io — actively updated, with an eventual Steam release the long-term goal once there's more of a community around it.

Current example of the dynamic lighting system, all art assets are placeholders

Happy to answer anything about the Rust/Bevy side of building it, or just looking for people to try it and tell me what's fun/frustrating: [Try it for free today]

reddit.com
u/CryptFallGame — 15 days ago
▲ 26 r/bevy

I've been building a bullet-hell dungeon-crawler roguelite in Bevy — solo dev, now on itch.io

CryptFall is a dungeon-crawler roguelite I've been building solo in Rust and Bevy — pick a class, fight through swarms of enemies, build a run out of relics and weapon upgrades, and push deeper through rotating biomes toward whatever boss is waiting. Runs are seeded, so a good (or brutal) layout can be replayed or shared.

A few things that might be interesting to this sub specifically:

- Procedural dungeon generation across 8 rotating biome themes, with secret and locked rooms

- Dynamic per-torch lighting with real line-of-sight and shadow casting

- Everything — bosses, relics, weapons, enemies, level-up cards — is built on the same data-driven template pattern: a `Def` struct + a registry array, so adding new content almost never touches the systems that drive it

- Local 2-player co-op with fully independent per-player progression (own class, weapons, relics, abilities, hotbar, and light source)

- 4 classes, 5 weapons, 20 relics across 4 rarity tiers, 3 unique bosses (one per active biome zone, more coming), and a whole risk/reward layer (Cursed relics, Risky level-up cards) added in the latest patch

It's been in active, fairly rapid development for a few months now — currently early access on itch.io, free, with an eventual Steam release as the goal once there's more of a community built up around it.

Current Example of the dynamic lighting system. All art assets are placeholder assets.

Would love thoughts from anyone who's built something similar in Bevy, or just wants to try it out: [Try it out here]

reddit.com
u/CryptFallGame — 15 days ago
▲ 4 r/bevy+1 crossposts

How CryptFall's boss "heavy attack" telegraphs work — reusing components instead of building new ones

CryptFall is a bullet-hell roguelite I'm building solo in Rust/Bevy. This patch added a phase-2-only "heavy attack" to every boss — a much bigger, longer-charging strike than their normal shots. The interesting part wasn't the attack itself, it was realizing I already had everything I needed to telegraph it.


Bosses already had two telegraph components from an earlier pass — a warning system that gives players a beat's notice before any attack fires:


```rust
struct AttackTelegraphRing { timer: f32, max_lifetime: f32, end_size: f32 }
struct AttackTelegraphLine { timer: f32, max_lifetime: f32 }
```


Both fields are per-instance, not hardcoded constants — `timer`/`max_lifetime` live on the spawned entity, not baked into the type. That meant when I needed a 
*much*
 longer, 
*much*
 bigger telegraph for the new heavy attacks (1.0–1.6s charge-up depending on the boss, vs. a fraction of a second for a normal shot), I didn't need a new component or a new rendering system — just a different call:


```rust
spawn_heavy_telegraph(&mut commands, &textures, origin, player_pos, def.heavy_charge_time);
```


Same ring, same line, just a longer `max_lifetime`, a bigger `end_size`, and a hot-amber tint instead of the standard red — enough to make "this is different, and bigger" read instantly without a single new asset.


The attacks themselves are plain function pointers on each boss's data-driven definition:


```rust
pub heavy_attack: Option<fn(&mut Commands, &TextureAssets, Vec2, Vec2, f32)>,
pub heavy_cd_range: [f32; 2],
pub heavy_charge_time: f32,
```


`None` means that boss doesn't have one yet — adding a new heavy attack to an existing boss, or giving a totally new boss one, is a data change in one array literal, not a new system. The AI loop just checks `if let Some(f) = def.heavy_attack` and calls it — no branching on which boss it is anywhere in the actual logic.


One small deliberate quirk: the cooldown re-rolls to a random value in `heavy_cd_range` after every shot (instead of a fixed cadence), specifically so the attack can't be timed or memorized — a data field, not a special case in the AI code.


Total new code for the feature: one new function (`spawn_heavy_telegraph`), one new AI branch, and a few new fields per boss definition. No new components, no new rendering path. The lesson that's stuck with me building this: when I go to add a "bigger" version of something that already exists, the first question is whether the existing thing was already parameterized enough to just be called differently — more often than I expect, it was.


CryptFall's on itch.io if anyone wants to see it in motion: [https://mobtv.itch.io/cryptfall]**How CryptFall's boss "heavy attack" telegraphs work — reusing components instead of building new ones**


CryptFall is a bullet-hell roguelite I'm building solo in Rust/Bevy. This patch added a phase-2-only "heavy attack" to every boss — a much bigger, longer-charging strike than their normal shots. The interesting part wasn't the attack itself, it was realizing I already had everything I needed to telegraph it.


Bosses already had two telegraph components from an earlier pass — a warning system that gives players a beat's notice before any attack fires:


```rust
struct AttackTelegraphRing { timer: f32, max_lifetime: f32, end_size: f32 }
struct AttackTelegraphLine { timer: f32, max_lifetime: f32 }
```


Both fields are per-instance, not hardcoded constants — `timer`/`max_lifetime` live on the spawned entity, not baked into the type. That meant when I needed a *much* longer, *much* bigger telegraph for the new heavy attacks (1.0–1.6s charge-up depending on the boss, vs. a fraction of a second for a normal shot), I didn't need a new component or a new rendering system — just a different call:


```rust
spawn_heavy_telegraph(&mut commands, &textures, origin, player_pos, def.heavy_charge_time);
```


Same ring, same line, just a longer `max_lifetime`, a bigger `end_size`, and a hot-amber tint instead of the standard red — enough to make "this is different, and bigger" read instantly without a single new asset.


The attacks themselves are plain function pointers on each boss's data-driven definition:


```rust
pub heavy_attack: Option<fn(&mut Commands, &TextureAssets, Vec2, Vec2, f32)>,
pub heavy_cd_range: [f32; 2],
pub heavy_charge_time: f32,
```


`None` means that boss doesn't have one yet — adding a new heavy attack to an existing boss, or giving a totally new boss one, is a data change in one array literal, not a new system. The AI loop just checks `if let Some(f) = def.heavy_attack` and calls it — no branching on which boss it is anywhere in the actual logic.


One small deliberate quirk: the cooldown re-rolls to a random value in `heavy_cd_range` after every shot (instead of a fixed cadence), specifically so the attack can't be timed or memorized — a data field, not a special case in the AI code.


Total new code for the feature: one new function (`spawn_heavy_telegraph`), one new AI branch, and a few new fields per boss definition. No new components, no new rendering path. The lesson that's stuck with me building this: when I go to add a "bigger" version of something that already exists, the first question is whether the existing thing was already parameterized enough to just be called differently — more often than I expect, it was.


CryptFall's on itch.io if anyone wants to see it in motion: [https://mobtv.itch.io/cryptfall]
u/CryptFallGame — 15 days ago