Dash & Slash is a one-thumb roguelite for Android and iPhone: you pull back to aim, time slows to 20%, you let go and cut through everything on the line. It's a solo project built with Flutter and Flame, and it ships without a single image or sound from an asset pack.
That wasn't a purity thing. I'm a QA engineer, not an artist or a sound designer, and I wanted three things: one consistent neon look, a small app, and assets I can change by editing a number instead of opening an image editor. Here's how that worked out, plus a bug where the level generator built a room you couldn't leave.
Sprites: a texture atlas painted at startup
All the small visual pieces (glows, sparks, rings, shards, smoke, damage digits, the words "PARRY" and "CRIT") come from one 512×512 texture atlas. It isn't a PNG in the bundle. When the game starts, it records a picture with Flutter's Canvas, draws 30 sprites into 64×64 cells and turns the result into an image.
// 0 glow: soft radial gradient.
final o = cellCenter(0);
final p = ui.Paint()
..shader = ui.Gradient.radial(o, 31, const [
ui.Color(0xFFFFFFFF),
ui.Color(0x88FFFFFF),
ui.Color(0x22FFFFFF),
ui.Color(0x00FFFFFF),
], const [0.0, 0.25, 0.6, 1.0]);
c.drawCircle(o, 31, p);
Every sprite is drawn in white. Color comes later, per instance, through a modulate blend, so a single glow sprite becomes the cyan dash trail, the magenta boss bullet and the orange fire relic.
Drawing goes through a small batch class that collects transforms, source rects and colors into preallocated typed arrays and sends them to the GPU with one drawRawAtlas call per layer. Nothing is allocated per frame, which matters when a late run has a few thousand particles on screen. The glow layers use BlendMode.plus, so overlapping light adds up instead of covering itself. That's most of the "neon" look: additive white blobs, tinted.
What I'd tell my past self: decide the cell grid early and keep a table of sprite IDs as constants. Adding sprite number 31 is then a dozen lines.
Sound: a Python synthesizer that writes WAV files
The audio is the opposite approach: not generated at runtime, but by a script, tools/gen_audio.py. It synthesizes all 34 sound effects, jingles and music loops from oscillators, filtered noise and a few effects, and writes them as mono 16-bit WAVs at 22,050 Hz. There are no samples.
A normal hit, for example, is four layers of about 150 ms each:
def sfx_hit(rng):
T = 0.15
n = ns(T)
tr = transient(rng, n, 2500, 0.0015)
body = thump(n, 520, 160, 0.015, 0.045, 2.2)
slash = svf(noise(rng, n), 3800, 1.3, 'bp') * env_perc(n, 0.001, 0.03)
knock = svf(noise(rng, n), 950, 2.0, 'bp') * env_perc(n, 0.001, 0.02)
return 0.8 * tr + 0.9 * body + 0.8 * slash + 0.7 * knock
A click for the attack, a pitch-dropping sine for weight, band-passed noise for the "cut" and a lower knock. Tweaking the feel of a hit means changing a frequency and running the script again.
Two details that saved me time:
- Fixed seeds per file. All randomness is seeded from the file name, so running the script twice gives byte-identical files and git only shows the sounds I actually changed.
- Seamless loops. The three music tracks are exactly 16 bars. Notes are written into a circular buffer of that length, so a release tail that runs past the end wraps around to the start. Delay and reverb are computed with circular FFT convolution over the same buffer, so the last sample flows into the first as if the loop had been playing forever.
In the game, every hit plays at a slightly random speed (for example 0.95 to 1.07), so twenty hits in a row don't sound like a machine gun of one sample.
Rooms: mirrored stamps, a flood fill, and a room with no exit
Rooms are a 13×21 tile grid. The generator places small "stamps" of walls and pits, mirrored left to right, then checks the result with a flood fill from the spawn tile. Floor that the flood fill can't reach becomes wall, so enemies never spawn in a sealed pocket. If less than 62% of the room is reachable, it tries again.
Then, while playing my own release build, I spawned in a room where the exit was sitting inside solid wall.
Here's what had happened. Pits on both sides and a wall stamp in the middle had closed the entire row below the door. The flood fill couldn't reach the two rows in front of the door, so the generator filled them with wall. That's what it's supposed to do with unreachable pockets. The rest of the room was big enough to pass the 62% check, so the room was accepted. When the door opened, there was no way to get to it.
The fix is one condition: the tile in front of the door has to be reached by the flood fill, otherwise the room is rerolled.
// The door approach must be reachable, otherwise the pocket in front of
// the door gets walled off below and the player is locked in the room.
if (_seen[map.idx(spawnCol, 1)] == 0) return false;
The more interesting part for me as a tester: there already was a test that generated hundreds of rooms and checked that every floor tile is reachable. It passed. Of course it did. The generator turns unreachable floor into wall, so "every remaining floor tile is reachable" is true by construction. The test checked the invariant the code enforces, not the property the player cares about. After adding "the door is reachable" to the test, it failed on the 40th seed before the fix and passes after it.
Was it worth it?
For a small game with an abstract neon style, yes. The whole art and sound pipeline is code I can diff, review and regenerate, and the app has no third-party asset licences to track. For anything with characters or hand-drawn backgrounds, I'd hire an artist.
Dash & Slash is free on Google Play and the App Store. Gameplay clips are on the website. If you have questions about the atlas, the synth or the generator, ask in the comments.
Top comments (0)