Roguelike Conversion

Release: Gamebanana page
Repository: GitHub

Project Overview

Brief: This mod, Dungeons of Soleanna (DoS), adds a new selectable gamemode, loading the player into a randomly generated dungeon using a 3x3 grid of rooms. To progress, the player most solve puzzles, find unique keys and defeat enemies while searching for the Boss Room.

After years of wanting to make a Roguelike dungeon crawler, I finally decided to prototype it in the most unorthodox way possible.
Sonic The Hedgehog (2006) is a game infamous for its strange controls, poor performance and myriad glitches, yet under the hood it's a surprisingly capable product, boasting the most modular design of any game in the series right out of the box (at least for modders.) After working with the game for years, I became well acquainted with its quirks and decided it would be a fun challenge to try and realize my Roguelike dreams by using it like a game engine.
This required cutting out a great many features, but it forced me to truly learn all the underlying mechanics that go into making a game like this so that I could properly adapt them into such a unique environment.

NOTES: No code injection was used for this project, beyond a minor patch to enable more dynamic audio. Everything was done within the game's native Lua, along with clever game object manipulation.
This project was featured in the Sonic Hacking Contest, where it received an honorable mention for Best Technical Work and a trophy for Best Boss Design. Judge evaluations can be found here.

The following are common terms used internally by the game. I will be referring to these often throughout this page.

  • Event: A string passed to Lua by some occurrence in the game world, most commonly sent when a player enters an "eventbox" (invisible collider.) The event string, as well as behavior invoked, is entirely up to the programmer, but there are certain C methods that can be invoked directly which target objects (e.g., signaling, which destroys ObjectPhysics).
  • ObjectPhysics: A generic havok object. Model data, Collision and other properties are pulled from a .bin file, making it easy to create custom ones.
  • SET: Object layout file.
  • ObjID: Every object has a simple internal ID corresponding to the order it was placed in the SET. Every object also has an "ObjName" parameter which is simply a string handle. Either can be used when calling a function that targets objects.
  • Group: A list of objects, collected by their IDs, with a string handle. Events can target the Group Name, passing the message to every object in the group, and Groups can be assigned a function to run when every object within the Group is destroyed.
  • Inactive: Every object can be declared as Inactive on start. This means the object still gets loaded when constructing the stage, but it won't appear in the game world until activated by an event. Once activated, an object cannot be de-activated.

I heavily based the design on the Dungeoneering skill from RuneScape. While my ideal game would flesh out many of those mechanics and incorporate new ones, for this project I focused on the basics:

  • Random, Grid Layout: A "floor" should consist of box-shaped rooms arranged on a grid, randomly connected at runtime. These rooms should be persistent so that the player can freely backtrack, so the connections have to be physical.
  • Varied Decoration: The interior of each room should have some random variation to it. A box on its own isn't interesting to explore, but a box segmented by crumbling wooden planks or designed like a barracks changes how you interact with it. This should change between runs to keep things fresh.
  • Exploratory Progression: Rooms have up to 4 doors that connect to other rooms. These doors can be locked until the player finds a unique key spawned elsewhere in the dungeon. This encourages exploring each room thoroughly and good spatial awareness as backtracking may be required.
  • Puzzles: Rooms may generate as "Puzzle Rooms", requiring the player to solve one of several randomly selected puzzles to unlock the doors. Puzzles can cover practically anything, as long as it's a good switch up from regular gameplay and quick to clear on repeat runs.
  • Boss Fight: One, mechanically interesting boss at the end of the dungeon. While Dungeoneering bosses tend to be more of one-trick minibosses, I took inspiration from MMO Raids for the final confrontation in DoS.

Of course, these goals came with their own sets of challenges that had be solved beforehand, such as:

  • Ensuring every layout can be completed.
  • Random generation in a game with virtually no prefab or instantiation system.
  • Synchronizing dungeon state in code between Lua instances.

The rest of this page will focus on the steps taken to solve these problems and implement these goals.

While Sonic 06 uses Lua for everything from stage creation to enemy behavior, it does not fully implement the language. Instead, Sonic Team decided to spin up different instances of Lua for each part of the game that uses it, then only expose whatever C-side functions were strictly necessary. While understandable, this means that generally the only part of Lua's standard libraries or functions available is the Math library.
Out of the 6 or so Lua instances that Sonic 06 creates, only three were important for this project.

  • Mission Instance
    • High level component of level progression.
    • Contains functions to manage NPC dialogue flow, display text boxes or other UI elements, and read certain flags from save data.
    • Mission scripts support intercepting events and incoming textboxes by adding "on_hint" and "on_event" functions. The string associated with the hint/event gets passed to the corresponding function.
  • Enemy Instance
    • Scripts containing enemy behavior. Contains generic functions acting as state templates (OnDamage, Update, Appear, etc.), allowing the author to create specific action sequences.
    • Supports functions to manipulate the camera, move the player, call textboxes, instantiate ObjectPhysics at coordinates, check if certain ObjectPhysics exist in the SET, and more.
    • The Enemy game object simply points to a table entry from one of these scripts, so it's trivial to create custom enemy behavior.
  • Stage Instance
    • Lower level component of level progression.
    • Has access to most standard Lua libraries and functions (String, Package and Debug are excluded).
    • Supports functions for interacting directly with the SET/stage, such as activating or sending pre-defined messages to objects and playing sound effects.
    • Events are passed to a "ProcessMessage" function by their string.

It's easy to imagine how much can be done through Lua using these instances, but the trouble is getting them to communicate as they're completely separated internally.
Thankfully, this is easier than it might sound. Sonic 06 has a volume object called "ScoreCollision", which targets an ObjectPhysics by its type (e.g., WoodBox, Barrel, BreakRoad) and destroys it upon entry, granting the player some points and passing a message to Lua called "Add_(Destroyed_Obj_Type)". The key is that since this is an event, it gets passed to both the Stage and Mission instances, and since it's invoked with an object, both the Enemy and Stage instances can decide when that fires (either by instantiating an object into a collider or activating a pre-placed one, respectively).
Furthermore, enemies can pass more sophisticated messages to the Mission environment by simply calling a textbox. The string invoked will be passed to the Mission's "on_hint" function, where it works as any other argument to a function. The textbox that would've been called can even be entirely ignored.

Here's an example using a locked door.

  • Doors in 06 are just game objects that can be opened or closed using an event or a function from the Stage instance.
  • In DoS, doors can require a "key" to open, which is randomly selected and spawned elsewhere in the dungeon. When a key is collected, it will appear on the HUD, and when it's used it will disappear.
  • When the player gets near a locked door, it should either consume the key and open or display a textbox saying which key is required.

The trouble with this is the textbox and HUD. I store dungeon information in the Stage instance, so it's easy to track if the player has collected a key and consume it to open doors (using the Lua function), but Stage can't handle UI or invoke text; Mission is needed for that.
It's not feasible to tell Mission how the entire dungeon generated, but it's not unreasonable to send a small piece of information the moment it's needed.

In the Mission script, I set up a table containing an array holding basic Room objects. Each Room has four entries for doors, paired to a blank string by default. In the SET, I have eventboxes in each room (to track which room the player is in) and an eventbox by each door that generated, so using the two it's easy to infer which door the player is near. Whenever one of these Door events is passed, the Mission instance checks if there's an associated string and uses it to call a textbox if needed.
When the player hits a Door eventbox, the Stage instance checks if that door is locked. If it is, it checks if the corresponding Key variable is set. If it isn't, it activates a unique object that's been placed inside a score collider. This sends a message to the Mission instance which then updates the door's blank string to be that key hint and invokes it. Now, whenever the player enters that eventbox without the key, the Mission script simply calls the paired string.

As for the UI, that required incorporating enemies.
There's an unused function available to the Mission instance which can call one of 9 sprites to display on the HUD, based on the string provided. The graphic file is missing from the game, but we have the tools to create one from scratch, so I opted to use that to display the player's currently held keys.
The trouble is, the function has to be called repeatedly to keep the HUD elements on screen, so I needed a way to loop the event and terminate on-demand. Activating an object in a score collider wasn't practical since that requires placing the objects ahead of time, so the loop couldn't be maintained indefinitely.
Instead, I decided to designate a custom enemy as a Key Manager.

