Describing Bullet Hell: Declarative Danmaku Syntax

Bagoum
Bagoum
Nov 22 · 9 min read

Bullet hell patterns can be incredibly complex, but as of today there isn’t really any way to succinctly describe them. The most basic element — the movement of a single object — is just a parametric equation, but everything on top of that is murky. How do you describe firing five bullets at the same time following the same parametric equation but slightly rotated for each successive bullet? How about firing five bullets over time? Or five bullets at the same time, fired five times over time? Or five bullets at the same time, fired twenty times over time but with gaps after each five fires?

As it turns out, trying to describe danmaku patterns in words is clunky. And trying to describe them purely in terms of low-level code is illegible at best. So, when writing up my engine, I tried to design it to turn complex pattern descriptions into basically pseudocode. I accidentally succeeded, and this post is the first in (hopefully) a series on how it works, and how it can be used to describe danmaku.

This post is a little bit technical — it describes architecture, but there’s no hardcore code. It should be general enough to be applied to any problem about describing compositional patterns.

I don’t know how I made this, but I do know how it works, thanks to Declarative Danmaku Syntax.

0. Declarative Programming

Declarative programming is a paradigm in which programs describe what they do, and leave the actual implementation to some underlying engine. Very little code in software engineering is declarative, with one common exception: SQL. In SQL, you query a database by giving it a description, eg: “I want a list of all the cars that have red paint and were sold before 1994”. The user doesn’t define how this list should be acquired, they just describe the results they want.

What’s great about declarative programming is that a program written in a declarative language is its own description. This makes code easy to look over, and also ensures that the description has a similar structure to the code itself. The raw SQL code for the example above would be:

select * from CARS where PAINT="RED" and SALES_YEAR<1994;

This is the approach of my engine: by making the danmaku language declarative, any code for a pattern is simultaneously a human-readable semantic description. Furthermore, because it is declarative, it can be implemented in many underlying engines without modification. The most common Touhou fangame engine — Danmakufu — uses an imperative language and its concomitant deluge of for-loops and increments, so it’s next to impossible to figure out what code does except by running it, and it’s absurdly difficult to even port scripts between language versions. Declarative programming is the remedy.

1. An Example

Take a look at the small blue circles in the following pattern:

Ignore the supermassive red circles.

To describe it in words, we might say:

“Every few frames, spawn a blue circle slightly below the bottom of the screen at a random x-position between the left and right edges of the screen. It moves upwards at a speed that lerps (smoothly shifts) back and forth between slow and medium speeds periodically, and its starting point in this period is random.”

Let’s say that we’ll spawn every 8 frames from the position <0,-5>, the screen width goes from -6 to 6, and the two speeds are 0.4 and 0.7, which go back and forth every 4 seconds. Here’s how that’s written in my engine code (slightly modified for friendliness):

fire circle-blue <0,-5>
repeat-every 8
randomize-x -6 6
fire-particle
cartesian-velocity
randomize-time 0 4
periodize 4
lerpfromtoback 0.5 1.5 2.5 3.5
<0,0.4>
<0,0.7>

Moving from the bottom up, we can see how this corresponds to the description:

  • The two vectors <0,0.4>, <0,0.7> describe the two velocity vectors we want to switch between.
  • lerpfromtoback T1 T2 T3 T4 V1 V2 describes a function that lerps from V1 to V2, stays at V2 a while, and then lerps back to V1. In this case, V1 and V2 are the two vectors <0,0.4> and <0,0.7>, while the numbers 0.5 1.5 2.5 3.5 configure the times at which the function starts or stops shifting between the two.
  • periodize T F describes a function that repeats the child function F every T seconds. So, after we've done one lerpfromtoback, at t=4 we start doing it again. This handles the task of shifting between the slow and fast speeds.
  • randomizetime T1 T2 F describes a function that returns the child function F with a random time-shift between T1 and T2. Every particle that uses this function will get a different random time-shift.
  • cartesian-velocity F indicates that we want to use the child function as a velocity descriptor in Cartesian space. Everything up to this point only returns a vector between <0,0.4> and <0,0.7>-- we need to indicate at the top level how the particles should interpret this vector. Two similar methods might be cartesian-offset and polar-offset.
  • fire-particle F indicates that we want to fire a particle with the movement function F.
  • randomize-x X1 X2 F will randomize the initial x-position of the fired particles.
  • repeat-every T F will repeat the firing function every T frames.

Note how all of this code only what should happen. There’s nothing here that indicates what the machine should do to implement any of these features in any engine. This code could serve as a human-readable description, or it could be plugged into any engine that supports these functions (my engine being one of them).

Of course, there’s a lot of hand-wringing that goes into ordering these lines correctly — we’ll get to that!

2. Arbitrary Extensibility

The greatest problem with describing danmaku patterns is that there are too many ways that particles can be organized. If we make any assumptions about how something will be organized, then we need to rewrite much of our logic when the inevitable exception arises. For example, maybe we assume — as we did above with repeat-every-- that when repeating functions over time, we wait the same number of frames between each invocation. But there could be a pattern which waits for 8 frames for the first 100 invocations, and then 6 frames for all invocations afterwards.

This contradiction between the patterns we want to create and the capabilities of our danmaku language is inevitable. Thus, the best strategy is to aggressively modularize each layer of our pattern description, so that when a new method of organization arises, we can write a tiny replacement function and , instead of replace, our previous architecture. For example, let’s say we wanted to do repeat-every with a variable number of frames, as described above. We could simply add another method, repeat-every-f, and use it as follows:

repeat-every-f
(6 if x > 100 else 8)
randomize-x -6 6
...

