Ross J Kuhn Profile picture
Jun 9 59 tweets 20 min read
Hi everyone! I think the thing everyone is most interested in w my new #Pico8 game is the gfx. It took a few tries/months to figure out how to do it! I don't have a blog, so here's what will probably be a VERY long, meandering thread about how it works 🧵

Before anything, I just want to say thank u for all the interest. I’m humbled by the response and kind words from everyone on all social media. A couple ppl have even told me they've played it for like an hour! It’s more than I could have ever asked for and I’m so grateful!
Also, I’m armed w a folder of gifs and a checklist, but am mostly improvising this, so u might want to just check back in a few hours, lol.
First, let’s go back in time. I initially realized a much bigger game than my first was possible when I came across @iSpellcaster's RLE library. Suddenly everyone's carts where the spritesheet/map is random noise made sense, they're encoded/compressed!

lexaloffle.com/bbs/?pid=79974
Getting things in/out of cartdata is probably out of the scope of this thread, but the general idea is that all the assets are a combination of data stored in cartdata, or binary blob strings in-code (all the metadata describing the asset in cartdata)
I'm not really a game dev, this is only my second, so all that was a new concept to me and took a while to wrap my mind around. I imagine some of my solutions are maybe unorthodox as a result?
@iSpellcaster's RLE library led to experiments learning how gfx are stored, how framebuffers work, etc. As a graphic designer, I never gave much thought in my career to where the term "bitmap" came from. Anyway, when I used this meme, I meant it, lol.
As mentioned above, in the end, the gfx did not end up being stored as strings, only the metadata. The reason is because we get 16KB of cartdata, and 16KB for code (including strings) which counts towards the compressed limit, and we need to use ALL of it.
During the times between my experiments failing, some essential features became available to the API which really made the game possible, not just multi-poke, but unpack(), split(), faster memcpy(), etc.
ok, yeah, this thread is going to be MASSIVE 😂
So, I drew up some concept art. I had an idea that the game would scroll/pan in both directions, but the zoom effect didn’t occur to me until I realized I wasn’t really capable of imagining pixel art at 128x128 yet. This is 256x512.

At this point, I realized how important the characters having dynamic facial reactions would be in trying to convey some kind of emotion in the game. I made the sprites as small as possible while still being able to do that, but they were still a little too big.
So, the zoomy camera was born. The next experiment didn’t go very well, lol, but that is six separate spritesheets being swapped in mid-frame and drawn at the same time, at least!

You might have noticed, we’re not using the map at all. In retrospect, I probably could have utilized the map and tline() to greater effect, but this first version worked well enough and I never revisited it...
*deep breath*
So, that's the big secret behind the zoomy camera. The playfield is always 256 x (infinity to the right or limited to the left), and the camera simply pans/zooms around that area. Everything on-screen is drawn with sspr() and multiplied by the cam_s value.
In fact, we never use the built-in camera() function at all. Technically, that camera always sits at 0,0 and everything, including our zoomy camera, moves relative to that.
Finally, this resembles the game I just released but this is also where I got stuck. This is fine, but they’re all drawn w the same priority and at 70% CPU! Y-sorting would be a big problem. Meanwhile, @FSouchu was releasing POOM. More on that later.

One thing I was never able to fully overcome is the scaling artifacts, I guess due to the underlying 16.16 fixed-point numbers in Pico-8 (and low-res). In the final version, it's very apparent where each wall tile begins/ends while the camera is zooming, despite my best efforts
My workflow involves a custom Python build script. There are also many utility p8 carts for doing various jobs. The build script uses these as templates to dynamically generate new ones per-asset (allowing for per-asset debugging), which are eventually rolled into the final cart.
The utility carts do things like compress assets to/from cartdata, merge tables from separate lua files and serialize them out to a string, convert palette data to byte arrays, and so on.
The gfx start in #aseprite. The rower bodies are the most complex; composed of ~50 frames, 2 cel layers each (some mirrored); 1 cel for bg arm/oar/body, 1 for fg arm/oar. A custom Lua script, called by a custom Python build script, gets all the position/palette/collision data...
idk what other ppl do, but being able to draw a hitbox in aseprite and just have that exported out with everything else gfx related, feels like one of the smartest ideas I had during development!
also notice that we don't have all 8 directions in that aseprite export, that's because 3 of them will be mirrored to save space.
All that gfx metadata gets run through my Lua table serializer (which I would love help with if u can help me optimize it). The result is attached to an object in the Python script, used by a util cart, for inserting into a master asset table later
github.com/ridgekuhn/pico…
At the same time, it also gets run through the lua-table-persistence module and saved to a text file, so I can have a human-readable file to reference if something seems wrong
github.com/hipe/lua-table…
Meanwhile, the spritesheet itself is packed into a 128x128 png, which is imported into a utility cart by pic2pico. In debug mode, the utility cart can recreate the original aseprite positions and animation, which aren't needed for the game.

github.com/nodepond/pic2p…
This last step w util carts is unnecessarily complex, but I'm making up for the fact that I don't really know how to compress a png and write that directly into cartdata. It works really well, though and took maybe less than an hour to code up.
Here's the final spritesheet that ends up getting stored in cartdata. Imagine trying to assign all that positional data by hand every time you make a change!
Here's the 6 spritesheets needed for the game gfx. The colors look funky because everything gets converted to the default Pico-8 palette. We'll worry about that later. Note the negative space. We'll worry about that later too! Also, are there less sprites than you expected? 😏
Forgot to mention, there's a lot of hidden layers in the #aseprite files to assist both myself and the export script, the most important being the "ActorOrigin" layer (the red pixel left of the head), which lets the script calculate the offsets for each cel.
The build script is hacky, but it works. Not only does it pack the assets, but it also monitors Lua files for changes, strips whitespace so we don't go over the char limit from all the asset metadata + comments, and feeds the updated files to a running cart. We're ready to code.
Oh, it also compares compression algorithms for packing all those assets into cartdata. I should have linked to these way back, but these are both great:

PX9 by @lexaloffle
lexaloffle.com/bbs/?tid=34058

LZ77 by assemblerbot
lexaloffle.com/bbs/?tid=42198
The early versions of the game's engine failed. I was able to draw everything, but at massive CPU cost due to all the spritesheet swapping from draw order. Between times I gave up, I read @FSouchu's POOM blog, where he mentioned a sprite caching system...
This is also where multi-poke and unpack() come in. I was storing the spritesheets in Lua RAM already, but the idea of a cache for the sprites themselves hadn't occurred to me. I also realized I wasn't taking advantage of 0x8000+ addresses in Pico-8 RAM and memcpy().
Remember the negative space in some of the spritesheets? That's a staging area where we combine all the cels together to build/save a complete sprite, allowing us to swap spritesheets in/out without writing over them. The end result is saved to Lua RAM.
The concept for the cache is very similar to building the sprites, except we are just writing to another spritesheet stored at 0x8000+. Here's the first attempt (which has a bug)
There's lots of garbage data in the cache spritesheet, but that's okay, and by design, because we are only ever writing to or reading from 16 specific areas, and only the smallest amount of data we need. The leftover data from previous writes is never read when passed to sspr().
So we combined 2 spritesheets, bodies & heads, to create the rower frames; and saved them to Lua RAM for caching. Now they need faces drawn at runtime. There’s a problem tho, the cached sprites are flattened now, so how do we draw the face behind the fg body layer?
Bitmasks (and @FSouchu, once again) to the rescue! Since the faces could change in any given frame, this allows to do just that without having to re-create/destroy all those cached sprites we just created and still have nice cel layering with the arms/oars
Finally, we can draw everything. Let’s take care of those funky colors. As u may know, Pico-8 has 3 16-color palettes, each corresponding to a draw state. Back in the build stage, every single frame was assigned multiple custom palettes.
We’ll make extensive use of palette 0, and a lot of cycling magic inspired by the legendary @Mawkyman, who was kind enough to interact w me a little when I tagged him on my first experiments, which led to me soaking up every interview of his. Thx Mark!!!

This allows us to animate certain static images without costing a lot of storage/RAM/CPU. If you want to see this taken to its extreme, look no further than @Mawkyman’s @LivingWorldsArt, which is mind-blowing:
effectgames.com/demos/worlds/
Also, if you’ve never seen @Mawkyman’s GDC talk, it’s one of the most illuminating videos on computer graphics you’ll ever see. I promise. My palette cycling is nothing compared to his, but watching this helped me visualize the process a lot more easily.
In my case, it does cost a little storage. I opted to store the hundreds of palettes needed for cycling as byte arrays in-code back in the build script. This saves me tokens from having to build them at run-time, at the cost of a decent amount of compressed space.
Those byte arrays get poked directly into RAM, because it’s faster than calling pal() and passing tables, and again, much cheaper token-wise. Another trick from @FSouchu’s POOM blog!

pico-8.fandom.com/wiki/Memory#Dr…
Just want to pause for a moment to recognize how I probably couldn’t have made this game without @FSouchu’s didactics throughout the Pico8verse. Please support his work if you’re able to!

freds72.itch.io
While we're at it, I also want to recognize @johanpeitz, who really came through for me when I was clearly moving down the wrong path on collision detection for this project. Support him too, if you’re able!

johanpeitz.itch.io
And support @Mawkyman since we're talking about pixel art and palette cycling! He seems like such a nice guy and is constantly boosting indie pixel artists, simply out of love for the medium. markferrari.com
I’m a little nervous about showing this next image. It’s the magic show without smoke or mirrors, and maybe the most interesting and/or revealing of this whole thread! 😅
Here’s the game with no palette swapping or cycling at all. @Mawkyman refers to these as “clown colors”, but when you only have 16 colors in the palette, everything becomes clown colors, lol. Smurfy!
…and w the cycling turned back on. All those water fx w only one frame’s worth of gfx data and no particle system; all it costs is 16B/palette and a few tokens to select the right one at runtime! imo, this is the most magical effect employed by game devs of the 1980s/1990s!
Finally, apply the screen palette, and we’re back at the beginning!
The screen palette also allows for the day/night transition. I never intended for the parallax layers to be viewed this closely, note the half-resolution and use of fillp() patterns, but when I realized I couldn’t fit a proper title screen, this felt like an even better idea.
One of the things p8 has taught me is that when things don’t go as expected, u can often turn that to your advantage. I could have made this game in JS in a week or so, and often thought about doing so, but I don’t think it would have come out nearly as well.
For example, the zoomy camera, which becomes an important component of the difficulty level when your crewmate is ejected, it’s a lot harder to navigate when you can only see half as far ahead! That never would have happened outside of p8.
Phew! That’s it. I hope you enjoyed the thread, if you look at the timestamps on some of those old tweets, it’s been a long journey! Thanks so much for your interest! lmk if u have any questions, the community has given me so much and I’d love to give back in any way I can!!!
(here's the top of the thread, since twitter doesn't handle very long threads that well, lol)
Hopefully this shows up in the thread. I've located the author of the Pico-8 LZ77 library on Twitter, it's @assembler_bot!

• • •

Missing some Tweet in this thread? You can try to force a refresh
 

Keep Current with Ross J Kuhn

Ross J Kuhn Profile picture

Stay in touch and get notified when new unrolls are available from this author!

Read all threads

This Thread may be Removed Anytime!

PDF

Twitter may remove this content at anytime! Save it as PDF for later use!

Try unrolling a thread yourself!

how to unroll video
  1. Follow @ThreadReaderApp to mention us!

  2. From a Twitter thread mention us with a keyword "unroll"
@threadreaderapp unroll

Practice here first or read more on our help page!

More from @ridgekuhn

Jun 9
There's not much more I can do so I'm releasing the kraken. Just want to say thanks so much to everyone who's followed along for this journey the past year or so. I know it's bad to be externally motivated, but there were times where your support was what kept me going. 🧵
It was a big challenge for me to complete this project, technically, but also emotionally. If u ever wondered why one of the characters looks like me, there's a reason for that; the game is actually about me and my mom, who passed away in 2017.
I cried a lot while making it. I've debated a lot over the last few days whether I should even publish it, or do so without explaining why I made it. Ultimately tho, I want it to be out there, for my mom, who I miss more than I ever could have imagined.
Read 4 tweets

Did Thread Reader help you today?

Support us! We are indie developers!


This site is made by just two indie developers on a laptop doing marketing, support and development! Read more about the story.

Become a Premium Member ($3/month or $30/year) and get exclusive features!

Become Premium

Don't want to be a Premium member but still want to support us?

Make a small donation by buying us coffee ($5) or help with server cost ($10)

Donate via Paypal

Or Donate anonymously using crypto!

Ethereum

0xfe58350B80634f60Fa6Dc149a72b4DFbc17D341E copy

Bitcoin

3ATGMxNzCUFzxpMCHL5sWSt4DVtS8UqXpi copy

Thank you for your support!

Follow Us on Twitter!

:(