Modeling Shmups in Machinations and Ceu

29 May 2016

Previously, I made a crude model of Pipe Dream, a geometry-intensive puzzle genre using Machinations, a tool by Joris Dormans, based on Petri nets for modeling game mechanics.

Shmups are a different, older, more popular genre of video game. They SEEM pretty geometry-intensive, but let's see if we can create a geometry-free model despite that.

It seems hard, so let's try and steal ideas from smart people. This is gonna seem like a couple of non sequiturs, but stick with me.

When a nuclear physicist talks about a "cross sectional area", they're actually talking about a probability. Specifically, the probability of a collision.

In most circumstances, you would say that areas and probabilities are two different kinds of things. However, imagine a filled-in blob on a piece of paper. We'll call the filled-in part the "figure", and the non-filled in part the "ground". If you put your finger (uniformly) randomly on the paper, the probability your chosen point is within the figure, is equal to the fraction of the paper's area that is the figure.

There's another simple model where probability-of-collision comes up.

Let's consider a well-mixed population of zombies and humans. When a zombie encounters a human, the human often turns into a zombie. When a zombie encounters a zombie... nothing happens. Similarly, when a human encounters a human, nothing happens (or at least, they both stay human). So the zombie population increases (and the human population decreases) at a rate that's proportional to the zombie/human collision probability.

This is a standard model of a lot of things, including epidemics and marketing ("enthusiasts are zombies"), and it creates a pretty S-curve.

