Simulated Evolution on the PICO-8

原始链接: https://bumbershootsoft.wordpress.com/2026/05/16/simulated-evolution-on-the-pico-8/

Sorry.
相关文章

原文

The articles from these past couple of weeks kinda burned me out. After wrapping it up, I decided I was going to take it easy, do something simple and brainless in a nice comfy high-level language, and just not worry about what kind of writeups I could get from it.

This backfired, of course, and now I have a whole new project I want to do. But in the meantime, I also spent a nice lazy afternoon with PICO-8 making a port of Simulated Evolution.

Getting all of this working was mostly a matter of bringing together various techniques I’ve already had to use in other contexts—some of which aren’t even PICO-8. Below the fold I’ll talk about what challenges I faced, how I solved them, and link to the related older projects that helped me along the way.

Implementing the Simulation

The first challenge, of course, was that I had to write the simulation itself. My earlier implementations were all in C or assembly language, and PICO-8 needs to be programmed in its own dialect of Lua. Happily, Lua is, itself, pretty comfy, so adapting the C code was straightforward. It was not, however, perfectly exact.

The original BASIC code I had adapted was a little bit buggy. It intended to loop through all the bugs in each simulation step, but because of the way iterations interacted with births and deaths, sometimes a bug would have its turn skipped if some other bug died or fissioned near it in memory. My C code made this more consistent while retaining much of the structure of the BASIC original. The Lua version, however, diverges a little bit in its design, and as a result while I did keep things consistent, it’s differently consistent.

All my previous implementations kept all the bug creatures in an array. If a bug died, the last bug in the array would be moved over to take its place and the total size of the array would drop. To ensure that the moved bug didn’t miss its turn—this was the issue in the BASIC original—it would meddle with the iterator to reprocess the same index with its new bug. A similar process was done when a bug fissioned: one of the newborns would replace the original parent, while the other was appended to the array. All of this was in the service of safely modifying a collection while iterating through it.

My Lua implementation elected to instead simply not do that. It instead made use of something more like a “for-each” loop, which makes it much less feasible to meddle with the iterator index in this way. However, the closest thing that Lua has to arrays are hashtables with integer keys, which in turn means they’re easy to create, grow, and shrink. For the Lua implementation I created BORN and DIED tables to collect the necessary changes along the way, and then use its table-manipulation functions after the iteration to delete any dead bugs and introduce any newly-born ones. However, that does introduce our discrepancy: in my previous implementations, the newborns had a simulation tick on the same turn they were created, while in the Lua one it does not. Neither result is truly crucial to the correctness of the simulation, but I’ll take either of these over the original behavior where exactly one of the newborns got a simulation tick on their birth turn.

Screen Memory as Real Memory

I wanted to replicate the trick I did in my Cyclic Cellular Automaton project, where the spritesheet is used as a sort of extra backbuffer for the screen. This would let me efficiently both track and render the plankton without declaring a supplemental 15,000-element array. There’s just one problem: that spritesheet is indexed as a 128×128-pixel region, and I need it to be a 150×100 one.

The solution here was to lean into some of the logic that I used on the Commodore 64 port. That port used a bitmap screen, which split up the bitmap display into 4×8-pixel blocks. To place or check for plankton, we would first find the block of pixels that our map coordinate corresponded to, and then index into that block for the final test.

PICO-8’s map facility is essentially a super-powered version of that bitmapped block placement. I set aside 247 of my 255 possible map tiles and arranged them into a 19×13 grid of 8×8-pixel blocks, into which my 150×100 world would fit. I then wrote a function that take world coordinates and return the X and Y coordinates in the 128×128 spritesheet space encoded into a single integer:

FUNCTION SCOORD(X,Y)
LOCAL CHAR=19*(Y\8)+(X\8)+1
LOCAL CY=8*(CHAR\16)+(Y&7)
LOCAL CX=8*(CHAR&15)+(X&7)
RETURN (CY<<7)|CX
END

(In PICO-8’s Lua dialect, the backslash is a truncating integer divide. This also means that X\1 is a reasonable way to convert a real number into an integer.)

Now we face our second problem: it’s not just the spritesheet that is 128×128. The screen is too. We can’t fit the whole world on-screen at once. We faced that problem on our other 8-bit version, for the Atari 800, and the high-level solution is identical: allow the user to scroll the display as desired. Vertical scrolling over a larger buffer was a trick that the Atari’s display lists made easy, and scrolling over a large map in any direction is even easier on the PICO-8. The whole map subsystem is dedicated to making this precise problem as easy as possible.

It turns out that this ease extends to sprites and custom drawing operations as well; the CAMERA() and CLIP() commands let you translate any drawing commands on their way to the screen and also restrict changes to a particular region within it. With this capability, I decided I could just leave the bugs as sprites, keeping the spritesheet bitmap exclusively for tracking the plankton. My DRAW() routine thus became this:

FUNCTION _DRAW()
CLS()
PRINT("SIMULATED EVOLUTION",26,0,10)
PRINT("POPULATION: "..#BUGS,0,120,7)
PRINT("⬅️➡️\F7 TO SCROLL",72,120,12)
CAMERA(CAM_X,-12)
RECTFILL(-2,-2,151,101,6)
MAP(0,0,0,0,19,13)
FOR BUG IN ALL(BUGS) DO
RECTFILL(BUG.X,BUG.Y,BUG.X+2,BUG.Y+2,15)
END
CAMERA()
END

(The emojis there are present in the source! PICO-8’s extended character set gets rendered as unicode graphics to the extent that it can when it saves it out.)

The borders of the world scroll in and out of view as necessary, and CAMERA()‘s transform did everything we needed to produce that effect.

Dodging System Limitations

One of the weirder parts of the PICO-8 platform is that its numbers are restricted to the 16-bit signed integer range of -32768-32767. That’s inconvenient for us; when I made the simulation 8-bit friendly I relied on unsigned 16-bit numbers and needed to be able to generate random numbers with values up to 40,960. That’s something we could work around with some clever extra checking, but happily we don’t need that. PICO-8’s numbers are 32-bit fixed-point, with 16 bits of fraction hard-coded in. We can just divide all our values in half and everything still works, no negative or rounded values required.

Even more happily, it turns out that its Lua dialect accepts negative numbers to its shift operations, so the expression 1 << -1 is actually 0.5 and we don’t even need to special-case anything.

Handling Metadata

One of the key aspects of Simulated Evolution is the “Garden of Eden:” the environment can be modified to produce a special region with far more food in it than normal. Its presence or absence has an effect on the behavior that ultimately ends up selected for in the bugs.

My PICO-8 port of Lights Out introduced me to the MENUITEM() facility for customizing the pause menu. I added a Garden of Eden switch there, and also a way to reset the simulation without rebooting the cartridge; that way your configuration didn’t get lost alongside it.

The reset ultimately worked exactly the same way the “New Puzzle” option did in my PICO-8 port of Lights-Out. The garden is a little more interesting, though, because it has to alter itself as part of its own handler to announce whether the garden is currently active or not. This turns out to map closely to an example in the manual, happily enough. Their sample code demonstrates the quirk where if you omit arguments from MENUITEM(), or offer them as NIL, it borrows the values from the option being updated. This is our menu code, in the end, run at the top level after defining _INIT(), _UPDATE60, and _DRAW():

MENUITEM(1|0X300,"NEW SIMULATION",_INIT)
MENUITEM(2,"GARDEN: ON",
FUNCTION()
GARDEN=NOT GARDEN
MENUITEM(NIL,"GARDEN: "..(GARDEN AND "ON" OR "OFF"))
RETURN TRUE
END)

Much of the graphical data for the project had to be supplied externally, again. The “spritesheet” data is all dynamic, but laying out all 247 tiles of the map promised to be extremely tedious. I wrote a couple lines of Python to spit out the map data for me; it’s just an ever-increasing list of hex numbers. This actually backfired the first time I tried to do it; if the hex numbers use capital letters for A-F, the reader rejects them but does so by silently replacing them with the transparent Tile 0. At least that corruption shows up in re-saved copies!

PICO-8 also includes some data import facilities of its own. The IMPORT command will let you load a 128×128 image as the spritesheet, or even put smaller images into particular places within it if you don’t want to make the atlas all at once. Even better, for me, the “cartridge label” may be imported by passing an additional -L option. That meant I could use Aseprite to create an image for it that brings in the beaker and petri dish from the Amiga and Mac icons:

I’m still not much of a pixel artist, alas, but I figured this one deserved more than a lazy screenshot.

What’s Missing

The biggest thing missing is something I didn’t want: lag! Even when I set the simulation to update at 60Hz, we still only use 60% of the PICO-8 CPU. Given the extra work I had to do in rendering here, I didn’t expect to fit the budget so cleanly. 60% is still enough that Warp Mode is a fool’s errand, though. It would just look like occasional frameskip.

I also don’t allow custom seeds. I didn’t pack my own RNG with this one, relying instead on PICO-8’s built-in RNG one. I could have fixed that—the 16-bit Xorshift PRNG I used for the 8-bit versions can be implemented easily enough in its fixed-point math—but without a keyboard to enter the seed with, the feature would be more irritation than convenience. I opted for simplicity here.

Downloads

As is traditional for PICO-8 works, the cartridge art up above includes the program within it, steganographically encoded within the cover art. I’ve also added it to the SimEvo collection on the Projects page and put the source code proper in the Bumbershoot Software GitHub repository.

Next time: a new project!

联系我们 contact @ memedata.com