Adding fire to our falling sand simulator

This post is the third part in a series. Make sure to start with: Making a falling sand simulator first!

This will be the final product for this post (drag around each of the materials and see how they interact!)

Behaviors

We're about to add a fair amount of complexity to our falling sand simulator. If you've spent some time exploring the final product above, you'll see there are a number of new behaviors.

Smoke falls upwards and dissipates over time. Fire spreads and sometimes sends burning pieces floating up or down. Colors are animating!

Particles simply inheriting from a base class and extending it (like we did in the last post) will quickly get unruly.

So let's introduce a new concept: the Behavior

class Behavior {
  update(particle, grid) {}
}

On our Particle... update will update each behavior according to the specified order.

And we're giving ourselves a way to check if a specific particle has a specific behavior, and retrieve it.

This is the entire Particle class now!

class Particle {
  constructor(
    index, {color, empty, behaviors} = {}
  ) {
    this.behaviors = behaviors ?? [];
    this.behaviorsLookup = Object.fromEntries(
      this.behaviors.map(
        (b) => [b.constructor.name, b]
      )
    );
    this.color = color;
    this.empty = empty ?? false;
  }

  update(grid, params) {
    this.behaviors.forEach(
      (b) => b.update(this, grid, params)
    );
  }

  getBehavior(type) {
    return this.behaviorsLookup[type.name];
  }
}

This changes the game for us. Now we can turn all that velocity behavior from last post into a Behavior which we'll call MovesDown.

class MovesDown extends Behavior {
  constructor(
    {maxSpeed, acceleration, velocity} = {}
  ) {
    super();
    this.maxSpeed = maxSpeed ?? 0;
    this.acceleration = acceleration ?? 0;
    this.velocity = velocity ?? 0;
  }

  // All the other methods and
  // functionality from last post
}

As you might imagine, it will be much easier for us to change the Behavior of a particle.

We're effectively using the Component pattern.

I figure it's not a great use of time and space to walk through the refactor here, but you can see this in the source of this page. Just check out the Grid class and the MovesDown class.

As you'll see in the code, the main Grid update method just looks like

for (let row = this.rowCount - 1; row >= 0; row--) {
  const rowOffset = row * this.width;
  const leftToRight = Math.random() > 0.5;
  for (let i = 0; i < this.width; i++) {
    // Go from right to left or left to
    // right depending on our random value
    const columnOffset = (
      leftToRight ? i : -i - 1 + this.width
    );
    const particle = this.grid[
      rowOffset + columnOffset
    ];
    particle.update(this);
  }
}

This is so much simpler to reason about compared to last time! Now we know, we go through each particle and call update - that's it. (we pass in an instance of the Grid so particles can affect others)

Smoke goes up

Wait a moment - we don't have fire yet, why start with the smoke?

Smoke has some cool attributes that we can use and extend for fire. It has only a limited lifetime. It changes color from frame to frame. It can move left or right, in addition to up, and diagonal.

Fire does these things too, but spreads instead of moves (and it generates smoke). It will be easier to add that after we have the other parts already completed for smoke!

So let's get started making our smoke particle!

class Smoke extends Particle {
  static baseColor = "#4C4A4D";
  static addProbability = 0.25;

  // We pass index in to all particles now,
  // so particles know where they are in
  // relation to others.
  constructor(p, index, {} = {}) {
    super(index, {
      color: p.varyColor(
        Smoke.baseColor, {
          lightFn: () => p.random(-5, 5),
          satFn: () => p.random(-5, 0)
        }
      ),
      behaviors: [
        // Let's start by only making
        // acceleration negative but
        // otherwise identical to sand
        new MovesDown({
          maxSpeed: 8,
          acceleration: -0.4
        }),
        // Fading behavior?
      ]
    });
  }
}

And one other tweak to our velocity logic...

Last post our updatePixel method chose the potential particles to swap with like so

// (BEFORE) Finding particles we can swap with
const below = i + this.width;
const belowLeft = below - 1;
const belowRight = below + 1;
// Swapping logic goes here

So we'll just do a minor tweak and some renaming!

// (AFTER) Finding particles we can swap with
const nextDelta = Math.sign(this.velocity) * grid.width;
const nextVertical = i + nextDelta;
const nextVerticalLeft = nextVertical - 1;
const nextVerticalRight = nextVertical + 1;
// Swapping logic goes here

So instead of assuming our direction is down, we check the sign of the velocity.

Because we happened to write our logic to determine the number of updates in a way that handled negative values, we didn't need to make any changes.

Recall our getUpdateCount method

getUpdateCount() {
  const abs = Math.abs(this.velocity);
  const floored = Math.floor(abs);
  const mod = abs - floored;
  // Treat a remainder (e.g. 0.5)
  // as a random chance to update
  return floored + (Math.random() < mod ? 1 : 0);
}

So even if our velocity is negative, it knows how many updates to perform.

And as we can see these tweaks are enough to make our smoke go up - but hold on, this looks familiar.

Our smoke is teleporting, not rising!

Smoke rises up slowly

We need to change our update logic once again - just like last post! Instead of going from back to front, we need to go from front to back, if our particle is going up.

Introducing, the forward and backward passes!

class BidirectionalGrid extends Grid {
  update() {
    this.beforeUpdate();
    // A pass for positive y direction velocities,
    // and negative
    for (let pass = -1; pass <= 1; pass += 2) {
      this.updateWithParams({direction: pass})
    }
  }

  // Call this right before choosing the particle!
  modifyIndexHook(index, {direction} = {}) {
    if (direction === -1) {
      return this.grid.length - index - 1;
    }
    return index;
  }
}

And update our grid update method (now abstracted to updateWithParams)

index = this.modifyIndexHook(index, params);
const particle = this.grid[index];
particle.update(this, params);

And we're almost done... but if you try it now, both materials teleport!

So we need to only update if the velocity of the material is the same as the direction.

We now pass these params to all our behavior updates.

this.behaviors.forEach(
  (b) => b.update(this, grid, params)
);

And modify the MovesDown behavior to only update when it should.

class Moves extends MovesDown {
  shouldUpdate({direction}) {
    return direction === Math.sign(this.nextVelocity());
  }

  // Only update if we should!
  update(particle, grid, params) {
    if (!this.shouldUpdate(params)) { return; }
    super.update(particle, grid, params);
  }
}

Now we update Sand and Smoke to use the Moves behavior, instead of MovesDown.

As a final touch, we'll reduce the max speed and acceleration of Smoke.

// Way less acceleration and max speed for smoke
new Moves({
  maxSpeed: 0.25,
  acceleration: -0.05
}),

So our smoke rises correctly now!

But if you play around with it and sand, you'll notice that sand can't pass through it - which is a bit strange.

Sand should go through smoke

Before, we always just checked isEmpty, so now we should modify this to instead be canPassThrough, which can be modified depending on the particle. A simple approach is to just add a field airy, true only on smoke, that we now check for in the canPassThrough method, in addition to empty, on particles that can pass through smoke.

We do need to make sure smoke can only pass through empty though.

Check the source code if you'd like to see it implemented!

Fading smoke

So, we have relatively convincing smoke, but it should really disappear over time.

Let's take a crack at introducing a new Behavior, called LimitedLife.

The key is going to be the update method.