The Enemy instance is still separate from Stage, which is where the actual keys are stored, but enemies can detect certain ObjectPhysics in a SET by their name, so I used that quirk to track the state of each key.
Each key has two ObjectPhysics assigned out of bounds, with one enemy constantly checking all of them. If the first Object gets destroyed (signaled when a key is collected), the enemy begins calling a hint message which Mission intercepts and uses to spawn the HUD element. This also clears that key's hint if it was paired to a door in Mission.
If the second Object gets destroyed (signaled when the player unlocks the door), the enemy simply stops calling the hint message.

It's quite common for Roguelikes to generate a layout by simply stitching together pre-fabricated rooms. While this is essentially how I wanted to handle generation as well, Sonic 06 has no concept of pre-fabs, let alone dynamic object movement or exposing instantiation to Lua; at best you can spawn ObjectPhysics at coordinates using an enemy, but that doesn't even allow rotation.

Since randomy spawning rooms was out of the question, I decided to randomize the connections between them. This means the walls and floor (both made using ObjectPhysics) for every room are loaded for every run, but on each side of the room, both a wall and a door exist in an Inactive state. When the dungeon generates, it goes to each of these connections and decides whether to activate the door or the wall.
This means that while most dungeons will have all 9 playable rooms, it's entirely possible to generate with fewer rooms, and the experience will feel fresh regardless as the way each room connects changes every time.

For the most part, this step is just a coin flip per edge on the graph, but there are a couple quirks. I bias towards spawning at least 2 connections for the rooms in the middle column of the grid, though it isn't guaranteed, and edges on the corner of the grid will always generate as walls.
Additionally, some strange room spawns are possible, like a dead-ending puzzle room, or being able to circumvent the need for a key with a longer route (though that isn't necessarily a bad thing).
Still, these oddities are rather uncommon, so this method works well given the limitations of the game.

Interior layouts are handled in a similar manner. Rooms have up to 4 "design layouts" that they can load. These are pre-made arrangements of objects and enemies, all set to Inactive and placed in a group then copy-pasted for each room. As part of the generation step, each room will randomly select a design's Group Name to load from a table then simply activate the group.
Puzzles support a similar system where they're assigned valid rooms to generate in. If a room is chosen to spawn as a puzzle room, it'll randomly select an available puzzle for that room and load that design as its room layout instead.
Every room also has a "Spawn" layout which will take precedence if that's chosen as your initial room for the run and a "Boss" layout containing a portal to the final encounter.
This system does incur additional loading when you first enter a dungeon due to the larger SET file, so I had to limit how much detail I placed in each design layout, but it's completely seamless afterward; after loading, you can restart the level as many times as you like to generate new layouts without any delay.

Unfortunately, this system wouldn't have been viable for the keys. Even with a preloaded 3x3 layout in mind, I tried to design every system to be easily extendable to an arbitrary dungeon size. Since there are 8 unique keys, placing each one in an Inactive state for every room was simply out of the question.
Rather, I gave every room one key (set to Inactive) which would send a generic "GetKey" event when collected. Internally, I assign a string to a room for the key that generated within it, so when GetKey is passed I simply check which key is associated with the current room and award that to the player. This does have a downside where each room only supports one key, but that's far from an insurmountable issue.

Dungeon generation is primarily handled inside the Stage instance, where I set up an array containing a number of "room objects." Each of these objects contains some basic information (room name, number, type, etc.) as well as an array holding the room numbers for each of its borders, the status/type of its own doors, and some fields for unique background music or lighting.
The other steps in the generation process focus on populating the room objects and creating an array of ObjNames to activate. Once a candidate dungeon has been created, I run a pathfinding algorithm to verify it can be completed, then either activate all the necessary objects or restart the generation step from scratch until it succeeds.

Generation Order

  1. Create room object.
    1. Determine column in the grid.
    2. Calculate neighboring room IDs based on current row and column. Since this is a 3x3 layout, the first 3 IDs are in the bottom row while the last 3 are in the top. Anything else is considered a middle row.
  2. Iterate through the room object array to establish connections between rooms.
    1. Force edges on the border of the grid to generate as walls.
    2. For the rest, make note of the current candidate and the adjoining candidate.
    3. If an edge is marked to spawn a door or wall already, do so then continue the loop (this ensures you never have a door connecting to a wall).
    4. Otherwise, flip a coin to determine whether the connection should be a door or a wall, adding the relevant name to the ObjName array and marking the adjoining edge to spawn this way as well.
  3. Ensure at least 1 connection between rooms. This doesn't prevent a small set of rooms from generating without connections to the larger dungeon, but it ensures there are no individual isolated nodes.
  4. Set the Room Type field for each room object.
    1. Randomly select one room as the Spawn Room and another as the Boss room. These can generate anywhere, though the boss room cannot be in the middle of the grid.
    2. For the rest, roll a number between 1 and 100.
    3. If the maximum number of puzzle rooms hasn't been spawned, there's a 45% chance a room will be set as a Puzzle Room, where it will randomly select a puzzle available to that room ID. A reference to that puzzle object is then assigned to the room object.
    4. Otherwise, mark the room as Standard and select a design layout.
  5. Iterate through every edge of every room and select a door type for each.
    1. If the Room Type is "Spawn", the door is unlocked, meaning it will open when the player approaches.
    2. If the Room Type is "Boss", the door is unlocked, but the player will receive a warning message if they unlock the door early (originally, the door would remain locked until a certain number of rooms had been explored).
    3. If the Room Type is "Puzzle", the door is locked until the player clears a puzzle. If approached from a hallway, however, the door will open (to provide access in the first place).
    4. If the Room Type is "Standard", 75% of the time it'll be a "Guardian" door, requiring all enemies in the room to be defeated. If that fails (and the maximum number of locked doors hasn't been reached), 60% of the time it will be "Key" door, requiring a key to open. Otherwise, the door will generate as unlocked.
  6. Set the status of each door as either True or False based on whether it spawned as a door or a wall, informing the next step whether an edge should be considered when pathfinding.
  7. Run the pathfinding algorithm to ensure a valid layout has been spawned, restarting the generation process if one cannot be found.
  8. Go through the ObjName array and activate everything within it.

