Lispy Rogue postmortem


It’s finally time to wrap up Autumn Lisp Game Jam 2024, where I participated with a rogue-like game called Lispy Rogue (yes, I’m terrible at naming things — I’m a programmer, after all). I’ve wanted to explore this genre for a while, as it presents many interesting challenges: time discretization into turns, map generation, and so much more.

Choosing the Technical Stack

When prepping for the jam, I went back and forth on the technical stack. There’s an excellent Roguelike tutorial for Common Lisp, based on a Python tutorial that uses the C library libtcod. This library has a lot of useful features for roguelikes, like ASCII output, field-of-view calculations, and even pathfinding — all ready to use. There’s even a fully functional CL-based roguelike from the tutorial that I got sucked into for about twenty minutes 😁 But after thinking it over, I decided to use a familiar stack: liballegro, Nuklear, and cl-fast-ecs. This stack is tried-and-true for me, while using libtcod would have required some painful deployment steps since there are no precompiled binaries for Windows, Linux, nor MacOS.

This choice meant I had to implement turn-based logic myself. For inspiration, I looked to one of my favorite recent roguelikes, Path of Achra. In this game, the world animates and comes alive — complete with sound effects and special effects — whenever you take an action, but then pauses. Different characters act at different speeds, which I assume is based on the least common multiple of these speeds. For example, if my character’s speed is 6 and an enemy’s speed is 3, my character would kick an enemy twice for every enemy attack, then the world pauses again. I also read through a chapter in Harris’s book Exploring Roguelike Games on turn-based motion, but none of the approaches felt quite right. So, almost immediately, I came up with what I thought was an elegant solution: each ECS system simulating the game world (like movement or combat) takes a dt parameter for the time step as usual, but they only run when the global boolean *turn* is set to true value. This variable is set to true only when the player performs an action, like moving to a tile or attacking an enemy, and reverts to false once the action is completed, pausing the world again. This approach worked well, but as the game grew, a bug appeared: sometimes the simulation doesn’t pause as expected, leaving the player character open to being mobbed to death. I’ve tried hard to fix this, but it’s rare enough to remain elusive, so I’ve left it for future work.

Graphics and Tools

Instead of classic ASCII-style pseudo-graphics, I opted for a real tileset: the fantastic Urizen 1Bit, which I’d been eyeing for a while. At the jam’s start, I wondered how to store information about individual tiles in the tileset image — where to define things like the player character at offset (100, 100) or a wall tile at (200, 200). After brainstorming with ChatGPT, I decided to use Tiled, since it has a tileset format that allows custom properties for tiles, just like in my own tutorial. Using familiar, well-working tools saved me tons of time and helped me make a mostly finished game.

After that, I followed the Python roguelike tutorial, converting each feature into Common Lisp, ECS style. This approach was so much simpler than working with traditional OOP 😀 Performance is better too, although it was a bit of an afterthought. By the end, my game was using 17% of one CPU core at 75 FPS on my Ryzen 5. There are some obvious places to optimize, like memory allocation for a C struct that holds keyboard state — right now I’m doing this in multiple places, which could be consolidated. But when it came time to optimize, I had no energy left for it.

Combat

When it was time to implement combat, I turned to another game where I’d spent far too much time: Path of Exile. I started with melee combat, and I pretty much copied the damage calculations straight from it 😅 Why reinvent the wheel when it works? This is why my game has defensive stats like evasion, block chance, armor, and offensive stats like accuracy, so if you have high damage but low accuracy, you will miss frequently and still be ineffective.

On Thursday, the jam’s seventh day, I tried to fix some annoying bugs, including the one where the simulation didn’t pause, but with no luck. I burned out a bit, but eventually pushed through to add important new features. Later, I started building the item system — something I’d never gone this far with before, so I felt like in this meme 😁

It came out pretty good, and (spoiler alert) having good equipment is the key to winning the game.

The penultimate day saw me adding essential mechanics, like pathfinding with A* for enemies, ranged attacks, and UI windows for leveling up and win messages. I planned to leave game balance for the last day, along with sound effects for atmosphere. But after starting early, coffee-fueled, I spent most of Sunday adding enemies, ensuring they weren’t too hard or too easy, and fixing bugs, such as this one, where dropped items continued to count as equipped 🤣 Magic was cut down to two scrolls: a fireball scroll (from the tutorial) and a cripple scroll, which I added to slow fast enemies that are hard to escape. Finally, by 10 p.m., the final build was ready. I even recorded a 28-minute let’s play, where you can hear the caffeine wearing off toward the end 😔

Funny enough, I only properly tested the game balance on Monday morning, verifying that while it’s challenging, it’s not impossible to win — here’s the proof that you can reach the level 10 exit:

Conclusions

Apart from discovering how fun it is to make roguelikes (and that I’d probably want to take part in 7DRL jam next March), here are some areas for improvement:

  • Using and equipping items should take time in-game.
  • Noticed on Monday that due to low damage and high armor, the log sometimes shows messages like “so-and-so deals 0 damage to so-and-so” — a bit silly 😂
  • Memory allocation for the C struct storing keyboard state should be optimized.
  • Functions checking if tiles are lit or blocked could be optimized. I should revisit the Spatial Partition chapter in Nystrom’s Game Programming Patterns.
  • My code includes a bunch of global variables, essentially describing the game state — whether the help button pressed, the help window is shown, inventory button is pressed etc. I had a thought Sunday night that I should turn those into tag components on player entity, but it was too late.
  • Handling combat bonuses is a bit clunky, as it was my first time implementing something like this. It might have been cleaner to go all-in with ECS and make attributes like strength, dexterity, intelligence, accuracy etc. into dynamic entities. It would most definitely make the architecture cleaner.
  • My ECS framework, cl-fast-ecs, lacks some safety. Once, I mixed up level numbers and level entity IDs (which are integers as well), which caused a tough-to-debug error. Framework’s functions should check better whether their arguments are an actual entities.
  • The :with syntax for local variables in systems looks awkward and should be refactored.
  • I’d also like to support “state” variables retained between calls in system code.
  • Adding new component slot sometimes crashes the code in a running game. This breaks interactive development, a major feature of Lisp, I should fix that.
  • I also need to consider adding a (de)serialization for ECS data to save and load the game.
  • cl-liballegro binding lacks convenient accessors for C struct fields, you have to write an atrocity like (cffi:foreign-slot-value mouse-state '(:struct al:mouse-state) 'al::buttons). I might do a pull request with those later, the binding maintainer is very responsive and willing to cooperate.
  • Discovered a spectacular bug in my UI library, cl-liballegro-nuklear: if you define a window using a declarative interface with a large number of widgets, at a certain point the SBCL compiler fails to compile code for that window due to an excessive amount of internal macros, running into some internal limits. It’s a complex issue, but one that definitely needs fixing.
  • Finally, quite a few people have started leaving comments complaining that the game crashes with the error “Initializing display failed.” However, in some cases, it turns out they have Wayland-based potato in the PCI slot instead of an actual graphics card. I should implement detailed logging for the liballegro initialization process, maybe by using the function al_open_native_text_log, which, as I understand it, opens a log window reminiscent of the one in Quake 3 (if you remember that, then you, like me, are about 140 years old).

To conclude, there’s a plenty of interesting stuff to keep developing, and that’s exactly what I’ll be working on in my free time 👍

Get Lispy Rogue

Comments

Log in with itch.io to leave a comment.

(+1)

Great read, thanks for writing up a post-mortem.

Thanks!