On each update, we need to check if our remaining life (# of frames) is more than 0. If we have life, subtract one. If not, die. Either way, call onTick, and indicate that the particle was modified, so our simulation doesn't stop.

class LimitedLife extends Behavior {
  constructor(lifetime, {onTick, onDeath} = {}) {
    super();
    this.lifetime = lifetime;
    this.remainingLife = this.lifetime;
    this.onTick = onTick ?? (() => {});
    this.onDeath = onDeath ?? (() => {});
  }

  update(particle, grid) {
    if (this.remainingLife <= 0) {
      this.onDeath(this, particle, grid);
    } else {
      this.remainingLife = Math.floor(this.remainingLife - 1);
    }

    this.onTick(this, particle);
    grid.onModified(particle.index);
  }
}

Now we can write our onTick method, which should fade the particle according to its remaining life.

let onTick = (behavior, particle) => {
  let pct = behavior.remainingLife / behavior.lifetime;
  particle.color.setAlpha(Math.floor(255.0 * pct));
};

And our onDeath method, which will delete our particle.

let onDeath = (_, particle, grid) => {
  grid.clearIndex(particle.index);
};

And now we just add the new behavior to our smoke particle...

behaviors: [
  new Moves({
    maxSpeed: 0.25, acceleration: -0.05
  }),
  new LimitedLife(
    // Each particle has 400 - 800 life (random)
    400 + 400 * (Math.random()), {onTick, onDeath}
  ),
]

And we have our fading smoke! (It's probably gone by now- try dragging smoke around)

This is pretty good, but smoke should be able to move left and right too, not just up, right?

Try to tackle this one yourself! It's implemented in the final if you'd like a hint.

Animated, short-lived Fire

Finally! Let's make some fire!

Let's first make a basic particle that flickers between some fiery colors, and then disappears.

Instead of writing it all inline in the class, let's make a new behavior that we'll modify during this section, called Flammable.

class Flammable extends LimitedLife {
  constructor(p, {fuel} = {}) {
    fuel = fuel ?? 10 + 100 * (Math.random());
    const colors = [
      p.color("#541e1e"),
      p.color("#ff1f1f"),
      p.color("#ea5a00"),
      p.color("#ff6900"),
      p.color("#eecc09")
    ];
    super(
      fuel,
      {
        onTick: (behavior, particle) => {
          const frequency = Math.sqrt(
            behavior.lifetime / behavior.remainingLife
          );
          const period = frequency * colors.length;
          const pct = behavior.remainingLife / period;
          const colorIndex = Math.floor(pct) % colors.length;
          particle.color = colors[colorIndex];
        },
        onDeath: (_, particle, grid) => {
          const smoke = new Smoke(p, particle.index);
          grid.setIndex(particle.index, smoke);
        }
      }
    );
    this.colors = colors;
  }
}

For our onTick we chose some colors that felt good, and flicker between them. Instead of fading over time, we flicker slower as our particle dies, otherwise, it's the same as smoke.

For our onDeath, instead of removing the particle, we replace it with smoke!

Now we can just use this behavior, and have our new Fire particle.

class Fire extends Particle {
  static baseColor = "#e34f0f";
  static addProbability = 0.25;

  constructor(p, index) {
    const flammable = new Flammable(p);
    const colors = flammable.colors;
    super(index, {
      color: p.varyColor(
        colors[Math.floor(Math.random() * colors.length)],
      ),
      behaviors: [flammable],
    });
  }
}

This is a great start, and was pretty simple as we had all the pieces after implementing smoke. But fire is supposed to spread...

Catching Fire

Let's introduce a concept of burning and a chanceToCatch.

We could state, each frame, if a flammable particle is next to fire, it should catch with the probability of chanceToCatch, which would make a good amount of sense.

But, if there's more fire around a particle, it should be more likely to catch.

So, let's instead multiply the chanceToCatch by the chancesToCatch which are determined by the number of particles around it that are burning.

update(particle, grid) {
  if (this.chancesToCatch > 0 && !this.burning) {
    // Check if we caught on fire
    const chanceToCatch = (
      this.chancesToCatch * this.chanceToCatch
    );
    if (Math.random() < chanceToCatch) {
      this.burning = true;
    }
    this.chancesToCatch = 0;
  }
  // If we're burning, update our remaining
  // life and try to spread more fire.
  if (this.burning) {
    super.update(particle, grid);
    this.tryToSpread(particle, grid);
  }
}

One key piece here, as we're extending LimitedLife, is to only call its update method if we're actively burning. Wood that's not on fire shouldn't just disappear. And it shouldn't spread fire.

There's one last piece of the puzzle: our spreading logic.

Once we have our candidates, it's pretty straight forward. Iterate through them, check to see if they are flammable, and if they are, increase their chancesToCatch.

tryToSpread(particle, grid) {
  const candidates = this.getSpreadCandidates(particle, grid);
  candidates.forEach((i) => {
    const p = grid.grid[i];
    const flammable = p.getBehavior(Flammable);
    if (flammable) {
      flammable.chancesToCatch += 0.5 + Math.random() * 0.5;
    }
  });
}

So now we just need to retrieve our candidates. Our logic will just be, look in all 8 cardinal directions, and as long as it's in bounds, and didn't wrap to a new line because we went too far left or right, that's a candidate.

getSpreadCandidates(particle, grid) {
  const index = particle.index;

  const column = index % grid.width;
  const candidates = [];
  // Each of the 8 directions
  for (let dx = -1; dx <= 1; dx ++) {
    for (let dy = -1; dy <= 1; dy ++) {
      const di = index + dx + dy * grid.width;
      const x = di % grid.width;
      // Make sure it's in our grid
      const inBounds = di >= 0 && di < grid.grid.length;
      // Make sure we didn't wrap to the next or previous row
      const noWrap = Math.abs(x - column) <= 1;
      if (inBounds && noWrap) {
        candidates.push(di);
      }
    }
  }
  return candidates;
}

Putting it all together, each frame, we check each flammable particle. If it's not burning, roll to see if it now should be on fire. If it's burning, we reduce its life, look at all the flammable particles around it, and increase their chance to catch on fire.

As a last step here, let's make Wood flammable.

class Wood extends Particle {
  static baseColor = "#46281d";

  constructor(p, index) {
    const color = p.varyColor(
      Wood.baseColor, {
        lightFn: () => p.random(-6, 10)
      }
    );
    super(index, {
      color,
      behaviors: [new Flammable(p, {
        fuel: 200 + 100 * (Math.random()),
        chanceToCatch: 0.005,
      })]
    });
  }
}

And we've got some burning wood! (it's probably gone - try adding wood and burning it yourself)

Another addition that could add to the effect is to limit the chanceToSpread. That is, specify some function that determines how likely something is to spread, for example, how much remaining life it has. (this is present in the final product)

Adding (peaceful) sparks

In real life, fire sometimes sparks and causes things to catch fire that might not be directly next to what is burning. By introducing this behavior into our simulator, the way fire spreads feels much more natural. If we also animate it accordingly, it looks quite pleasing too.

One approach is to modify Smoke to have a chance to be burning and have a chance to either rise or fall. These attributes, coupled with allowing smoke to move horizontally creates the effect you'll see in the final product.

At this point, we've built our final product.

We've got some different solids, plasma, and gas. I wonder what we'll tackle next!