The biggest issue with any randomly generated playing field is making sure it's actually, well, playable. In DoS, I randomly select one room as the Spawn room and another as the Boss room. Since I just randomize connections instead of generating rooms in a continuous path, I knew I'd need to take some basic measures to ensure a route from spawn to the boss, but the bigger problem was the key system.

Because every locked door requires a unique key, I needed to somehow ensure that every necessary key between the Spawn Room and the Boss Room would generate in a room the player could somehow access, a problem further complicated by the (up to) four uniques entryways each room can have.

Thus, I decided to write a pathfinding algorithm that could navigate my dungeon - as it generated in Lua - from the Spawn Room to the Boss Room, keeping track of any visited rooms along the way. That way, if it encountered a locked door it could simply assign it an available key then pair that key to one of its visited rooms.
Now, there are a great many options for very smart pathfinding in video games, and since I would be applying it to such a modest graph, there were practically no optimization concerns to speak of.
But I didn't want a smart algorithm. I wanted a rather stupid one. Less A*, more C+.

Since my dungeons had relatively few rooms and a compact design (i.e., minimal distance between any node on the graph), any sort of efficient algorithm would likely make things too simple. I wanted to ensure a minimum length "critical path" (the route between Spawn and Boss) of 5 rooms, and I wanted to allow dead-ends inside that route to create a more immersive dungeon.
Since I'd need to customize whatever algorithm I went with to handle my key needs, I decided to try writing my own with some basic recursion. This is what I came up with:

  • Each room has an internal ID. Supply the function with the start room and target room (e.g., FindPath(1,9)).
  • Check the connections available to your current room. Prioritize whichever room number is closer to your goal.
    • E.g., if the connections available are Room 6 and Room 2, and the target is Room 5, it'll choose Room 6 as that's closer numerically, even if Room 5 borders both on the graph.
    • In the case of equal weights (Target room 7, connections to 6 and 8), it'll check if either option has a direct connection to the target. If neither does, it flips a coin.
    • If every connection were available, this would always ensure the shortest path through the dungeon. Since that will almost never be the case, this introduces some natural variance in the route while still technically aiming for the shortest path.
  • Whenever a new room is visited, add it to the "Critical Path" array.
  • The previous room cannot be immediately revisited unless there are no other options.
  • If the above occurs, or the algorithm finds itself in a room it has already visited and that only connects to other visited rooms, blacklist said room.
    • Blacklisted rooms are no longer considered as possible connections by the algorithm.
  • When taking a step between rooms, check if either the current door or the connecting door generated as "Locked" and spawn a key using a room from the Critical Path as needed.
  • If the goal is encountered too early, step backwards and attempt to path to a new node until the Critical Path array has at least 5 elements.
  • If every available room in the dungeon is blacklisted, or if it takes too long to find a route (50 steps), abort entirely and regenerate the dungeon.

