Posted November 05, 2024 by Andrew
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.
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.
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.
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:
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:
:with
syntax for local variables in systems looks awkward and should be refactored.(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.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 👍