Project: Zig + Tic80 - Parallax Backgrounds

After maintaining this site for almost half a year I've got my first actual something-worth-posting project. I present to you: Aseprite-to-TIC80 Parallax Background.


This project got started after I saw some really cool pixel artwork on Mastodon back in november by an artist called PlusPixels:

A foggy view of a futuristic industrial sector, with trucks, crane, and cargo spaceships in the background. By PlusPixels
Original by PlusPixels, link post on Mastodon

Inspiration had struck: "I want to make an interactive parallax version of this on the TIC-80".

Side note: I got the original author's permission to use their work before continuing.


To achieve this, I'd need to try my hand at pixel art and re-draw the image layer-by-layer, keeping the following TIC-80 limitations in mind:

  • resolution of 240 x 136
  • 16-color palette
  • maximum of 256 unique 8x8 tiles (technically 512, but let's stick with 256)

There's probably cleverer ways to go about this than re-drawing everything from scratch, but I am a simple person, and the pixel art part was actually pretty fun.


This was my first non-trivial pixel-related endeavor, and it took a lot longer than I'd expected. I used the lovely Aseprite software to do the pixel pushing, which is something I've been meaning to tinker with more for a long time. It has a very useful grid tool that let me line up my pixels and easily copy/paste 8x8 chunks.

Here's an example of a slice of one layer with all the re-used 8x8 blocks numbered:

A screenshot of Aseprite zoomed in to emphasize the grid function. Tiles are numbered to indicate uniqueness.

While it's not nearly as good as the original, and missing some key details I couldn't really fit into the downsized version, I eventually had my 240 x 136 16-color 8-layer version of the artwork ready to port over to TIC-80.

A hand-pixelled spiritual duplicate of the original artwork

That's step 1 complete!

-

The next step was to chop up the pixel art and jam it into a TIC-80 cartridge. Fortunately Aseprite has full file spec on github, but unfortunately I've got very little experience parsing binary data.

I thought this looked like an excellent excuse to use Zig, my favorite language de jour! I want to note here that my native tongues are Python and JavaScript, and I am terrible at Zig and low-level languages in general. This probably could have been a couple hours of python work, but instead turned into many hours of scouring Zig documentation*.

For those unfamiliar with the lang, "Zig documentation" means "Zig standard library source code"


Anyways, Zig has some really useful tools for dealing with binary data -- namely it has a `packed struct` with a guaranteed memory layout, no padding between field, and exact-bit-width arbitrary integers. That means we can declare stuff like:

//zig
const Tight = packed struct{
    width: u16,
    height: u16,
    _pad: u24, // 3 bytes of padding,
    flags: TightFlags,
};

const TightFlags = packed struct{
    visible: bool,
    editable: bool,
    _unused: u5,
    inverted: bool
};

`bool` is a single bit in packed structs, which makes dealing with bit-flags super simple!

I stumbled through the process for a little bit and eventually found an existing Zig + Aseprite library: (BanchouBoo/tatl) from which I stole a couple Enum definitions. Interestingly, they took a different approach for reading structs from a file. Where I'd come across `FileReader.readStruct(...)` which tries to just slurp up the struct, this library manually read each individual field from the file.

Knowing what I now now about reading structs from files in Zig, I think their approach is a better one -- it can correctly deal with endianness and doesn't get hit with some extensive trouble I had with `readStruct` using `@size` to get the BYTE size of my structs, and not bit size, then Zig complaining that the data getting shoved into the cast wasn't the right size.

All that said, theres a lot more cool factor to being able to just slurp structs up in a single command! I'm willing to sacrifice quite a bit of stability for coolness:

//zig-pseudocode
// WHICH IS COOLER?
// THIS?
const UnCool = struct{
    a: u8,
    b: u8,

    pub fn load(reader: FileReader) !UnCool {
        return .{
            a = try reader.readLittleEndianInteger(u8),
            b = try reader.readLittleEndianInteger(u8)
        };
    }
};

const uc = try UnCool.load(reader);

// OR THIS
const SuperCool = packed struct{
    a: u8,
    b: u8
};

const sc = try readStruct(reader, SuperCool);

A couple of helper functions later I was bouncing around the bits in my reader and bustin' structs like a native. I ended up keeping the code fairly slim and only implemented the handful of structs I needed for my use case -- the rest get discarded in the reading process.

I'm also not super familiar with manual memory management, so I leaned pretty heavily on Zig's ArenaAllocator which lets you just drop the whole allocator at the end of your work instead of needing to manually deallocate stuff you've allocated.

I got tripped up for a while by the fact that Aseprite compresses it's pixel data using zlib. Fortunately Zig has the ability to uncompress the data baked right into the standard library, but it's completely undocumented... like a lot of Zig! Really, it adds to the charm!

I'm pretty sure I've already read more of Zig's source code than I have of python's, and I've been working with python for more than 10 years.


Anyways, Once I had my struct-slurping and pixel-parsing out of the way, I went about doing the actual stuff I was intending to do in the first place:

  • Extract the palette
  • For each visible layer, break the layer into 8x8 chunks
  • Keep track of (and index) each unique 8x8 chunk we find
  • Reconstruct it all into TIC-80 "maps" (a layout of sprite indices)
  • Export all of it into copy-pastable data to inject into TIC-80

Most of this was easy enough, but I ran into a handful more roadblocks such as Aseprite layers being stored as their minimum dimensions (need to align back to the 8x8 grid), TIC-80 map coordinates being super confusing (aa meant "tile at (10, 10)", not "tile index 170". Lots of silly little snags.

TIC-80 takes all of this data as string-encoded hex at the bottom of the file. For example I'm using the JS option for TIC-80 and some of the output looks like this:

// <PALETTE>
// 000:000000cd79624a2c395b3d475b3a497a535e9b7576ba9c92dbc6b4dcc4b9eedfd6f7eee2000000000000000000000000
// </PALETTE>

// <TILES>
// 001:0000000000000000000000000000000000000000000000000000000000000000
// 002:000000000000000a00000aaa0000aaaa000aaaaa00aaaaaa00aaaaaa0aaaaaaa
// 003:0aaaaaa0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
// 004:00000000a0000000aaa00000aaaa0000aaaaa000aaaaaa00aaaaaa00aaaaaaa0
// 005:0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0aaaaaaa
// 006:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
// 007:aaaaaaa0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0
// 008:0aaaaaaa00aaaaaa00aaaaaa000aaaaa0000aaaa00000aaa0000000a00000000
// 009:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0aaaaaa0
...

// <MAP>
...
// 036:10101010101010101010101010101010101010101010ea1010dab9b9b9b9b9b93a3a....
...

(note: you need the paid version of TIC-80 to be able to directly edit your files outside the emulator)

This gives us a sprite-sheet looking like so:

The sprite-editor tool within tic-80, populated with my beautiful tiles The tiles reassembled into their original layers in the tic-80 map editor

That's step 2, baby!

-

With my layers now successfully transformed into TIC-80-embeddable JS, all that was left to do was blit everything to the screen. This was the easiest part in the whole journey and reminded me I need to do more TIC-80 stuff. The code's not fancy or clean or anything, I just don't need to fight JavaScript to make things happen.


It ended up taking me probably 15-20 hours of actual behind-the-keyboard time to get to here and... It's not much. Not even a game, really, just a demo doing things that there's better ways to do.

I'm happy with the result, however, and I picked up a whole bunch along the way. Got to take Aseprite for a whirl, do something in TIC-80, and built myself a little Zig library -- overall I'm feeling a lot more comfortable with all 3 tools!

The journey, right?

So, while it really isn't much, I present to you: Parallax City TIC-80 Demo (mouse required):


- CLICK TO PLAY -


The code for this project has all been made available on GitHub: