
A falling-sand cellular automaton built in Unreal Engine 5 C++. A double-buffered 32×32 grid simulates four interacting materials — sand falls and slides, water flows with direction memory, mud forms when they meet and decays over time, and stone acts as immovable walls. Inspired by Noita. Zero per-cell actors — every living cell is an ISM instance.
Grid[32][32], writes to GridBuffer; a single FMemory::Memcpy swap at step end. No locks, no races.FCell::LastDir (int8) stores last horizontal direction; tried first on the next step, preventing oscillation and creating realistic streams.Lifetime countdown; decays to sand automatically.Render() clears and rebuilds all three ISMs in one O(W×H) pass; per-material sine-wave Z-offsets give a living, breathing look.
The entire grid is a flat 2D array of 3-field FCell structs. The loop shuffles column order per row before dispatching on ECellType.
// Minimal cell — fits in a cache line
UENUM() enum class ECellType : uint8 { Empty, Sand, Water, Stone, Mud };
USTRUCT() struct FCell {
ECellType Type = ECellType::Empty;
int32 Lifetime = 0; // mud decay counter
int8 LastDir = 0; // water flow direction memory
};
void AFallingSandSimActor::Simulate()
{
FMemory::Memcpy(GridBuffer, Grid, sizeof(Grid)); // snapshot → buffer
for (int32 Y = 1; Y < GridHeight; ++Y)
{
TArray<int32> XIndices;
for (int32 X = 0; X < GridWidth; ++X) XIndices.Add(X);
XIndices.Sort([](int32, int32) { return FMath::RandBool(); });
for (int32 X : XIndices)
{
FCell& Cell = Grid[X][Y];
switch (Cell.Type)
{
case ECellType::Sand: SimulateSand (X, Y, Cell); break;
case ECellType::Water: SimulateWater(X, Y, Cell); break;
case ECellType::Mud: SimulateMud (X, Y, Cell); break;
}
}
}
FMemory::Memcpy(Grid, GridBuffer, sizeof(Grid)); // commit buffer → grid
}
Sand falls then slides diagonally; contact with water forms Mud. Water uses LastDir to persist flow direction and suppress oscillation.
void AFallingSandSimActor::SimulateSand(int32 X, int32 Y, FCell& Cell)
{
if (Grid[X][Y-1].Type == ECellType::Empty)
SwapCells(X, Y, X, Y-1); // fall down
else if (Grid[X][Y-1].Type == ECellType::Water)
{
GridBuffer[X][Y-1] = { ECellType::Mud, 300, 0 }; // sand+water → mud
GridBuffer[X][Y].Type = ECellType::Empty;
}
else if (X > 0 && Grid[X-1][Y-1].Type == ECellType::Empty)
SwapCells(X, Y, X-1, Y-1); // slide left
else if (X < GridWidth-1 && Grid[X+1][Y-1].Type == ECellType::Empty)
SwapCells(X, Y, X+1, Y-1); // slide right
}
void AFallingSandSimActor::SimulateWater(int32 X, int32 Y, FCell& Cell)
{
if (Grid[X][Y-1].Type == ECellType::Empty)
{ SwapCells(X, Y, X, Y-1); GridBuffer[X][Y].LastDir = 0; return; }
// Try last direction first — prevents oscillation
int8 LastDir = Cell.LastDir;
int TryDirs[2] = { LastDir != 0 ? LastDir : -1, LastDir != 0 ? -LastDir : 1 };
for (int Dir : TryDirs)
{
int32 NX = X + Dir;
if (NX >= 0 && NX < GridWidth && Grid[NX][Y].Type == ECellType::Empty)
{
SwapCells(X, Y, NX, Y);
GridBuffer[NX][Y].LastDir = Dir; // remember for next step
return;
}
}
}
All three ISMs are cleared and rebuilt in one O(W×H) pass each frame. Sine-wave Z-offsets per material give each element an alive bounce. The tick accumulates real DeltaTime and drains it at a fixed rate.
void AFallingSandSimActor::Render()
{
ClearInstances();
const float T = GetWorld()->TimeSeconds;
for (int32 Y = 0; Y < GridHeight; ++Y)
for (int32 X = 0; X < GridWidth; ++X)
{
FCell& Cell = Grid[X][Y];
if (Cell.Type == ECellType::Empty) continue;
float ZOff = 0.f;
switch (Cell.Type) {
case ECellType::Sand:
ZOff = 2.f + FMath::Sin(T*6.f + X*0.3f + Y*0.3f) * 10.f; break;
case ECellType::Water:
ZOff = 1.f + FMath::Sin(T*10.f + X*0.5f) * 15.f; break;
case ECellType::Mud:
ZOff = 3.f + FMath::Sin(Cell.Lifetime*0.2f) * 5.f; break;
}
FTransform Xf;
Xf.SetLocation(FVector(X*100, Y*100, ZOff));
switch (Cell.Type) {
case ECellType::Sand: SandMesh ->AddInstance(Xf); break;
case ECellType::Water: WaterMesh->AddInstance(Xf); break;
case ECellType::Mud: MudMesh ->AddInstance(Xf); break;
}
}
}
void AFallingSandSimActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
SimTimeAccumulator += DeltaTime;
while (SimTimeAccumulator >= SimStepInterval)
{
Simulate(); // fixed-rate step
SimTimeAccumulator -= SimStepInterval;
}
Render();
}