This is a short behind the scenes about "Ad Astra" , a demo for the Pico-8 virtual console that I worked on recently with ilkke Here's a video of it, of you can watch it online.
I got into Pico-8 coding after seeing some short-form gif tutorials that the author, Lexaloffe, uploaded to his Twitter account. At the time I was on the tail end of a large commercial game project, so it was a nice distraction writing in this self-contained development environment after work. Being an occasional demo coder the first things I worked on were replicating a few popular demoscene effects: the Plasma, Vertical Rasters and then Vector Bobs.
After doing a few of these independently of each other I thought about some way to release them, and that's when I decided to try making a little demo 'engine' to run these scenes in sequence, with maybe some sort of design to tie them together.
In the modern demoscene a demo engine is a broad term, in it's basic form an engine runs behind the scenes, managing the sequence of demo parts, assets, handling their set up and the overall timing. On the other end of the scale you have fully integrated tool suites like Werkkzeug, which was the production tool behind a lot of Farbrauch's content like the famous fr-08 from a few years back.
What I wanted to do was something that handled the back-end work and left me free to create scenes from the various effects I'd made easily. This boiled down to three requirements:
- Each part of the demo can be timed accurately.
- Each part can be made from modular components so they can be re-used, and the 'engine' will handle initialisation and constructing the draw and update loops for it.
- The content of scenes can be mostly constructed outside Pico-8 for easy iteration using a very basic script language, making it a "data-driven" demo rather than hard-coding each screen to fit.
The first thing I needed to do was work out a way to do timing. Not timing for effects, we have _draw() and _update() for that, but general timing of the demo flow. Modern demos usually sync to the music as it's a constant and there is likely an audio system in place providing rock-solid timing for you. In Pico-8 there are a couple of stat() variables you can use to find out which pattern a channel is playing (16-19), and what position it's got to in that pattern. (20-23) There aren't any for song position so the way I did it was to check if we'd hit pattern position 0 and used a flag to check if we were still in the same pattern. I decided that each scene in the demo could be X song patterns in length, so when the timing flag is set it decreases this counter by one and if it reaches zero we know it's time to move on to the next screen.
2) Modular Components
As with games, modern demos (as in PC) are usually coded out of modular components. This is so scenes can be constructed from various re-usable parts to make a greater whole and saves on code duplication.
Let's take an example, here's a scroller screen I coded beforehand. It has a starfield, vector bobs, the scroll and a mirror effect. They're all hardcoded to that one screen.
Instead you split each element out into functions and give them as many input variables as you can, without impacting on performance too much. This instantly makes each thing more flexible and gives the designer a lot more scope to construct scenes from these smaller parts to make a larger whole. It also applies to effects you're only going to use once, like the landscape part in Ad Astra, because you can enhance these scenes with other modules you've already written.
So now each effect had three functions: Init, Draw and Update. Actually some of them didn't have an update because I did that in the Display bit instead (naughty) but never mind. That's another rule of making demos: if it seems to be faster do it that way instead.
When a new scene starts the engine checks which modules are used and feeds their Init function with the relevent data for that scene. It then builds array lists for the _draw() and _update() functions. These are basically just loops checking if a value is true in the array and calling the relevant effect function at that point.
The z-order of things in a scene is decided by their order in the _draw() array. This was good for backgrounds and overlays where needed.
Effects aren't all truly modular as some of them only have one instance of their variables available (the Bobs & Vectors for a start) , however the important bits that get re-used often (like the Map module) are setup properly and can be re-used multiple times in a scene.
3) Building scenes outside Pico-8
This was mostly about making things more comfortable for the creative process. The easier it is to make your demos the faster you can iterate and improve them, you're more likely to do that in a workflow that isn't awkward.
To that end I made a few tools to help out, these were all made in the old Blitz Basic which, for me at least, I still find the fastest way to make little graphics tools. As only the two of us were ever going to use them they didn't need to look nice, they just had to work.
The main tool was both a script compiler and .p8 file builder. You feed it a script in the right format which it turns into an array the demo engine can read. It then splices this with a .p8 file of the demo engine and exports a new compiled version. Finally it boots pico8 and runs the file automatically for convenience.
Here's a quick example of the really simple script format. First two lines are the p8 filename to splice into (useful for checking earlier versions if something starts breaking) and the timing values for each scene in array format. The 255 at the end is a special command to tell it to loop on the last scene.
Then each part follows, this is a list of the modules used in the Z order we want them to be displayed. Each module starts with a # followed by the name and then the data for that part afterwards. For example:
80 ; layerxmap
4 ; layerymap
0 ; layerxpos
47 ; layerypos
16 ; layerxsize
12 ; layerysize
0 ; layerxposreset
0 ; layeryposreset
0 ; layerxspeed
7 ; layeryspeed
0 ; layerxposadd
-1 ; layeryposadd
0 ; layerxmapadd
0 ; layerymapadd
0 ; layerxposmax
0 ; layeryposmax
0 ; skip mesh reset?
50 ; start zoom
148 ; xpos offset
48 ; ypos offset
0.5 ; x rotate start
0.0 ; y rotate start
0.0 ; z rotate start
0.000 ; x rotate move
-0.001 ; y rotate move
0.0003 ; z rotate move
700 ; target zoom
4 ; zoom speed
-1.1 ; xpos add
0.15 ; ypos add
1 ; mesh object
There are a few extra commands to handle adding data (such as the 3d meshes) and using Pico-8 commands within a scene, like the palette controls. This might seem odd but because it's data-driven we want to avoid hard coding things per scene if possible. So while changing the palette through an extra function rather than directly may lose a tiny amount of cpu time it means it's available in the script with the other parts of the scene. There were several times where I had to change masking colours for particular map layers and being able to do that in one continuous process made things much easier.
Each scene ends with an #end command so the engine knows we're done.
This may not look like a much faster way of working, but being able to run this in Notepad++ , tweak a setting here and there, cut/paste entire parts around to change the flow etc. certainly helped with the production workflow.
Another useful tool I made was a .PNG to _map & _gfx converter for the artwork. This was like any old tile converter in that it optimized the tiles used and then reconstructed the .png in the new order as a _map & _gfx set that could copy/pasted into the .p8 file. So ilkke could send me his latest version of the artwork as a .PNG file, made in whatever he wanted to use:
And I then had these in the engine ready to view straight way.
The final tool I did was a converter for .OBJ (Wavefront) files to turn them into an array for the vector routine. The only easy way to store vertex colours was in materials, so we did that. Here's a screenshot of ilkke's original Blender spaceship:
In hindsight my use of an array meant I wasted a LOT of tokens, and didn't throughly check that out until we started running out of memory. Though luckily it was only in the last days of production. Next time I'll use a pure bitstream approach and also standardize the input format for parts so we don't have any fixed variable names.
In the next two posts I'll go through each part in turn with a bit more detail.