Because the actual objects comprising the dungeon layout are all loaded ahead of time, just in their Inactive state, I can easily reroll dungeons by storing their Obj/Group Names and only Activating them if a dungeon generates successfully.
If it fails for any reason, every step can be redone in an instant because nothing physically changed the in game world.

Dungeoneering notably features a great many ambient music tracks for exploration as well different tracks that swap in when you enter combat.
In 06, the Stage instance has Lua functions for calling and stopping background music, so I wrote a function to manage selecting and swapping between background tracks.
There were just two slight issues. Background music could not be resumed after calling StopBGM, and there was no way to detect when the player was in combat. Thankfully, both had relatively simple solutions.

For the former, the fix required patching the game's executable - the only instance of this in the entire mod. When StopBGM is called, the game discards the module that handles BGM. This behavior wasn't present in earlier builds of the game, so the community had already developed a patch to fix it.

As for combat, I opted to give each room a Threat Level. This is simply a field on the room object equal to the number of enemies in the room (and thus set when the design layout is chosen). When an enemy dies, the Threat Level of the room decreases by one.
If the player enters a room with a Threat Level > 0, the current ambient track is paused and a combat track is selected instead. If the threat level falls to 0, or the player enters a peaceful room, ambient music resumes.

While sparsely utilized outside of puzzles, I put together a similar system for playing ambient sound effects. The table simply contains the soundback and cue of the SFX, along with an interval between calls. Active cues can be toggled on or off at will, and optionally they can be set to only play once.

Puzzles are an integral part of any good dungeon, providing an interesting break from the typical gameplay of speeding through rooms.
For DoS, I wanted most puzzles to be tech demos of what can be done in 06 which meant each one would pose its own developmental challenges. That said, I was able to set up a system to for many common features.

Broadly speaking, each puzzle is held internally as a struct with common fields for design layout, background music, lighting, relevant events, audio cues, and any unique variables the puzzle may need.
"Relevant events" are simply event strings that are used in solving the puzzle. When an event is passed to the Stage instance, I make it check if the current room is a puzzle room with that event registered. If it is, the string gets passed to a different function along with a reference to the struct for the current puzzle. Audio cues can be tied to these events on the struct, making them automatically play with the event is invoked.
If a room gets assigned as a puzzle room, the relevant struct is attached to the room, and that data is used to generate the interior.


                            -- Example puzzle struct
                            PuzzleTable = { -- Contains all puzzles
                                Stealth = {
                                    -- Identifier used in construction and event handling.
                                    puzzle_type = "Stealth",

                                    -- Group Name. The assigned room is concatenated to the end here.
                                    object_set = "pzle_stealth_",

                                    -- Room ID that the puzzle generated in
                                    assigned_room = 0,

                                    -- Room IDs where the puzzle can spawn
                                    valid_rooms = {1, 3, 5},

                                    -- String indices for easy lookup
                                    relevant_events = {PlayerFound = true, AllKilled = true},

                                    puzzle_sounds = {
                                        PlayerFound = { bank = "common", cue = "cage_start" }
                                    },

                                    -- Audio track to use, if any.
                                    bgm = "",

                                    -- ObjName for a lighting volume object
                                    lighting = "light_orange"
                                }
                            }
                        