(There's another model, the Lanchester model, which also uses this kind of technique; but it's depressing so I'll just mention it and not go into it in any detail.)

So let's try and use this kind of logistic-growth-inspired/well-mixed-reactor model of collisions in modeling a shmup. Let's imagine there are four kinds of sprites on the screen: baddies, baddiebullets, the player, and playerbullets. We'll say that the rate of X colliding with Y is proportional to X * Y. The constant of proportionality is the "cross sectional area".

There are two important kinds of collisions - playerbullets hitting baddies, and baddiebullets hitting the player. Let's leave out the question of what happens if the player collides with a baddie; this model is complicated enough already.

There's also baddie production of baddiebullets. The rate of new baddiebullets is probably proportional to the number of baddies.

Note that usually, baddies, baddiebullets, and bullets only stay on the screen for a duration of time. Machinations (and System Dynamics, and ordinary differential equations generally) is not good at modeling delays (they're a kind of one-dimensional geometry). There's a couple of different ways to finesse the problem, but let's use one of the crudest ways: We cap the pools. Once X things are on the screen, new things arriving will be balanced by old things leaving.

There's a subtlety though. What should the cap be on baddiebullets? If a single, motionless baddie was continuously firing baddiebullets, there would be a linearly rising number of baddiebullets, and then (because they are running off the screen), the rise would stop, and there would be a generally steady number of baddiebullets.

So initially it looks like this steady number should be our cap. But what if there are two motionless baddies? Then we get the same story, but because there are two streams of bullets, the number that we stabilize at is twice as high. So the cap on baddiebullets should be proportional to the number of baddies.

This model leaves out a huge amount of things, but it does keep a tiny fraction of the drama - there is some sort of slippery slope, where if the player were to slow or stop attacking for a moment, then there would be fewer playerbullets, which would lead to more baddies, which would lead to more baddiebullets.

Of course, the player inevitably loses, even fighting as hard as they can. But in real shmups, there are points, and if you score enough points, you get additional lives. We could easly add a crude version of that mechanic:

So what would this look like in Ceu? I think it looks nicer if we use loops instead of multiplication; the formula node in Machinations seems in this case to be obscuring rather than revealing things.

input void FIRE;

var int baddies = 0;
var int baddiebullets = 0;
var int playerbullets = 0;
var int player = 0;

par do
  // baddies spawn regularly
  loop do
    await 1s;
    baddies = baddies + 1;
  end
with
  // playerbullets kill baddies
  loop do
    await 1s;
    loop b in baddies do
      loop pb in playerbullets do
        // a one-percent cross-sectional area
        if _rand() % 100 < 1 then
          baddies = baddies - 1;
          playerbullets = playerbullets - 1;
          // TODO: maybe give player points and/or another life?
        end
      end
    end
  end
with
  // player shoots playerbullets
  loop do
    await FIRE;
    if player > 0 then
      playerbullets = playerbullets + 1;
    end
  end
with
  // baddies shoot baddiebullets
  loop do
    await 1s;
    loop b in baddies do
      baddiebullets = baddiebullets + 1;
    end
  end
with
  // baddiebullets kill player
  loop do
    if player == 0 then
      break;
    end
    await 1s;
    loop b in baddiebullets do
      // a one-percent cross-sectional area
      if _rand() % 100 < 1 then
        player = player - 1;
      end
    end
  end
end

Note that there's no representation of the caps in this code (yet).

I'm not sure I can explain Ceu pools and Ceu organisms quickly, but it's actually useful to see some examples of a thing before talking about what a thing is.

We can replace the integer "baddies" (representing the Machination pool "baddies") with a Ceu construct, called a pool. Instead of incrementing the integer, we spawn a Baddie in the pool. Also, we can add a cap on the number of baddies simultaneously - spawning when we're already at cap will do nothing. Also, we can have each baddie remember how long ago it was spawned, and vanish after that period of time, which was awkward to do in Machinations. It's not all good though - the nested loop over all baddies and playerbullets isn't straightforward to transform.

input void FIRE;

#define BADDIECAP 25

class Baddie with
do
  // baddies eventually drift off the screen
  await 10s;
end;

pool Baddie[BADDIECAP] baddies;
var int baddiebullets = 0;
var int playerbullets = 0;
var int player = 0;

par do
  // baddies spawn regularly
  loop do
    await 1s;
    spawn Baddie in baddies;
  end
with
  // player shoots playerbullets
  loop do
    await FIRE;
    if player > 0 then
      playerbullets = playerbullets + 1;
    end
  end
with
  // baddies shoot baddiebullets
  loop do
    await 1s;
    loop b in baddies do
      baddiebullets = baddiebullets + 1;
    end
  end
with
  // baddiebullets kill player
  loop do
    if player == 0 then
      break;
    end
    await 1s;
    loop b in baddiebullets do
      // a one-percent cross-sectional area
      if _rand() % 100 < 1 then
        player = player - 1;
      end
    end
  end
end

One of the things that looks wrong is that the Baddie is only doing one thing - waiting for ten seconds. Shouldn't it be shooting baddiebullets during that ten seconds?

So what if the baddie did two things in parallel - spawning baddiebullets periodically, and drifting off the screen eventually. In order for all of the baddies to be incrementing the same baddiebullets variable, we need to tell each baddie when we spawn it where that variable is. But once we do that, it looks pretty reasonable!

input void FIRE;

#define BADDIECAP 25

class Baddie with
  var int& baddiebullets;
do
  par/or do
    // baddies shoot baddiebullets
    loop do
      await 1s;
      baddiebullets = baddiebullets + 1;
    end
  with
    // baddies eventually drift off the screen
    await 10s;
  end
end;

pool Baddie[BADDIECAP] baddies;
var int baddiebullets = 0;
var int playerbullets = 0;
var int player = 0;

par do
  // baddies spawn regularly
  loop do
    await 1s;
    spawn Baddie in baddies with
      this.baddiebullets = &baddiebullets;
    end;
  end
with
  // player shoots playerbullets
  loop do
    await FIRE;
    if player > 0 then
      playerbullets = playerbullets + 1;
    end
  end
with
  // baddiebullets kill player
  loop do
    if player == 0 then
      break;
    end
    await 1s;
    loop b in baddiebullets do
      // a one-percent cross-sectional area
      if _rand() % 100 < 1 then
        player = player - 1;
      end
    end
  end
end

Ok, we still have to figure out baddie-playerbullet collisions, but let's transform another integer variable into a pool.

input void FIRE;

class BaddieBullet with
do
  // baddiebullets eventually drift off the screen
  await 5s;
end

class Baddie with
  pool BaddieBullet[]& baddiebullets;
do
  par/or do
    // baddies shoot baddiebullets
    loop do
      await 1s;
      spawn BaddieBullet in baddiebullets;
    end
  with
    // baddies eventually drift off the screen
    await 10s;
  end
end;

pool Baddie[] baddies;
pool BaddieBullet[] baddiebullets;
var int playerbullets = 0;
var int player = 0;

par do
  // baddies spawn regularly
  loop do
    await 1s;
    spawn Baddie in baddies with
      this.baddiebullets = &baddiebullets;
    end;
  end
with
  // player shoots playerbullets
  loop do
    await FIRE;
    if player > 0 then
      playerbullets = playerbullets + 1;
    end
  end
with
  // baddiebullets kill player
  loop do
    if player == 0 then
      break;
    end
    await 1s;
    loop b in baddiebullets do
      // a one-percent cross-sectional area
      if _rand() % 100 < 1 then
        player = player - 1;
      end
    end
  end
end

Okay, so if baddiebullets is a pool, then we have to pass a reference to a pool to Baddie instead of a reference to an integer. That's not so bad. Also, I switched from using bounded pools to unbounded ones - because the reason we had caps in Machinations was in order to model delays. But in a real application, we would probably develop using unbounded pools, and look at how many organisms were actually populating those pools, and put a bound on them anyway, with some margin.

But instead of looping over all baddiebullets at the bottom of the program, why not have the baddiebullets actually do the killing the player directly? Of course, in order for them to kill the player, they need a reference to the player, and in order to give them a reference to the player when they are spawned, the baddie needs a reference to the player.

input void FIRE;

// player cross-sectional area as a percentage probability
#define PLAYER_AREA 1

class BaddieBullet with
  var int& player;
do
  par/or do
    if _rand() % 100 < PLAYER_AREA then
      player = player - 1;
    end
  with
    // baddiebullets eventually drift off the screen
    await 5s;
  end
end

class Baddie with
  pool BaddieBullet[]& baddiebullets;
  var int& player;
do
  par/or do
    // baddies shoot baddiebullets
    loop do
      await 1s;
      spawn BaddieBullet in baddiebullets with
        this.player = &player;
      end;
    end
  with
    // baddies eventually drift off the screen
    await 10s;
  end
end;

pool Baddie[] baddies;
pool BaddieBullet[] baddiebullets;
var int playerbullets = 0;
var int player = 0;

par do
  // baddies spawn regularly
  loop do
    await 1s;
    spawn Baddie in baddies with
      this.baddiebullets = &baddiebullets;
      this.player = &player;
    end;
  end
with
  // player shoots playerbullets
  loop do
    await FIRE;
    if player > 0 then
      playerbullets = playerbullets + 1;
    end
  end
with
  // if player is dead, it is game over
  loop do
    await 1s;
    if player == 0 then
      break;
    end
  end
end

Okay, let's transform one more integer variable into a pool.

input void FIRE;

// cross-sectional areas as percentage probabilities
#define PLAYER_AREA 1
#define BADDIE_AREA 1

class BaddieBullet with
  var int& player;
do
  par/or do
    if _rand() % 100 < PLAYER_AREA then
      player = player - 1;
    end
  with
    // baddiebullets eventually drift off the screen
    await 5s;
  end
end

class Baddie with
  pool BaddieBullet[]& baddiebullets;
  var int& player;
  event void go_collide;
do
  par/or do
    // baddies shoot baddiebullets
    loop do
      await 1s;
      spawn BaddieBullet in baddiebullets with
        this.player = &player;
      end;
    end
  with
    // baddies eventually drift off the screen
    await 10s;
  with
    // or they can collide with player bullets
    await go_collide;
  end
end;

class PlayerBullet with
  pool Baddie[]& baddies;
do
  par/or do
    loop do
      await 1s;
      var Baddie&&? to_collide_with;
      loop b in baddies do
        if _rand() % 100 < BADDIE_AREA then
          to_collide_with = b;
        end
      end
      if to_collide_with? then
        emit to_collide_with!:go_collide;
        break;
      end
    end
  with
    // player bullets eventually drift off the screen
    await 5s;
  end
end;

pool Baddie[] baddies;
pool BaddieBullet[] baddiebullets;
pool PlayerBullet[] playerbullets;
var int player = 0;

par do
  // baddies spawn regularly
  loop do
    await 1s;
    spawn Baddie in baddies with
      this.baddiebullets = &baddiebullets;
      this.player = &player;
    end;
  end
with
  // player shoots playerbullets
  loop do
    await FIRE;
    spawn PlayerBullet in playerbullets with
      this.baddies = &baddies;
    end;
  end
with
  // baddiebullets kill player
  loop do
    if player == 0 then
      break;
    end
    await 1s;
  end
end

escape 0;

With this design, the playerbullets are responsible for iterating over the baddies, and sending a kill message to one baddie if the playerbullet decides that they are colliding.

Of course, there's more to be done to make this even a very crude game - in particular, everything will need to know how to draw itself every frame, and in order to draw itself, will need to have parameters like X and Y. In order for things to move, everything will need to know how to move itself every frame, and we will want to replace the ridiculous "cross sectional area" calculation with a real collides-with test, possibly circle or rectangle collision.

We may want the player to be an organism, or at least send a kill message from the baddiebullet when it kills the player; currently, the baddiebullet decrements the player variable and then another process notices that the player variable is zero, which is ridiculous.

However, it's surprising how much of the software architecture from the Machinations model is still somewhat recogizable.

I hope this has made at least some sense.