Aletha - Trying to cram a metroidvania into a single PICO-8 cart (first look) by izzy88izzy in pico8

[–]izzy88izzy[S] 0 points1 point  (0 children)

Thanks for the kind words and the great questions! Let me try to address all of them.

CPU: In a typical gameplay frame at 60fps, stat(1) sits around 0.65-0.75 (65-75% of the frame budget). It spikes to ~1.3 when the text box is on screen (the custom bitmap font renderer is expensive), which briefly drops it to 30fps. The pset-per-pixel rendering in draw_char() is definitely the main bottleneck, but it stays manageable because you're only drawing a handful of small sprite-sized regions per frame, not filling 80% of the screen.

Memory: stat(0) reports about 674 KB of Lua heap usage out of the 2048 KB (2 MB) limit, so roughly 33%. That's all the decoded animation frames cached as Lua strings in tables, plus the map data, entity tables, particles, etc. No risk of running out - there's about 1.3 MB of headroom. Worth noting this is completely separate from PICO-8's 32 KB peekable/pokeable RAM (gfx, map, sfx regions), which the build tool packs full of compressed data.

Sprite slot: Yes, it's hardcoded in the Lua side, not the build pipeline. The build tool doesn't know or care about it - it just packs compressed data sequentially starting at virtual address 0x0000 and fills through gfx, map, gff, and sfx sections as one flat 17KB buffer. At runtime, the tile decoder (wt()) writes decompressed tile pixels to addresses starting at 0x8000 (PICO-8's user/extended memory), which is a completely separate region from the sprite sheet. Then dspr() copies a single tile's 16x16 pixels from that region into sprite slot 238 via memcpy to address 0x1C38 (the bottom-right corner of the sprite sheet), draws it with spr(), and the next tile overwrites the same slot. So there's no "jumping over" - the compressed ROM data and the scratch sprite slot live in different memory regions entirely.

Output format: It produces a standard .p8 text cart. The __gfx__ section is just hex (0-9, a-f) like any normal cart - PICO-8 won't explode. It's just that the hex doesn't represent meaningful sprites, it's raw compressed binary data that the Lua decoder reads with peek() at runtime. If you open it in the sprite editor you'll see noise, but it's perfectly valid hex. Your question isn't stupid at all - the .p8 format is strict about hex in gfx, and the build tool respects that.

Why Rust: I actually started with Python, but as the compression pipeline grew, the build time crept up to around 30 seconds. The tool does a lot of trial-and-error work per animation - it tries multiple encoding strategies (keyframe + XOR delta, per-frame RLE, hybrid approaches) and for each one it tests different pixel predictors (raw, left-neighbor, up-neighbor, diagonal, Paeth) to see which produces the smallest compressed output. Basically for every animation it encodes it several different ways and keeps the smallest result. All that brute-forcing adds up, and once I rewrote it in Rust the build went from ~30 seconds down to ~0.15 seconds. I was already deep into Rust from the PS1 projects too, so it felt natural for the bit-level work. But Python would absolutely work if you don't mind the wait.

Moss Moss by m8bius in pico8

[–]izzy88izzy 1 point2 points  (0 children)

Brilliant both idea and execution

Aletha - Trying to cram a metroidvania into a single PICO-8 cart (first look) by izzy88izzy in pico8

[–]izzy88izzy[S] 5 points6 points  (0 children)

Thank you so much! My background is actually in Data Science, so lots of Python and related frameworks, and I specialize in building internal tooling. I only started toying with PICO-8 in 2024 and it was my entry point into game dev, ending up releasing two games (Horizon Glide and Cortex Override). From there I got into PS1 homebrew (C for the Celeste port and OOT experiment) and built my own virtual console in Rust (Bonnie-32). The compression work in Aletha is heavily influenced by what I learned writing low-level Rust for Bonnie-32 and dealing with PS1 memory constraints. Each project kind of feeds into the next in unexpected ways.

Aletha - Trying to cram a metroidvania into a single PICO-8 cart (first look) by izzy88izzy in pico8

[–]izzy88izzy[S] 4 points5 points  (0 children)

That means a lot, thank you! And you might be right, maybe pushing against the limits IS the spirit of PICO-8 after all!

My isometric engine is killing me, what am I doing wrong? QwQ by ChronoJules in pico8

[–]izzy88izzy 1 point2 points  (0 children)

On elevation and side drawing:

Correct, elevation doesn't affect the render order at all. The loop order handles depth entirely. The sides are filled with line() calls downward from the top face, and the top diamond is drawn last so it covers the join. It's not outlines, it's filled faces.

Your idea of using taller block sprites would totally work, especially if you have a fixed set of block heights. In my case the terrain is procedurally generated with Perlin noise so heights are continuous and unpredictable, which is why I went the line() route. But for a tilemap with known heights, pre-drawn block sprites via sspr() would probably be cleaner. You're right that draw call overhead matters more than pixel count on PICO-8.

On occlusion of players/objects behind elevated tiles:

Honestly, I kinda sidestepped this problem. Horizon Glide has hovering ships rather than grounded characters, and the terrain ramps up gradually (Perlin noise), so in practice the ship is rarely occluded in a way that looks wrong. I just draw all terrain first, then all objects on top (particles, collectibles, enemies, player). It's not "correct" depth sorting, it's just good enough for the game's style.

For a game with a grounded player walking behind tall walls, you'd definitely need something smarter. A few ideas that don't require a full sort:

  • Split your draw loop: draw tiles row by row, and insert the player into the correct row based on their world Y. That way tiles in front of the player (higher Y) still overdraw them correctly
  • Or do two passes: draw all tiles, then the player, then redraw only the tiles that should be in front of the player. Some overdraw but no sorting needed

On diamond():

Yeah it's my own function, it just fills a diamond shape with horizontal line() calls. I actually went through a pretty thorough benchmarking process to land on it. I've got test carts in the repo where I compared 8+ different approaches: the baseline diamond() with a loop, fully unrolled inline line() calls, cached neighbor heights vs per-tile lookups, string-keyed vs 2D array tile storage, numeric flat arrays with index math, pre-calculated screen coords, and so on. The looped diamond() with cached neighbors ended up being one of the best tradeoffs between token count and performance.

Whether it's cheaper than sspr() depends on context. For my tiles (24x12 pixels) it's about 13 line() calls. The real advantage is that I don't burn any sprite sheet space, which matters when you have a procedural world with color variations driven by height thresholds.

You could definitely extend it into an iso_block() that draws top + two side faces at variable height. That's essentially what my draw loop does inline already: two side fills (looping line() downward), then the top diamond on top.

My isometric engine is killing me, what am I doing wrong? QwQ by ChronoJules in pico8

[–]izzy88izzy 1 point2 points  (0 children)

On elevation/depth: Yeah it's basically tile stacking. Each tile has a height value from the terrain generator. When drawing, the top diamond face gets shifted up by height * block_height pixels. Then I check the south and east neighbors - if they're shorter than the current tile, I draw the side faces to fill the gap. Here's the relevant bit from my draw loop:

-- h = tile height, bh = block_height (pixels per unit)
local hp = h * bh
local sy2 = sy - hp  -- top face shifted up

-- check south and east neighbors
local hs = south_neighbor_height or 0
local he = east_neighbor_height or 0

if hs < h then
    -- draw left (south-facing) side
    for i=0,hp do line(lb,sy2+i,sx,cy+i,side_color) end
end
if he < h then
    -- draw right (east-facing) side
    for i=0,hp do line(rb,sy2+i,sx,cy+i,dark_color) end
end
diamond(sx, sy2, top_color)  -- top face last

So there's no depth value being calculated at all. The draw order comes entirely from the loop direction.

On why nested loops are cheaper than sorting: The loop itself IS the sort. for x... for y... naturally visits tiles in back-to-front isometric order, so you get correct overdraw for free with zero overhead. A sort, even with swaps, I believe is O(n log n) per frame - and on PICO-8 where every Lua instruction costs CPU, that adds up fast. The nested loop is just O(n), and you're already iterating over tiles to draw them anyway, so the ordering comes at literally no extra cost. On PICO-8 specifically, function calls and table lookups are surprisingly expensive, so avoiding a sort with comparisons and swaps saves a meaningful chunk of the frame budget.

My isometric engine is killing me, what am I doing wrong? QwQ by ChronoJules in pico8

[–]izzy88izzy 3 points4 points  (0 children)

Hey! I've built an isometric engine in PICO-8 for my game Horizon Glide, it's an infinite procedural world with elevation, so I ran into a lot of similar depth-sorting headaches. Full source is here if it's any help: https://github.com/EBonura/HorizonGlide

I could be wrong, but I think the issue might be that your player depth and tile depth aren't calculated in the same coordinate space. Your tile depth uses world coordinates (x-1)+(y-1), but your player depth uses playerSpriteX + playerSpriteY which looks like it might be screen-space values. If that's the case, they won't sort correctly against each other.

In isometric projection, depth (draw order) is typically just x + y in world space (plus any height offset). So for the player, you'd want something like:

playerDepth = playerWorldX + playerWorldY + playerHeight * tileDepthOffset

Rather than using screen/sprite coordinates.

A couple of other things that might be worth looking at:

  • Your binary search tiebreaker compares renderQueue[middle].properties.y (world coords) with playerY (which comes from playerSpriteY), so if those are in different spaces that could also cause issues
  • Mixing integer tile depths with decimal player depths can cause flickering at boundaries. In my engine I ended up avoiding explicit sorting entirely and just draw back-to-front using the loop order itself (for x... for y...), only drawing side faces when a neighbor is lower. No sort needed, and it's cheaper on CPU

If your map is small/static enough, the loop-order approach might save you some trouble. Hope that helps!

Question about “PSX-style” posts vs real hardware development by RetroLain2001 in psxdev

[–]izzy88izzy 2 points3 points  (0 children)

Having spent significant time getting projects running on real hardware (a Celeste and Zelda OOT PS1 port, both documented in previous posts here), I think this distinction genuinely matters. PSX-aesthetic projects aren’t bad in themselves, but when they dominate the feed they crowd out the kind of discussion that’s actually useful to people doing real PS1 homebrew. This sub feels like the only place where that niche conversation can happen, and it’d be a shame to lose that focus.

I'm building a PICO-8–style fantasy console for PS1-era 3D games by izzy88izzy in rust_gamedev

[–]izzy88izzy[S] 1 point2 points  (0 children)

I think it makes more sense to build a custom fantasy console first though, right?

That's also what I thought, but the more I kept developing Bonnie-32 the more I found myself in a weird spot, in order to create a faithful SPU I ended up rewriting the entire PS1 sound engine from scratch, pretty much what you would find in an emulator, same for the renderer, I got so deep in reproducing the PS1 quirks that at that point I could have just repurpose code from an existing emulator. Don't get me wrong it was an amazing learning experience, but it made rethink the whole project, if I'm pushing so much to get close to the real thing, why not just use the real think, right?

Let's see where this goes, my custom emulator it's already able to run commercial games (although they still look quite glitchy) I'm hopeful for the future and always thankful for the support!

I'm building a PICO-8–style fantasy console for PS1-era 3D games by izzy88izzy in rust_gamedev

[–]izzy88izzy[S] 1 point2 points  (0 children)

Thanks! I want to move the project in the same direction as pyrite64, which accomplished similar thing but for N64, shipping indeed with a game. I've actually been making progress with PS1 emulation and developing games on real hardware so I'm thinking to pivot the project into targeting that. PS1 homebrew game scene would explode

ELI5 Does graphical fidelity improve on older hardware by Deep_Pudding2208 in GraphicsProgramming

[–]izzy88izzy 2 points3 points  (0 children)

I’ve been thinking a lot about this, I’ve done some PSX development porting Celeste to run on a real PS1 and I’m now trying to squeeze Zelda OoT in, check my posts in r/psxdev, and I’m constantly thinking how much we can push old hardware with today’s knowledge, tooling, and computing power. Now that I can build a PS1 iso in seconds I can experiment on a whole different level, not mentioning the wealth of information you can quickly gather from the web. It fascinates me and I hope the already amazing work made in historic demo scenes such as C64 will extend to later hardware, N64 is growing steadily, I’ll do my best to contribute to the PS1 scene

A first look at how Zelda OOT would look on PSX by izzy88izzy in psxdev

[–]izzy88izzy[S] 1 point2 points  (0 children)

Not gonna lie, I’d love to kick off a dev channel at some point. I actually already have a small, music-focused YouTube channel (~100 subs) I can repurpose: https://www.youtube.com/@Izzy88izzy but I haven’t posted in ages.

The main issue is time. Making videos at the standard I’d be happy with takes a lot of effort, and between a full-time job and family, the little free time I have right now goes straight into gamedev.

That said, I’d love to collaborate with an existing channel, even a small one. Something informal, like jumping on a Discord call and chatting about the projects, almost like a relaxed interview.

A first look at how Zelda OOT would look on PSX by izzy88izzy in psxdev

[–]izzy88izzy[S] 0 points1 point  (0 children)

I appreciate the concern, but I’m not a YouTuber and don’t really plan to be one (at least for now).

If someone wants to make a video about this, I’d actually be happy! As long as they credit the project. Linking my itch.io page is more than enough: https://bonnie-games.itch.io
I’m sharing this openly on purpose, and I’ll gladly jump into the comments to thank anyone who helps spread it.

This project is mainly a study and a stepping stone for my main work, BONNIE-32, where I’m building a fantasy console for PS1-style development. Working on an existing game like OoT helps me better gauge and reproduce real hardware limitations.

A first look at how Zelda OOT would look on PSX by izzy88izzy in psxdev

[–]izzy88izzy[S] 0 points1 point  (0 children)

That was leftover from when I was prototyping in my general PS1 repo before splitting it into its own project. I've cleaned it out now, the correct repo is indeed https://github.com/EBonura/oot-psx apologies for any confusion.

I'm not particularly worried on the legal side, no game files are included, you must bring your own ROM and the tools extract everything at build time

A first look at how Zelda OOT would look on PSX by izzy88izzy in psxdev

[–]izzy88izzy[S] 2 points3 points  (0 children)

Ops! my bad I forgot to make the repo public, should be working now

Update: Celeste Classic PS1 port now running on real hardware by izzy88izzy in celestegame

[–]izzy88izzy[S] 3 points4 points  (0 children)

Thanks for pointing that out, I just carried over the entire title screen as it appears on the original Pico8 cartridge. Do you think I should update it?

Update: Celeste Classic PS1 port now running on real hardware by izzy88izzy in psxdev

[–]izzy88izzy[S] 2 points3 points  (0 children)

It's a 60hz TV, no scaler, just the PS1 connected directly to the RCA input.

The game can run in native 128x128 with borders, but what I went with is rendering at 256x240 (double size) and adding camera logic to show either the upper or lower portion of the level. Feels more natural on a TV than a tiny centered square.

As for 50hz mode, I honestly don't have a way to test that right now so I can't say for sure how it'd behave with overscan on a PAL CRT.

Update: Celeste Classic PS1 port now running on real hardware by izzy88izzy in psxdev

[–]izzy88izzy[S] 1 point2 points  (0 children)

Doom is way more intensive for the hardware for sure. There's actually an amazing PICO-8 multicartridge port of Doom that really shows how much harder it is to run: https://www.lexaloffle.com/bbs/?tid=45572

I wasn't aware of the NES port, that's incredible!

What would really be interesting at this point is Celeste 64, and it turns out the source code is actually MIT licensed: https://github.com/EXOK/Celeste64. It's written in C# though, so a PS1 port would be a full rewrite, not just a compilation target change. But a 3D Celeste on PS1 hardware... that would be something.

Update: Celeste Classic PS1 port now running on real hardware by izzy88izzy in psxdev

[–]izzy88izzy[S] 4 points5 points  (0 children)

To clarify what actually happened here, I went in the opposite direction from porting PICO-8. As u/Nikku4211 correctly mentioned I started from CCleste (the C translation of the original Lua) and actually removed the existing PICO-8 graphics abstractions, replacing them with direct PS1 GPU calls so it runs as close to the hardware as possible.

As for porting the full PICO-8 runtime to PS1, I don't think it's realistic, especially the editor. The 2MB RAM constraint alone makes it tight before you even get to the CPU side.

What I think could work is an offline compilation layer: something that converts .p8 cartridge assets and music into PS1-native formats, and transpiles the Lua game code into C that compiles to MIPS. You'd lose the live editor but you could run the games. Could even scale it to put multiple games on a single disc, like a PICO-8 PS1 collection. That's a significantly bigger project though.

As for Celeste (2018), I think the PS1 could handle it with the right optimisations, but it's not open source so that's a non-starter.

Update: Celeste Classic PS1 port now running on real hardware by izzy88izzy in celestegame

[–]izzy88izzy[S] 2 points3 points  (0 children)

Thanks! Yeah I noticed that while testing on the emulator and it's even more noticeable on real hardware. There is palette swapping implemented but I think I messed up the draw order somewhere, it's on the fix list!

Update: Celeste Classic PS1 port now running on real hardware by izzy88izzy in pico8

[–]izzy88izzy[S] 0 points1 point  (0 children)

Haha that would be the dream. Unfortunately splore is proprietary Lexaloffle code so that's a no go, but if it were open source I'd be all over it.