This new method would take a function instead of a single number, pass in the invocation number, and wait for the returned number of frames before the next invocation. How expensive is it to add support for this new function? Well, the danmaku language requires one new definition, and an engine would need about 10 lines of code — precisely due to the aggressive modularization of the architecture. To any other function, repeat-every and repeat-every-f are just "functions that do something", so it doesn't matter to any other function if we swap one for the other.

3. Typing

I said previously that repeat-every and repeat-every-f are just "functions that do something"-- but this isn't entirely accurate. Every word in our pseudocode description needs to say what of thing it is-- in other words, it needs to have a type. We said that repeat-every would wait for some number of frames and repeat an action-- but for this to work, we need to know that 8 is a number, and not a "function that does something"!

Let’s say we have some type AsyncFunction (for patterns that run over time) and another type SyncFunction (for instantaneous patterns); the exact implementation of these types will depend on the underlying engine. What we want to express is that repeat-every takes in a number (a frame counter) and a SyncFunction, and spits out an AsyncFunction. The type can be expressed as (number, SyncFunction) => AsyncFunction.

For repeat-every-f, our frame counter is instead a function that takes in a number and spits out a number. The type of the frame counter is (number) => number. Then the type of repeat-every-f is ((number) => number, SyncFunction) => AsyncFunction.

We can see that repeat-every and repeat-every-f have different types-- in that case, how are they interchangeable? The key is that they have the same return type AsyncFunction. When we're parsing the danmaku language code for these two methods, we provide input arguments depending on whatever input types it requires. But what we receive from the method is always an AsyncFunction, which can be passed up to the parent. Recall that the parent of repeat-every in the example was fire circle-blue <0,-5> repeat-every.... The type of fire would be: (string, vector, AsyncFunction) => FireConfiguration. In this case, fire doesn't care about what input arguments are required by repeat-every,repeat-every-f, as long as they always return an AsyncFunction.

Here are the types of each of the other layers of the example. (number) => vector is the type of a parametric equation: we pass in a time value, and it returns an <X,Y> pair.

  • randomize-x: (number, number, SyncFunction) => SyncFunction
  • fire-particle: (MovementDescriptor) => SyncFunction
  • cartesian-velocity: ((number) => vector) => MovementDescriptor
  • randomize-time: (number, number, (number) => vector) => ((number) => vector)
  • periodize: (number, (number) => vector) => ((number) => vector)
  • lerpfromtoback: (number, number, number, number, (number) => vector, (number) => vector) => ((number) => vector)

There’s only one problem. For maximum modularity, lerpfromtoback should take two parametric equations as children, as noted above. However, in the example, <0,0.4> and <0,0.7> are vectors, not parametric equations. When coming across these type conflicts, we need to consider the semantics. The two vectors are supposed to be constants that don't change with time. In that case, we need a wrapper function that takes in a vector and treats it as a constant: let's say constant-vector, which would have type (vector) => ((number) => vector). Our last three lines should therefore actually be:

lerpfromtoback 0.5 1.5 2.5 3.5
constant-vector <0,0.4>
constant-vector <0,0.7>

3.1 What Types Do We Need?

For this modular structure to be extendable to any possible pattern, the types must be general and flexible. We’ve used the following complex types above, in addition to a few basic types (number, string, vector):

  • Patterns over time — AsyncFunction
  • Instantaneous patterns — SyncFunction
  • Movement of a single particle — MovementDescriptor
  • Parametric functions — (number) => vector
  • Number to number functions — (number) => number

We can always add more types, but the critical path seems to be the first three complex types. At the end of the day, a danmaku pattern is always something that is done over time (AsyncFunction) as a combination of instantaneous actions (SyncFunction) of which the particles follow some kind of movement path (MovementDescriptor).

3.2 Categories Within Types

We should also note that within a single type, we might have functions with completely different semantics. This is most notable for AsyncFunction and SyncFunction. For example, cartesian-velocity and randomize-x are both SyncFunction, but the first has the semantic group of "firing a single particle", whereas the second has the semantic group of "modify another SyncPattern". Here are groupings that we might make:

  • AsyncFunction that modifies another AsyncFunction
  • AsyncFunction that repeats another AsyncFunction, maybe over time
  • AsyncFunction that repeats a SyncFunction (repeat-every)
  • SyncFunction that modifies another SyncFunction (randomize-x)
  • SyncFunction that repeats a SyncFunction (eg. calling it multiple times simultaneously)
  • SyncFunction that fires a particle or performs some other singular action (fire-particle)

In my unabridged engine, I have three basic “repeat” functions — IRepeat, CRepeat, and Repeat. The first is an Async-over-Async function, the second is an Async-over-Sync function, and the third is a Sync-over-Sync function. I use prefixes to mark the type-based groupings (I, C, or none), while the main body of the name marks the general action. These are useful considerations for standardizing naming practices.

4. Conclusion

A declarative engine-independent language is the simplest (and probably the only practical) way to accomplish the task of describing complex danmaku patterns in a way that can be converted into code. This article sought to show what that language might look like and explain the basics of how and why its syntax works from a static verification perspective. How an engine might run this code is a more technical subject for another post.

If you’re interested in toying around with my implementation of this idea, you can check out my sandbox project on BulletForge. It allows you write and run completely legit danmaku scripts using this structure — and because the language is declarative, you’re simultaneously writing pithy descriptions of what you’re doing. Be warned that the full implementation is a bit more powerful and a bit less friendly than the simplified example I showed here, and describes complete behavior patterns rather than only particle firing.

The Startup

Medium's largest active publication, followed by +537K people. Follow to join our community.

Bagoum

Written by

Bagoum

Software engineer, epic gamer, and student of Barthes and Skinner.

The Startup

Medium's largest active publication, followed by +537K people. Follow to join our community.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade