Skip to main content
grains

Overworld

Overworld is a game for Playdate written in Lua that is inspired by AirXonix. It is a 1-bit action-puzzle where your goal is to fill a rectangle with pixels by slicing it's surface into pieces, one polygon at a time.

A short demo of Overworld gameplay. A circle runs around a square arena, capturing area by cutting away blobs of white pixels where enemies can't reach you

I'd like to extend on the original concept by introducing an open-world, jRPG-like gameplay. At the moment I'm in an early phase, trying to make the classic concept work without a lag.

👉 If you'd like to learn more about the original xonix game and its numerous clones, check out my notes about AirXonix.

Progress Tracker #

I decided to track the development process day-by-day to be able to see how much of an actual time it will take. It's been a humbling experience: the gameplay looked relatively simple and I didn't expect to get stuck knee-deep in technical issues for so long.

Day 1 #

A bunch of ball shapes and lines on a white rectangular surface

Figured out how to draw the field, the player, and the trace (or the "attack path", as I call it below).

Day 2 #

Implemented a slow but working version of the floodfill algorithm to capture field areas. The painted area doesn't re-render until the player moves over it, not sure what is the problem.

Day 3 #

Day 4 #

Updated the diary entries in README.md, not much else done.

Day 5 #

Up until this point, all of the code resided in one file, so making changes to it became tedious. Spent the day to refactor the code to OOP clases following the Object Oriented Programming for the Playdate SDK tutorial.

Day 6 #

A couple days ago I learned about quadtree, a widespread data structure that can solve a few problems when it comes to performance. It got me excited, so I came up with an idea to track the captured area inside of a quadtree to replace the inefficient 400x270 grid.

Every polygon should split into a union of rectangles, allowing me to operate on a few hundred nodes rather than tens of thousands of pixels (or at least I thought so for quite some time – note from the future me). Now it feels like it will be much easier to implement the rest of the code on top of it: collisions and floodfill. All you need is a quadtree.

AI trigger warning: an LLM generated an almost working implementation of the tree. The result wasn't impressive but it was usable and removed a mental block that kept me from moving further. I only had to fix a few bugs and implement a visualizer to make sense of the tree.

Day 7 #

With the quadtree visualizer done the creative juice started flowing again, and I settled on the idea for the game: an open-world xonix. The game happens on an overworld map, with only a part of the map being visible at any given time. The overworld concept is coming from old jRPGs, as seen in Robin Sloan's game concept called Perils of the Overworld (click on the link to read the devlog, it's a curious little series).

Well past midnight I started a research on how to integrate the player's attack path into the tree and learned a few things about polygon covering. Random thought: I really need to settle on the vocabulary for the game (i.e. "attack", "path", "captured area", "arena" vs "field etc).

Day 8 #

I've spent a bit more time reading about polygon covering and visualized a potential algorithm in Aseprite. After a bit of headscratching it was declared an unnecessary distraction: I already had a method to add a rectangle to the tree which I can reuse to store the attack path as a series of rectangles with width equal to 1. The side-effect of this decision was that now there's far more nodes in the tree, as it has to split many times. It's not laggy now, but might end up being laggy later in the game.

An animation visualizing how the tree changes each time a simple shape is added to it

Yasna noted that the resulting pattern resembles a rug ornament, and Tyoma added that the data structure can be exploited for visual effects. I like the idea, there could be a carpet-themed level one day.

I tried out the Playdate collisions API by simply adding empty collision sprites on every tree node .addEmptyCollisionSprite(). Theoretically, I could add all the nodes as regular sprites. It works, but not perfectly: since collision sprites have boundaries, I randomly get stuck in the walls all the time. Also the collisions do not get triggered when crossing an active attack path.

Day 9 #

The built-in collisions engine worked well, so I won't have to reinvent the wheel. It's simple and fast, and it's based on an open source engine bump.lua. As a cherry on top, it was dead simple to add enemy movements once the player collisions were in place. Check out the demo visualization shipped with bump.lua, it shows how the internal data structure tracks objects of interest as the player moves through the map.

A couple workarounds I had to implement:

Happy with how collisions turned out, I started questioning whether the game really needs a quadtree at this point. All I wanted it to do is to run the floodfill faster, but I am still using the old algorithm and don't have any idea how to switch it to trees.

Day 10 #

I'm trying not to give up on quadtrees, so I spent some time researching. A quadtree is normally used to speed up finding objects that are in close vicinity, i.e. "find all enemies in a blast radius of a hand-grenade". Truth is, there are no similar mechanics in xonix. I picked it for novelty and hoped for a little bit of magic to save me from solving the real problem: efficiently detecting isolated blobs of pixels on the screen.

Solving this problem with a tree is possible, but not at all straightforward. It can be done by recursively iterating over all neighbor nodes until the outside boundary is encountered, and detecting neighbours in a tree is no easy task. The Advanced Octrees 4: finding neighbor nodes by David Geier contains a great explanation and an incomplete code example.

There's a few more curious papers about the problem:

Day 11-12 #

I implemented David's algo for finding neighbors, and it worked perfectly, letting me build a tree-based recursive floodfill. That said, my floodfill algo painted the entire screen black instead of filling a closed area, and I couldn't catch the bug for some time.

Turns out, there were gaps in the tree which caused the floodfill to spill into adjacent areas. I managed to incorrectly implement both the tree construction and the tree visualization routines incorrectly, and one covered for the others: the tree looked perfectly gapless on the screen.

Another LLM-generated piece of code helped me find the bug. This time it created a browser-based visualizer which made spotting the gaps and locating all the edge-cases of the algorithm much simpler.

A screenshot from a tree visualizer: a pattern of rectangles colored by their tree depth, oddly reminiscent of a rug

A screenshot from the visualizer: the grey cells are the cells that are marked as filled in the game

Somehow, the floodfill didn't work as I expected, and it was still slow. At this point the node counts is way above 10k, and the code is hard to follow, which is worse than my tree-less version by a margin by any measure I can think of.

Day 13 #

Today is the day: I gave up on quadtrees and went back to a simple grid representation. There's less code now and it is orders of magniture simpler to reason about. That said, my naive floodfill is as slow as before, so we're back to square one.

After the migration was completed, I tried running the game on Playdate for the first time, and to no surprise, it crashed immediately. Turns out even the earlier versions crashed on the real hardware. I suspect this happens because all of them abuse the Lua garbage collector which only becomes apparent on the device. The simulator is more forgiving as there is no memory limit.

This part – performance optimization – is what I have been subconsciously avoiding by using a "smart" data structure. I know next to nothing about game engine optimization and it is scary.

The ultimate solution to the problem would be either to reimplement the field data structure and the algorithm in C, or to reduce the field resolution from 220x220 to something like 100x100 or even less. These two options were around since the beginning but there should be a lot of low hanging fruits before I take this step.

I started by removing the float -> int conversion that happened two times per each coordinate per frame, which improved the situation substantially. Then, I replaced all the nice and readable string enums with integers, and topped it up by removing a class that represented a pixel in the grid. As a result, memory usage per frame dropped from 12Mb to 2Mb, letting me run the game on Playdate. Not bad! Although the game freezes for 5-7 seconds when the attacks end.

To fuel my motivation, I decided to step back from the engine and have some fun with the game itself. A few missing pieces were added: some basic sounds, a score system and the game over screen. This relatively low effort promoted Overworld from a prototype to a real game, nevermind it is still slow and boring.

The Unfinished Horse Drawing meme where the back half of the horse is incredibly detailed and beautiful, while the front part is a wonky and naive unfinished sketch. Regardless of its qualities, there's no doubt it's an actual horse.

The Wonky Horse is a metaphor for the text above

Day 14 #

Happy with the fact that the game actually runs on a playdate, and can be "beaten" to some extent, I decided to clean up my devlog and publish it on grains. Looking back at 14 separate days of work, I'm surprised how far I've gotten without giving up and switching to the next thing.

Day 15-16 #

I'm trying to improve performance by approaching it from two different angles: trying to pinpoint the current performance bottlenecks and at the same time thinking of alternative algorithms that I could switch to. Still learning to use the sampler view of the simulator. Switched the 2D grid data structure to a more efficient 1D table. Tried implementing floodfill without recursion, felt slower – not sure why.

Day 17 #

Tyoma stepped in to help me with peformance issues and in a matter of hours he resolved all of the open questions I had. He swapped the floodfill algo with a non-recursive version and created a stripped-down version of a game to isolate the issue further.

We confirmed that the game crashes on device due to CPU limits: Playdate OS runs a watchdog process that kills the game when the main loop hangs for 10 seconds. This information can be found in the error.log when the device is rebooted into the drive mode:

2024/02/12 17:26:15 /Games/overworld.pdx Run loop stalled for more than 10 seconds pc=0x2403F752 lr=0x24031317

Tyoma implemented a coroutine that animates the floodfill over multiple frames, unblocking the game loop:

Player moves towards the outer edges of a rectangle, starting multiple independent floodfills along the way. The floodfills grow in a shape of a rotated square until they all meet, painting the entire rectangle in black.

My next goal is to move from a brute-force DFS flood-fill to something that doesn't require so many CPU cycles. I'll try to do the following: