Posted June 12, 2025 by yourykiki
#pico-8 #postmortem
In this post, I share how I approached graphics, memory handling, and performance optimization for my PICO-8 game—from sprite compression and map depth simulation to fog of war and the minimap. I hope you like it !
At the beginning of the project (around 2022), I built a prototype with a scrollable map and an empty UI. As soon as I started adding unit animations, buildings, and interface elements, I quickly realized that a single 128×128 spritesheet wouldn’t be sufficient.
Through the community, I discovered PX9 compression, an algorithm that works well with repetitive pixel data. With it, I managed to store up to four spritesheets in the space of one. By experimenting, I was able to compress graphics for the map, human units, and buildings into just two sheets, without much additional optimization.
My initial goal was to fit everything into a single cartridge. So, I included both the PX9 decompression code and the compressed data directly in the main game cart. Later to free some Pico8 tokens, I switched to a multi-cartridge setup, moving the decompression code and data to a dedicated data cartridge.
Edit one of the four GIMP files (pack1.xcf
, ..., pack4.xcf
)
Export the file to PNG
Import the PNG into the corresponding .p8
cartridge
Use a compress_pack.p8
utility to compress and save the data inside a PICO-8 data cartridge
Note: GIMP is optional—you can use any image editor, including Pico8.
PICO-8 cartridges have 32KB of memory. Since version 0.2.4, an additional 32KB of high memory was introduced, which is the exact size of four spritesheets.
The idea is to decompress the packed sprites once into high memory, then draw from that memory during gameplay. My first implementation copied sprites from high memory into the main spritesheet, and then used spr()
or sspr()
to draw them.
This worked, but the memory copy added overhead.
With version 0.2.6, PICO-8 introduced video memory remapping using poke(0x5f54, ...)
. This allowed me to switch the active spritesheet to a high memory bank, eliminating the need for copying.
And that's it ! To simplify usage, I created two wrapper functions:
-- spr() wrapper to use sprites from high memory function vspr(nspr,x,y,flipx) --map spritesheet to highmem poke(0x5f54,nspr\256*32+128) --draw sprite spr(nspr%256,x,y,1,1,flipx) --restore scr spr mapping poke(0x5f54,0) end -- sspr() wrapper with memory remapping function vsspr(bank,sx,sy,w,h,dx,dy,dw,dh,flipx) --map spritesheet to highmem poke(0x5f54,b*32+128) --draw sprite sspr(sx,sy,w,h,dx,dy,dw or w,dh or h,flipx or false) --restore scr spr mapping poke(0x5f54,0) end
With these two functions, I could directly access and draw decompressed sprites from high memory—saving both CPU and tokens.
My map system consists of:
A tilemap background
A set of horizontal banks for units and objects, one for each row of tiles (to simulate depth)
The background includes 27 tile types (grass, dirt, water) and is always stored in the first spritesheet, where the first three lines are reserved for common tiles and cursors.
To make editing easier, I created a custom map editor, as the built-in one lacked features like object placement and auto-tiling. This tool also compresses and stores map data into the launcher cartridge.
PICO-8 allows certain memory areas to persist between cartridges. I use 0x4300
as temporary storage when switching from the launcher to the game.
After choosing a level, the launcher decompresses the background map into 0x4300
The game cart copies this data into the standard map memory at 0x2000
Using poke(0x5f57, map_width)
, I define the map width for rendering
Since 0x4300
holds 4864 bytes and a 64×64 tilemap only uses 4096 bytes, this works perfectly.
To simulate depth (units appearing in front of or behind buildings), I organize objects into banks based on their Y coordinate. Each tile row corresponds to a bank.
When a unit moves to a new row, it's removed from its old bank and inserted into the new one.
During rendering, I simply loop through the banks from top to bottom and draw objects—this automatically handles depth without needing a sorting algorithm.
Fog of war is one of the most CPU-intensive features. Fortunately, the playable area is smaller than the full screen due to the UI (about 106 pixels tall), so I can post-process it efficiently using sprite memory.
Draw tilemap, buildings, and units
Copy the visible screen to the spritesheet (starting at line 24)
Draw black circles over areas within line-of-sight
Use sspr()
to apply darkening and transparency
Repeat the process for already visited areas
This system uses memory remapping and offscreen rendering to maintain performance.
Under the hood, here is how it looks:
This was the most complex feature to optimize !
I first tried drawing each tile and unit pixel-by-pixel based on map data, with fog of war. This method consumed up to 20% of the CPU—far too much !
I reserved the last upper memory page (128×128 pixels) and split it into four regions:
Top-left: Unit/building markers + workspace (32×32 pixels)
Bottom-right: Pre-rendered full map background
Bottom-left: Visibility mask
Top-right: Visited areas
These layers are merged just like the fog of war system. Drawing uses vsspr()
and circfill()
instead of per-pixel logic.
This redesign reduced CPU usage by 14%, and the performance is now much more stable. Wouhou !!
Working within PICO-8’s constraints requires creative solutions. By using compression, memory remapping, and smart rendering pipelines, I was able to build a visually rich strategy game within tight limitations.
This article focused on the technical systems behind sprite storage, map depth, fog of war, and the minimap. If you're working on a similar project or curious about the tools I used, feel free to reach out or comment.
Thanks for reading!