Into Ruins
by ericb
Reach depth 16 and retrieve the Wings of Yendor
Controls
(◀)(▶)(▼) : Turn
(▲) : Step forward / Attack / Interact
(X) : Open inventory
(O)/Z/C : Wait 1 turn
Into Ruins is a roguelike for the PICO-8 Fantasy Console.
There are no stairs. Jump down holes in the ground to make your way to the bottom. You will
encounter natural cave formations, crumbling dungeon rooms, and terrifying creatures on your
way.
There are no doors. Tread carefully or the creature in the next room might spot you. Light
helps you explore more easily, but also reveals you to your foes. Fires can spread wildly
through the environment, while glowing mushroom spores can mend your wounds.
There are no classes or character levels. Expand your abilities by finding magical items on
your descent, and bring them to their full potential with Orbs of Power.
Each attempt is a new beginning — the different types of orbs, staves, cloaks and
amulets will not be immediately recognizable to you. Some experimentation is required to
identify them.
Credits
Into Ruins was created by Eric Billingsley. It was greatly inspired by Brogue by Brian Walker.
Thanks to FReDs72, Heracleum, James Edward Smith, morgan, Oli414, Sim, SlainteES,
SmellyFishstiks and Waporwave for beta testing and feedback.
Technical Info
I figured some of you might be interested in some of the technical aspects of the game. This
was my first foray into PICO-8 and Lua, and I learned a lot along the way -- from discovering
with shock that variables are global by default after a week and a half of strange bugs, to
trying to cram as much as possible into strings, and _ENV abuse to reduce token count.
How does it all fit?
Initially I thought I could fit everything into 1 cart, but partway through I realized that
even if I could fit all the features I had planned in while respecting the token limit, the
compressed character limit would be a problem.
The final version of the game is in 2 carts -- one for the title screen and text intro, and
another for the main game. The title screen cart also puts a huge string containing most of
the game's data (creature stat blocks, animation data, item stats, text descriptions) into the
upper memory region, which is then read and parsed by the main cart. Because the title screen
still displays the environment and character, there's a lot of similar code between the two
carts, though a lot of things that were refactored to save tokens in the main cart are still
in the title cart in their original form.
Even with these measures, the main cart still needed to be stripped of comments, newlines, and
whitespace using shrinko8 to fit within the compressed character limit.
As far as tokens go, making almost everything in the game be the same type of object went a
long way. Each object has a type (which is just its sprite number), and from that we can
initialize its properties with a combination of default values and ones read from the huge
string mentioned earlier. Having everything be intialized and accessed the same way means that
each object has a lot of data it doesn't actually need, but we aren't strapped for Lua memory
so that's okay! The upside is we can reuse code where appropriate, and make things interact
more easily. To save tokens on accessing properties, I also made heavy use of the _ENV trick
outlined by slainte here, and seleb's list of token optimizations was indispensible as well.
Level generation
The levels are generated using a couple different algorithms. Levels can contain natural
caves, manmade dungeon rooms, or both. In either case, there is a variable called Entropy
which ticks down as more of the level is generated.
Caves
Caves are made recursively using a type of random walk. From our current position, we try to
generate another tile in a random neighbouring space. We then decide whether to recurse again
from this position or backtrack -- this probability is based on the current entropy, so at the
start we always continue, and by the end backtracking is more likely. Playing with the
starting entropy and the amount it goes down by with each recursion allows us to achieve the
right density and balance between narrow tunnels and wide open areas.
When choosing which tile to put in a new space, we consider the tile we are generating from.
Each tile type has a corresponding section of the map data which defines 16 possibilities for
the next tile, so we choose one at random from these. In a way this is like a Markov Chain,
where the state is just based on the tile we are coming from. Holes, grass and other
environmental features are all generated this way. The tile definition can also include an
entity to generate, like a mushroom, brazier, chair or barrel.
With each recursion, there's a very small chance we switch to generating rooms.
Rooms
For rooms, we choose a random point on the map which will be contained in the room, and then
random values for the room's width and height. The boundaries of the room are clamped to
multiples of 2 or 4 to get them to lock together well and play nice with the rendering. We
then create the floor and walls of the room, with one dimension being staggered to fit
properly into the hex grid. Each room gets a random crumble value, which we use to decide if
we should replace parts of the wall with different tiles (selected in the same way as in the
cave generation).
After generating each room, there's a chance we switch to cave generation. Otherwise, we keep
generating rooms at random positions until the entropy reaches 0. The rooms can overlap each
other, and each time we generate one, a random openplan variable sets whether it should have
walls on all sides, or try to merge with any overlapping rooms. In this way, we can get all
kinds of interesting dungeon shapes just by combining rectangles.
Post-processing
Once the initial generation is complete, we analyze the level to find parts that are
inaccessible, and then find a nearby position the player can get to and connect them in a
straight line, either by building bridges or tunneling through walls. Then we recurse randomly
through the level and fill out the contents of the manmade rooms, in the same way we did with
the caves earlier.
There are a few more steps, like creating cave walls, ensuring there are exit holes, and
running the code to connect areas again at various points. One interesting step is to replace
cave walls which have walkable tiles both below and above them with a random tile, again
sampled from the map data. This is actually the only way that stalagmites and mushrooms can be
generated, and I think it makes their placement a bit more natural looking.
Spawning
With the environment generated, all that's left is to spawn creatures and items.
Creature spawn groups are again defined in the map, with each line corresponding to a
different depth. We attempt 6 times to spawn a group of creatures, either choosing a group
from the current depth or sometimes randomly from a greater one for a nasty surprise. We pick
a random position, and try to spawn a creature there, and then move on to a to a neighbouring
tile for the next one in the group. If it turns out the spawn point is invalid, we don't try
again, just move on. This means some levels, especially smaller ones, will tend to have fewer
creatures but that's okay -- it just adds variety!
Items are spawned in a similar way, with the depth just affecting how many we try to spawn (we
attempt to spawn slightly more items on earlier depths than on later ones). For certain
important orbs, we make extra spawn attempts if there haven't been enough of them generated
yet over the course of the game based on the current depth.
Wrap-up
There's a lot of other things that I think could be interesting to write about, like the
lighting, FOV algorithm, or various aspects of the AI, but for now I think this post is long
enough. Let me know if there's anything you'd like to hear more about!
Into Ruins is also available on itch.io, and the unminified code is up on Github.
v1.07 changes (2024/05/05)
Fixed victory animation not playing properly when attacking with rapier on final move
Spear special attack now triggers when summoned blades, pushed objects, or teleporting enemies
enter the guarded space
Improved spore/fire interaction so that burned mushrooms or glowhorns now produce an expanding
fireball when destroyed
Fixed lit torch thrown at pink jelly only lighting one of the split jellies on fire
Fixed lit torch thrown at an enemy flying over a pit not setting it on fire
Fixed sleeping enemies pushed by a thrown orb of gravity not falling down pits or bumping into
things properly
Enemies pushed by a thrown orb of gravity now lose their turn
v1.06 changes (2023/06/22)
Web player touch controls (itch & newgrounds only): removed ability to input a diagonal on
the d-pad by pressing in between two directions.
Web player touch controls (itch & newgrounds only): removed ability to input simultaneous
X and O by pressing in between both buttons.
Fixed cursor being off by 1 pixel vertically when aiming
Adjusted aiming prompt text alignment
v1.05 Changes (2023/06/09)
Added custom font
Clearer text descriptions for Orbs of Gravity and Light
Fixed searching enemies sometimes moving after spotting you
Fixed orbs not activating when thrown at enemies flying over a hole
Fixed Pink Jellies having the wrong icon when hit with an Orb of Data
v1.04 Changes (2023/03/08)
Increased chance that a given spawned item will be a "fun" orb (fire, ice, teleport) -- other
items will spawn slightly less often as a result
Improved several movement animations (player & some enemies) that had incorrect timing
v1.03 Changes (2023/01/02)
Increased chance of spawning a weapon or staff until at least one has been spawned
Increased chance of spawning an uncursed wearable item until at least one has been spawned
Flesh Horrors have a new unique ability...
Frozen enemies no longer display "?" when switching to searching state
Fixed player appearing not to die on game over screen when killed while being pushed down a
pit with slofall active
Fixed a visual glitch that would happen if you blinked to victory
Fixed player not casting light until taking a step after descending while on fire
Fixed creatures not casting light until next turn when they catch fire from being in light
Fixed tiles not catching fire if a burning creature moves onto it and either dies or stops
burning immediately after
Fixed creatures with idle animations still animating while frozen
v1.02 Changes (2022/11/08)
Improved fire animation (was intended to flip horizontally every 3 frames, but I accidentally
broke this before release)
Changed text description for Orb of Light for improved clarity
v1.01 Changes (2022/11/02)
You can now pick up <important item> even if your inventory is full.
Fix a rare bug where 2 Mirrorshards could teleport into the same space, with strange effects.
Increase Mirrorshard HP from 6 to 8