I’ve always been fascinated by falling sand games — those mesmerizing simulations where you drop particles and watch them interact with each other in surprisingly realistic ways. As a Flutter developer, I wanted to see if I could build one that runs smoothly on mobile devices. The result was SandTrace, a real-time particle simulation app featuring sand, water, and fire physics. In this article, I’ll walk you through how I built it.
The Idea Behind SandTrace
The concept is simple: give users a canvas where they can draw with different particle types — sand, water, fire — and watch the physics play out in real time. Sand should pile up, water should flow and pool, and fire should rise and consume. The challenge lies in making all of this happen at 60 frames per second on a phone.
I chose Flutter for this project because of its powerful CustomPainter API and its ability to render pixel-level graphics efficiently. Most people think of Flutter as a UI framework, but its Canvas API is surprisingly capable for this kind of work.
Cellular Automaton: The Core Approach
The simulation is built on a cellular automaton — a grid where each cell can be empty or contain a particle. Every frame, the engine iterates through every cell and applies rules based on the particle type. This is the same approach used by classic falling sand games like Noita and The Powder Toy.
The grid is represented as a simple 2D array:
class ParticleGrid {
final int width;
final int height;
late List<List<Particle?>> cells;
ParticleGrid(this.width, this.height) {
cells = List.generate(
height,
(_) => List.filled(width, null),
);
}
Particle? getCell(int x, int y) {
if (x < 0 || x >= width || y < 0 || y >= height) return null;
return cells[y][x];
}
void setCell(int x, int y, Particle? particle) {
if (x >= 0 && x < width && y >= 0 && y < height) {
cells[y][x] = particle;
}
}
}
Each Particle holds its type, color, and a flag indicating whether it has already been updated this frame (to prevent double-processing):
class Particle {
final ParticleType type;
Color color;
bool updated = false;
Particle(this.type, this.color);
}
enum ParticleType { sand, water, fire, smoke, wall }
Particle Behaviors
The magic happens in the update rules. Each particle type follows different physics. I process the grid from bottom to top so that gravity-affected particles can settle naturally.
Sand Physics
Sand is the simplest particle. It tries to fall straight down. If blocked, it tries to slide diagonally. This creates natural-looking piles with slopes:
void updateSand(int x, int y) {
// Try to fall straight down
if (isEmpty(x, y + 1)) {
moveParticle(x, y, x, y + 1);
}
// Try to slide diagonally
else if (isEmpty(x - 1, y + 1) && isEmpty(x + 1, y + 1)) {
// Both sides open — pick randomly for natural look
final dir = random.nextBool() ? -1 : 1;
moveParticle(x, y, x + dir, y + 1);
} else if (isEmpty(x - 1, y + 1)) {
moveParticle(x, y, x - 1, y + 1);
} else if (isEmpty(x + 1, y + 1)) {
moveParticle(x, y, x + 1, y + 1);
}
}
Water Physics
Water follows similar gravity rules as sand, but it also flows horizontally when it can’t fall. This makes it spread out and fill containers:
void updateWater(int x, int y) {
if (isEmpty(x, y + 1)) {
moveParticle(x, y, x, y + 1);
} else if (isEmpty(x - 1, y + 1)) {
moveParticle(x, y, x - 1, y + 1);
} else if (isEmpty(x + 1, y + 1)) {
moveParticle(x, y, x + 1, y + 1);
}
// Water spreads horizontally
else {
final dir = random.nextBool() ? -1 : 1;
if (isEmpty(x + dir, y)) {
moveParticle(x, y, x + dir, y);
} else if (isEmpty(x - dir, y)) {
moveParticle(x, y, x - dir, y);
}
}
}
An important detail: sand should sink through water. When sand tries to move into a cell occupied by water, the two particles swap positions. This creates a realistic sinking effect.
Fire Physics
Fire rises upward (the opposite of sand), has a limited lifetime, and spawns smoke particles as it dies:
void updateFire(int x, int y) {
final particle = getCell(x, y)!;
particle.lifetime--;
if (particle.lifetime <= 0) {
// Fire dies and becomes smoke
setCell(x, y, Particle(ParticleType.smoke, Colors.grey));
return;
}
// Fire rises with slight horizontal drift
final dx = random.nextInt(3) - 1; // -1, 0, or 1
if (isEmpty(x + dx, y - 1)) {
moveParticle(x, y, x + dx, y - 1);
} else if (isEmpty(x, y - 1)) {
moveParticle(x, y, x, y - 1);
}
}
Custom Rendering with Canvas
Rendering the grid efficiently is crucial. I use Flutter’s CustomPainter to draw each particle as a single pixel (or small rectangle, depending on zoom level). The key insight is to use Canvas.drawRect for each particle rather than trying to build an Image object:
class ParticlePainter extends CustomPainter {
final ParticleGrid grid;
final double cellSize;
ParticlePainter(this.grid, this.cellSize);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
for (int y = 0; y < grid.height; y++) {
for (int x = 0; x < grid.width; x++) {
final particle = grid.cells[y][x];
if (particle != null) {
paint.color = particle.color;
canvas.drawRect(
Rect.fromLTWH(
x * cellSize,
y * cellSize,
cellSize,
cellSize,
),
paint,
);
}
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
Performance Optimization
Running a cellular automaton at 60 FPS on mobile requires careful optimization. Here are the key techniques I used:
1. Dirty Region Tracking
Instead of redrawing the entire grid every frame, I track which regions have changed and only repaint those areas. This dramatically reduces the rendering workload when most of the simulation has settled.
2. Grid Resolution
The simulation grid doesn’t need to match the screen resolution. I use a grid of around 200×400 cells, and each cell is rendered as a small rectangle. This keeps the computation manageable while still looking smooth.
3. Frame-Based Updates with Ticker
I use a Ticker to drive the simulation loop, which ties updates to the display refresh rate:
late Ticker _ticker;
@override
void initState() {
super.initState();
_ticker = createTicker((_) {
grid.update();
setState(() {});
});
_ticker.start();
}
4. Bottom-Up Iteration with Randomized Horizontal Order
Processing from bottom to top is essential for gravity. Randomizing the left-to-right order each frame prevents visual artifacts where particles always favor one direction.
Touch Interaction
Users interact with SandTrace by touching and dragging on the screen. I convert touch positions to grid coordinates and spawn particles in a brush pattern:
GestureDetector(
onPanUpdate: (details) {
final localPos = details.localPosition;
final gridX = (localPos.dx / cellSize).floor();
final gridY = (localPos.dy / cellSize).floor();
// Spawn particles in a brush radius
for (int dy = -brushRadius; dy <= brushRadius; dy++) {
for (int dx = -brushRadius; dx <= brushRadius; dx++) {
if (dx * dx + dy * dy <= brushRadius * brushRadius) {
grid.spawnParticle(gridX + dx, gridY + dy, selectedType);
}
}
}
},
child: CustomPaint(
painter: ParticlePainter(grid, cellSize),
size: Size.infinite,
),
)
The circular brush makes drawing feel natural and smooth, similar to painting apps.
Saving Screenshots
One feature users love is the ability to save their creations as images. I use RenderRepaintBoundary to capture the canvas:
Future<void> saveScreenshot() async {
final boundary = _repaintKey.currentContext!
.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
final bytes = byteData!.buffer.asUint8List();
await ImageGallerySaverPlus.saveImage(bytes, name: 'sandtrace_creation');
}
Lessons Learned
Building SandTrace taught me several valuable lessons:
-
Flutter’s Canvas is powerful. You can build real-time simulations that look great and perform well, even on mid-range devices.
-
Simplicity wins. The cellular automaton approach is deceptively simple — just a grid with rules — but it produces emergent behavior that feels complex and organic.
-
Profile early, profile often. I spent significant time with Flutter DevTools identifying rendering bottlenecks. The biggest wins came from reducing unnecessary repaints, not from algorithmic cleverness.
-
User interaction matters. The brush size, particle selection UI, and save feature transform a tech demo into something people actually want to play with.
What’s Next
I’m exploring adding more particle types — oil, acid, plant growth — and possibly multiplayer sandbox mode. Flutter’s cross-platform nature means SandTrace runs on both iOS and Android from a single codebase, which makes iteration fast.
If you’re interested in building your own simulation in Flutter, start with a small grid and just sand. Get the core loop working, then add complexity one particle type at a time. You’ll be surprised how quickly it comes together.
SandTrace is available on the App Store and Google Play. Give it a try and let me know what you think!