While I won't discuss every puzzle in detail, I ended up creating these 7:

  1. Cooking: Get a random list of ingredients from an NPC then bring them to a central barrel. Talking to the NPC afterwards opens a minigame where you must select ingredients from a UI in the correct order to "cook" a dish for a reward.
    1. Required creating a system for dialogue trees (06 NPCs typically send a single message string to Lua which opens a single textbox). Dialogue states included:
      1. Intro text.
      2. Accepted/Declined/Re-talked intro text.
      3. Main puzzle text.
      4. Post-puzzle, pre-minigame text.
      5. Accepted/Declined/Re-talked minigame text.
      6. Minigame failure/retry text.
      7. Post minigame text.
    2. The minigame was handled by repurposing the game's shop system. "Selecting" an ingredient is simply purchasing it.
  2. Maze: Light atmospheric horror. Navigate a traditional maze to find and return five scepters to the center of the room. Each scepter collected makes the atmosphere more ominous by changing the lighting or adding new ambient audio.
    1. Impetus for writing the ambient audio manager.
    2. Demo:
  3. Lost Woods: Use portals to progress through four rooms. Spatial audio directs you to the correct portal, a la the Lost Woods in Ocarina of Time.
    1. Nothing special code-wise, just a showcase of the game's surprisingly good sound system.
    2. Demo:
  4. Stealth: Hit a switch in the center of the room guarded by sleeping enemies. Walking keeps them asleep, running wakes them up.
    1. Required writing a basic speedometer using off screen enemies to track how fast the distance between the player and said enemy was changing.
    2. Enemies can be killed using hazards in the room without waking them up.
  5. Levers: Activate 4 switches before time runs out. Switches are guarded by blinking lasers.
    1. Nothing special, just a good gameplay challenge.
  6. Crystals: Synchronize four moving orbs by using switches to stall movement.
    1. Each orb line is managed by an off screen enemy iterating through an array of points to spawn an ObjectPhysics.
    2. Standing near a switch prevents the enemy from updating to the next position in the list, stalling the orb for a cycle.
    3. The object is created with a short lifetime so it naturally gets destroyed before the next one spawns.
    4. When the object reaches the central pillar, a score collider destroys it and starts a countdown. If the other orbs reach the center before the countdown expires, the room clears. Audio indicates how many orbs are synchronized.
    5. Demo:
  7. Rescue: Activate four switches in the correct order to free an NPC. Audio cues inform you if you're on the right track.
    1. Completing this puzzle unlocks "Sonic Man" as a playable character in future runs.
    2. NPC dialogue changes if you've completed his missions in the base game or if you encounter this puzzle while playing as him.

While Sonic 06 does not have any sort of traditional inventory system, I realized I could create my own using the game's shop windows. Shops are opened by calling a function in the Mission instance and passing a table as an argument. Within the table, you can specify dialogue (e.g., for entering and exiting the shop) and add an arbitrary number of items. Each item is assigned a name, price (which I used to represent quantity), description, and optionally events for purchasing.
Because all this data is handled with a table, it's trivial to add, remove or modify entries on the fly, so I could write a system for creating objects in the shop format, pair them to some handle in a dictionary, then use events to add or remove items from that "inventory".

As for constructing the items themselves, I wrote a simple system using inheritance that defines a base "Item" class with Constructor, Setup, and Pickup functions.
NOTE: Since metamethods are not available in the Mission instance, I wrote my own method of inheritance using a recursive search function that checks for a string index in a table. As long as a table has an entry called _index pointing to another table, the function will continue running.


                            -- Inheritance function
                            function inherit(lookup)
                                -- Base class to inherit from
                                local proto = {_index = lookup}

                                -- Method for declaring new instances of an object.
                                proto.new = function(Name)
                                    -- Ensures instances of this object have the same base class
                                    local new_table = {_index = proto}

                                    -- Search for a constructor function then call it
                                    search(new_table, "constructor")(new_table, {item_name = Name})
                                    return new_table
                                end
                                return proto
                            end

                            -- Replicates the __index metamethod
                            function search(base_table, search_item)
                                if base_table[search_item] then
                                    return base_table[search_item]
                                elseif base_table._index then
                                    local next_layer = base_item._index

                                    -- Minimize recursive calls by checking the immediate next layer
                                    if next_layer[search_item] then
                                        return next_layer[search_item]
                                    elseif next_layer._index then
                                        return search(next_layer, search_item)
                                    else
                                        return false
                                    end
                                else
                                    -- Item could not be found
                                    return false
                                end
                            end
                        
  • The base constructor defines a name and description for the item based on arguments provided, and registers the new item inside an Item List. Derived items can implement their own constructors, but they should also call the base constructor.
  • The Setup function acts as a virtual method. Certain upgrades use it to register a flag to be set upon item collection. This is called when an item is declared.
  • The Pickup function is very similar, getting called when an item is collected in the game world.

