Hey Jam Community,
I had a really great time this jam, both because I was really proud of the actual game we made, but also because I think this was some of the best engineering work my team has done in a jam before. In particular, I wanted to share the level design tools we made and how we used them. For more details on the general design of our game, please check out our postmortem, but this post will just be about how we made our cards, enemies, and levels.
Context
For context, we made a time-themed roguelike deckbuilder called Justin Time: Man of the Hour where your turns are 10 seconds long, and your cards cost time from your turn. Between turns, enemies either attack you if they're in range or approach you if they're not. Between rounds, you get to add one of three random cards to your deck and take a perk (like bonus health or time in future rounds). Aside from just the gameplay mechanics of making a real-time card game, we also needed to make a bunch of cards with different effects to fill out the game. We decided to invest early in a scriptable object-based system that would allow us to make cards quickly and entirely through the UI, with little-or-no code necessary.
Scriptable Objects
For those of you that aren't familiar or haven't experimented much with them before, Scriptable Objects are essentially a fancy data store. They're not MonoBehaviors, so they don't have any of the gameloop functions (like Start or Update), and they're not GameObjects so they're never instantiated. Instead, they're just objects that contain information and scripts. The really magical thing about them though is that you can create them in the Unity Editor outside of a scene. I'll give you an example:
Making Cards as Scriptable Objects
Here's our card prefab:

The prefab contains all the text and images needed for a generic card, but in terms of functionality, it only includes a collider and single script that can play the card (activating its ability, which is not yet specified) and initialize the card given a CardDescriptor scriptable object.
Let's take a look at that scriptable object:

That's really it. The Card Descriptor holds all the relevant information that a card might need (Name, card description text, cost, whether it returns to your hand after playing it, the image on the card, a sound effect if the card has a sound effect, and a list of card effects that describe what the card will do when played). When we want to make a card in the game, we take the blank card prefab and call initialize with a CardDescriptor, and that fills in all of the relevant information for the card.
But this is just a generic CardDescriptor script, it doesn't actually detail any cards in particular. This is where some of the fun of Scriptable Objects comes in. The CreateAssetMenu line at the top adds our scriptable object the Unity's Create menu, so we can create a new "instance" of our CardDescriptor object through Unity's UI:


You can see that this creates a new asset in our assets folder that contains all the information laid out by our scriptable object which we can fill in the inspector. This is really powerful because it allows you to make several distinct objects that are basically just information containers, but they are distinct from each other while all following a model that's outlined by the ScriptableObject script. It's sort of like making a bunch of prefabs based on one prefab, but much cleaner and easier to reference in code.
Now let's take another look at that list of card effects. Each card effect is an instance of the following struct:

The System.Serializable line at the top of the struct is just there to tell Unity that this object can be edited in the inspector. You can see in the image above how this struct connects to fields you saw in the inspector. The relative position list specifies the area of effect of a card relative to where you placed it. An entry of (0,0) would mean that the space you placed the card on is affected, (0,1) means that the next space clockwise in the same ring as where you placed the card would be affected, and (1,0) would mean that the space behind where you placed the card in the same lane would be affected. From here, we can make a list of relative positions that make up the card's entire area of effect. For example, the list of relative positions for a card that hits an entire ring would be [(0,0),(0,1),(0,2),(0,3),(0,4),(0,5)]. These values are used both for the highlighting the grid before you play a card and applying the effect to the correct spaces after.
The effects list in CardEffects (aside from being poorly named), also shouldn't be a list. We found when using it that we never actually had more than one element in this list, so it should have just been a single element. If we ever decide to reuse this system in the future, we'll know to change that. This field specifies what the card actually does using this enum:

From the card creator's perspective, making a card effect looks something like this:

Then the values list allows you to give the values that a particular effect might need. A damage dealing effect should know how much damage it's going to do. An effect that pushes enemies should know what direction to push them in and how far. If we were trying to make this more professional/a little safer, we'd include asserts in our code that forced you to input the correct number of values, but since this was for a jam, we felt ok leaving it a little messy.
When a card is played, we evaluate what the card should do with a switch case that funnels the values input to the correct function:

And just like that, we have card effects! To add a new effect (that can't just reuse old code), all you have to do is write a function that performs that effect, add an entry to the enum for it, and add a case to the switch statement and then you can add that effect to any card through the UI.
The last note I want to make about this is how useful it was to be able to add multiple effects to one card. This added a lot of versatility to how we could design our cards by allowing us to compose multiple card effects. For example, take the card Hammer Time:

Hammer Time does 5 damage to the enemy where you placed the card (in melee range) and 3 damage to everyone in a T shaped area around it. Here's how the card's CardDescriptor looks in the inspector:

We have one effect that does 5 damage only at the position (0, 0), then a second effect that only does 3 damage, but at positions [(0,1), (0, -1), (1,0), (2,0)]. When playing the card, you get the full T shape for targeting, but the different spaces have different effects. By being able to compose our cards from multiple base effects, the amount of complexity we could make out of simple functions shot way up. Here's what it looks like in action with Hammer Time:

All together that made for an extremely easy and streamlined process for making new cards. All of that code was from a single file called CardDescriptorSO.cs, which comes out to about 150 lines of code including comments and all of the implementations of the card functions. No one thing in this code is particularly complex, but the amount of power it gave us to design the game the way we wanted to was enormous.
Card Lists
Now we have a great system for creating cards, but now we needed a place to put them. We could just have a list of CardDescriptors somewhere in the game that defined which cards are in the game, what's in the player's deck, etc., but we decided once again to use ScriptableObjects. You may have noticed that in the create menu, there was a generic card and a card list. This is a great example where we didn't actually do anything complex in the scriptable object itself, but it still wound up being a huge convenience for us. Here's our whole CardList ScriptableObject:

That's the whole file (other than includes at the top). Here's what it looks like when making a new card list:

Here we've made a list of all the cards we are including in the game, and if we want add another one, we just add it to this scriptable object, and anywhere that references that scriptable object will now know that the new card is a part of the game (and conveniently, since it's a list of card descriptors, you can use Unity's UI to find the card you're looking for rather than needing to drag and drop it, also pictured in this image).
We also made a CardList object for the player's starting deck. This was great not only for ease of editing, but also if we ever wanted to have other characters with different starting decks, we could just swap out the scriptable object representing the deck and everything else could be kept the same!
I love this example because it's really just a wrapper for a list, it doesn't do anything, and yet it still made our lives so much easier.
Enemies and Widgets
I go into more detail in the design side of this in our postmortem, but I wanted to go a little into the implementation of it here too. The basic idea is that we wanted to have randomized levels so every time you play the game, the levels feel different, but we didn't want to just randomly spawn enemies in. That felt too chaotic and hard to balance for. So we decided to do a sort of tiling system where we randomly apply premade sets of enemies which we called widgets. Each widget would specify information about the enemies, when they spawned, and how long to wait after applying the widget before moving on to the next one (harder widgets should give the player a few turns to deal with it before spawning new enemies -- unless the reason that they're hard is that they don't give the player time!). This allowed us to randomly generate our levels, but still have each set of enemies be carefully handcrafted.
And we did with ScriptableObjects! Woohoo! Here's our Widget scriptable object and an example in the inspector:


Here we have a widget that spawns 3 enemies, 1 ranged and 2 melee (Clockwork Mike was our name for the little round guy). The first two spawn immediately when the widget is activated, and the last melee guy waits one turn, then spawns. The “Wait Time” field at the bottom specifies how many turns the game should wait before activating another widget. This one isn’t a very hard widget to beat, so it only waits 1 turn before beginning the next widget, but our widgets that use the Big Boy give you a few turns to deal with the new threat before sending in another wave of enemies.
Once again, we can create a new widget by going to Create->Level Design->New Widget from the create menu and fill in the information in our new asset. Then we categorized the widgets by difficulty, which allowed us to bring in a few widgets of varying difficulty to give levels a feeling of having difficulty spikes followed by slightly easier breaks and assemble a randomly generated level that we feel is the correct difficulty for the player at this time.
This turned out to be a really good system for quickly making level design bits that could easily slot into the game without needing to change a single line of code. Even now, we could create tons more widgets with brand new enemies that don't exist yet, and they would just work in game without needing to change a single line of code.
While I'm proud of the widget system and it worked really well for us, I think that there was room for improvement (and room for more scriptable objects!). We should have had our widget lists also be scriptable objects, for the same reasons that the card lists were so useful. We also would've liked for enemy creation to be more streamlined in the same way that cards were, but that required a bit more thought and it was a still jam after all.
More Scriptable Objects?! A Scriptable Event System
Ok, we used them for cards, we used them for enemies, we used them for level design. What else could ScriptableObjects possibly do in this project??
Well reader, we can use them for events! I won't go into too much detail about this, since I think it's a more common example online and there are much better explainers on how to do it. There was a talk at Unite Austin that goes REALLY deep on how this works that's really good, but it's a lot of information. It also makes a compelling case for replacing everything (even floats!) with scriptable objects, which I think is going too far, but gave me a lot of appreciation for what they could do.
Basically, we created a UnityEvent in a scriptable object and added functions to interact with it. Now whenever we wanted something to subscribe to the event, we could just drop that event into a public field in the inspector and we'd be guaranteed that any object referencing that event is talking about the same event. For this project, we had an OnTurnEnd event and an OnTurnStart event, but we often have many more. Some that we've used in the past are OnTakeDamage, OnSelect, and too many others to name. This makes syncronized reactions really nice too. For example, if you wanted the screen to shake, your healthbar to be updated, and an animation to trigger all when you take damage, you don't need to communicate between these separate GameObjects to make sure they all happen. Just make sure they all have the same ScriptableObject event, and it'll be taken care of for you. Here's the code we used for ours:

I called this one a void event because the event handler doesn't take in any arguments, but you can make others that do take arguments. I'll often have a Float Event, a GameObject Event, etc. (Note that the ScriptableObject scripts should be general, like "event that takes an int" and the instances should be specific, like "OnDamageTakenEvent")
Also note that there's no public variable on this event at all! Up until now the scriptable objects we've been looking at have been data stores where we can edit them in the inspector, but these actually don't let us put any information in at all! Their usefulness just comes from existing and being different from each other. Super cool!
That's all I've got for you. If you've got any questions, please don't hesitate to ask, and I hope some of you found this useful! ScriptableObjects are really cool, but if you're not seeking them out, I feel like there isn't much opportunity to learn how to use them.
If you haven't yet, please go check out our game: Justin Time: Man of the Hour and see some of these systems at work!