Items are acquired by simply passing an event to the Mission instance with that item's name. The script will search your current inventory for the name of that item then either increment your quantity or replace an empty slot with it, pulling its data from the general Item List.
In the dungeon, unidentified items are found randomly either in rooms or by killing enemies. Taking them back to the spawn room allows you to convert them into a randomly selected upgrade that you don't already have.

Meaningful upgrades are tricky to do in 06, so for the most part items are just Sonic's gem upgrades from the Retail game, but I managed to add a few that interact with enemies, giving you chances to flinch, stun, or instakill on hit or trigger an explosion on their death. This was done similar to the key system where a manager enemy checks for existence of an off screen ObjectPhysics then sets a flag for all other enemies if it's absent.

A challenging final encounter is vital for a roguelike as it's what gives the player reason to thoroughly explore and acquire upgrades; it's a measure of both power and skill.
Sadly, making challenging bosses is very hard in Sonic games. Enemy attacks are easily avoidable due to the player's high speed, player attacks are fast, usually guaranteed to hit, and provide invulnerability, and having a single ring means one can survive practically any attack. Usually this forces boss fights to be extremely gimmicky or cinematic, but that doesn't lend itself well to replayability - which is a must in a Roguelike - so I had to get creative.

I chose to use Mephiles as the base for my boss since he's easy to fit into any arena and has many useful, boss-specific attacks available to him. As mentioned at the start of this page, I styled his mechanics like an MMO raid boss, giving him several phases with changing attack patterns interspersed with gimmicks that the player must solve or play around, turning the fight itself into a sort of puzzle.
The encounter is divided into four phases, each progressing after either solving a gimmick or inflicting enough damage on the boss.
Aside from the intro, each phase opens with a short cutscene showing off the phase's special attack/gimmick.

  1. Phase 1, Intro: Lasts from fight start until two "guards" have been slain.
    • Mephiles is invulnerable during this phase and will execute a basic attack loop where he launches projectiles, charges forwards, flies into the air, then attempts to dive on to the player.
    • The camera takes on a 2D perspective to make these attacks easier to avoid. They're merely meant to be a diversion while the player attempts to deal with the guards.
    • Demo:
  2. Phase 2, Bombs: Lasts until Mephiles sustains enough damage. Mephiles will periodically spawn bombs on the ground during this phase.
    • For his attack loop, Mephiles will spawn a bomb, fly to a point on the map, and fire a few projectiles then check the player's distance.
    • If the player is close, Mephiles will spawn some minions around him which swoop at the player, rapidly draining rings.
    • If the player is far away, Mephiles will rush towards them. The charge can be interrupt with a well timed attack from the White Gem or by making him slam into a wall, causing him to recoil for a bit.
    • Bombs are managed by spawning a series of ObjectPhysics with short lifetimes. These load particles and hitboxes when they naturally die. This system means Mephiles' AI only needs to spend a couple frames spawning the initial objects each cycle, leaving him free to attack while they're detonating. The indicator particle is custom made.
    • Bomb demo:
  3. Phase 3, Teleportation: Lasts until Mephiles sustains enough damage. Mephiles will teleport around in a cloud of smoke, leaving a clone behind. The player must find his final location using the smoke color.
    • This loop opens with the clone trick, followed by spawning a ring of minions around the player, then teleporting to a point to throw projectiles. His guard can be shattered with the White Gem.
    • Clone demo:
  4. Phase 4, Light Arrows: Lasts until all eyes are banished using Mephiles' own attacks.
    • Mephiles will fly behind a wall and begin shooting fast moving projectiles at the player. These must be directed into eyes that sequentially spawn throughout the arena.
    • After all the eyes are destroyed, the player can freely approach Mephiles and finish the fight.
    • Bombs will continuously spawn throughout the arena. Unlike the previous variants, these immobilize the player while quickly draining their rings, easily leading to death.
    • This attack, and the bombs, was once again created using ObjectPhysics with custom particle work.
    • Demo:

While most of the logic for this fight could be handled in the Enemy instance, it did require occasional communication with the Stage instance for things like creating the invisible wall at the end of the fight or playing the sound effect for the bombs (which was done by rapidly looping an sfx used elsewhere in the game).