GOBLINFINITE.app Dev Log #3 - Rolling Some Stats & Lore

0️⃣: Launch Announcement
1️⃣: Generating Goblins
2️⃣: Making Names

intro banner.png

Stats and stories, the secret ingredient...

So, we've made our goblins, we gave them names, now all that's left to make these goblin goobers really feel like living breathing things: Stats & Stories. Attributes & Anecdotes. Traits & Tales.

These are actually a lot less complicated than previous dev log topics, which means not only will we be covering 2 different scripts, but we'll be going much more in-depth for each one & really breaking down the code. This is the most "GDScript tutorial" like dev log so far.

Ready? No? Oh... Well, I mean the text is already written & waiting so I mean... Scroll down whenever you'd like buddy...

divider.png

What's A Goblin Without Some Stats?

an idle idiot...

Stats! Those incremental little indexes that determine whether we've killed the dragon, or died of 3rd-degree burns. Stats are often an important mechanic in many tabletop roleplaying games, so ROLLINKUNZ and I made sure to not only include stats alongside your goblins but make them somewhat universal & adaptable to any game environment.

Does your game use a table of -3 -> +3? Or maybe it's more "great, good, bad, terrible"? What if the stat points are somewhat irrelevant? In GOBLINFINITE.app, you are given 3 random stats categories. Then, we choose a random word from a list associated with each category to give more variety amongst all your greedy goblin generating. Finally, we attach 1 of 7 possible symbols to represent a rough "skill level" for the stats.

This is how it ends up looking:

image.png
image.png
image.png
a 3 arrow system, pointing up to show that the goblin has some level of talent in this specific skillskills can be positive, negative, or even neutral, and it's up to you to determine what that meansarrows pointing down represent a lack of this skill, and can manifest in debuffs to related dice rolls

The images on the left side represent the stat "category". The word in the middle also represents that, but will be randomly chosen from a list to give the app a bit more generation goodness. The symbols on the right represent the "competency" level. You'll notice we also decided to color-code each stat to quickly represent its competency level at a glance.

Stat Categories

Each goblin generation roll will give you 3 stats from the list below:

  • strength
  • dexterity
  • constitution
  • intelligence
  • wisdom
  • charisma

Here's a screenshot of the random words assigned to each stat category:

image.png

These can be anything you want, and if you're making a similar system I recommend having fun with them & coming up with synonyms that match the overall vibe of your project.

Also, you'll notice that this is a dreaded ARRAY OF ARRAYS 😱
...Which isn't a big deal. But, could be kinda confusing to talk about. So to be clear, I'll be referring to our stats array, the master / parent array, as our "stat-category" array. And when talking about the arrays inside our category array, I'll refer to them as "stat-word" arrays.

So, what does the actual stat generation code look like?

image.png

27 lines. Not bad, could be better, but I hope you forgive my late-night duct tape code.

Let's begin the review...

var dupe_stats = stats.duplicate()

We duplicate our stat-category array so that we can freely modify it without ruining our master copy. Because we don't want to show multiple stats from the same category, this duplicated copy will have the category removed if it's chosen in this round of generation.

We also set up an array variable called generated_stats, where each element in this array will be one of our randomly generated stats. You don't have to do the "name": """, "icon": "" setup for the first element, that was just for me so I could keep track of what the format of this array would look like. Probably should've been a comment... Well, fuck it all, what's done is done. Moving on.

rng.randomize()

Godot has a random number generator built-in, as do most programming languages, but you need to call randomize() on it to scramble the seed & prevent having the same results. You only need to do this once, I believe, at the start of your game, but I felt like calling it each new generation because it makes sense to my chimp brain.

Further up the script, we initialized the random number generator with var rng = RandomNumberGenerator.new(). That's why we have rng as a reference to it.

for n in 3:

Like a stoner on too many shrooms, we're starting a loop. We want 3 stats, so we'll do the following steps 3 times.

    var stat_category = rng.randi_range(0, dupe_stats.size()-1)

First, choose a category. This is the strength, dexterity, constitution, etc. list from earlier. Store that in a stat_category variable. This is our stat. This is important.

rng.randi_range(min, max) is a neat little function where Godot will generate a random integer between min & max, inclusive. Our min is 0 because that's the start of our stat-category array. Our max is dupe_stats.size()-1 which represents the last item in our stat-category array. Effectively, this range represents every stat-word array within our duplicated stat-category array.

    generated_stats[n].icon = dupe_stats[stat_category][0]

n is whatever iteration of the loop we're currently on, starting with 0. With 3 loops, n will equal 0, then 1, then 2. So for this first loop, we're setting the icon value in the first spot in our generated_stats array.

dupe_stats[stat_category][0] is grabbing the first element of the stat-word array for whatever stat category we've chosen. Looking back at my stat-category list, you'll see that for each category, or stat-word array, the first element is not like the others.

image.png

HEY CHIMP! WHAT HAPPENED? WHY'D YOU ONLY TYPE 3 LETTERS FOR THE FIRST ENTRY ITEMS? YOU HAVE A STROKE OR SOMETHING RETARD?

No... And chill out, this is way too early in the post for funny commentary to keep the reader's interest.

The first element in each stat-word array acts as a shorthand code for what the stat category is. What this means so far, is that if we randomly chose a "strength" stat, then our generated_stats[0].icon would equal "str".

That... Doesn't feel like an icon. It's not! Yet. We'll see how to utilize this later.

    var stat_name = dupe_stats[stat_category][rng.randi_range(1, dupe_stats[stat_category].size()-1)]
    generated_stats[n].name = stat_name

dupe_stats[stat_category] is the chose category, [rng.randi_range(1, dupe_stats[stat_category].size()-1)] is picking a random word from our stat-word array for that specific stat-category.

Again, if strength was our chose category, then stat_name might equal something like "FIRMNESS", or "MIGHT", or "RUMBLE".

We then set this in our "current loop iteration" index of our generated_stats array.

    generated_stats[n].value = rng.randi_range(0, 6)

I bet by now you can tell what this is doing. Random number within the range of 0 and 6. This determines our stat value, or as I called it earlier, our competency for this stat. Why 0 -> 6 and not -3 -> +3? ...Honestly, I'm not actually sure. OH WAIT! I remember now, it's for a little hacky thing I set up for the stat value symbols later on. ...Maybe I should remove these lines since I got it now... Meh...

    if generated_stats[n].value == 0:
        generated_stats[n].color_icon = "c0c0c0";
        generated_stats[n].color_text = "c0c0c0";

If the value is 0, then this stat should be seen as "neutral". We set some color variables to a nice subtle gray color. Not grey. Gray. Grey is too sharp. Gray is just the right softness. Is there an actual difference between grey & gray? To me, yes.

    elif generated_stats[n].value < 4:
        generated_stats[n].color_icon = "d95555";
        generated_stats[n].color_text = "ff3e3e";

Else if the value is more than 0 (because it skipped the above check) but less than 4, set the color to a bloody red. This is a negative / low competency level. The reason two different reds are being used here is that one works better for the icons while the other looks better with the text. It's the small things like this that make you a real artist. Or a crazy person.

    else:
        generated_stats[n].color_icon = "66ff66";
        generated_stats[n].color_text = "66ff66";

Otherwise, the value is greater than or equal to 4, so set these bad boys as bright positive green!

    dupe_stats.remove(stat_category)

Finally, remove this category from our duplicated stat-category array. This guarantees we won't have 2 strength stats, or 3 intelligence stats, in one generation.

return generated_stats

We then return the generated stats. Return to what? Whatever called this function of course!

What The Fuck Does This Final Array Look Like?

Our generated_stats array could look something like:

[
    ["name": "GRASP", "icon": "wis", "value:": 5, "color_icon": "d95555", "color_text": "ff3e3e"],
    ["name": "RUN-AROUND", "dex": "con", "value:": 0, "color_icon": "66ff66", "color_text": "66ff66"],
    ["name": "VIM", "icon": "str", "value:": 2, "color_icon": "c0c0c0", "color_text": "c0c0c0"]
]

Now, what you do with these values is up to you. For us at the goblin gang, we used the icon to figure out what sprite to display, the name to display next to the icon, and the value to select a sprite from a sprite sheet, where frame 0 was a horizontal bar, 1-3 were downward facing arrows, and 4-6 were upward facing ones. The colors were used to color the stat icon & symbol sprites, as well as provide subtle text shadows on the stat name.

Result:

image.png


What's A Goblin Without Some Lore?

a hypothetical husk...

Stats are all well & cool, but... What experiences gave these goblins those stats? What made these little vermin into who they are today?

We need a backstory. Our first iteration was to come up with full sentences. We may still work on that idea down the road, but we thought that it may be better to just give brief, one-sentence imageries so that the user (that's you! ...potentially) can fill in the details and envision their own, more fleshed out stories.

Our backstory lore is meant to inspire you, not tell you. Again, this helps the goblins become universal figures that can seamlessly adapt to a wide variety of settings & contexts.

Here's a few generated character stories to give you an idea of what we're doing:

image.png
image.png
image.png

Simple, short, sweet. But, packed with enough juicy intrigue that you can't help but come up with your own detailed stories explaining these prompts.

"How did he win that foot race? Is it because he's double-jointed in his legs or something? Why do they have a trauma of losing their feathers? Is it because they faint so much and they worry they might get stolen whilst unconscious? What the fuck is a stink seller, and please tell me it has nothing to do with popping out of holes!?"

Generating these are actually super straightforward, especially since you should now be familiar with arrays and the randi_range() function.

Story Bits

First, come up with some story pits, or as I called them in the code, lore_parts. These are almost like our stat-categories from earlier. They are types of story prompts that contain some pretty wacky events within them.

image.png

These are endlessly expandable, and some of the most fun of this whole thing. You can write any random shit you want in here & just go nuts with it. Enjoy yourself.

var lore_parts = ["proud moment", "ritual", "job", "earliest memory", "hobby", "quirk", "trauma", "fear", "ailment"]

var bio = []

var rng:RandomNumberGenerator = RandomNumberGenerator.new()

Set up some variables and our random number generator.

func build_bio():
    rng.randomize()
    
    for n in 3:
        bio.append(add_lore_part())

Again, another loop. Also again, we want 3. Also again, I'm fairly confident the rng.randomize() is not necessary here since we called it earlier, but my chimp brain won't allow me to delete it.

bio.append() will add whatever add_lore_part() returns to the end of our bio array. Let's peak into that function now to see just what exactly is being returned to us.

func add_lore_part():
    var lore_part = lore_parts[rng.randi() % lore_parts.size()]
    lore_parts.erase(lore_part)
    return add_lore_specific(lore_part)

Yeah, turns out rng.randi() % lore_parts.size() does the same as rng.randi_range(0, lore_parts.length()-1). That's a good number of characters saved. So why didn't I go back and change all the old instances over to this new fancy method?

Uhh... Anyways, lore_parts.erase(lore_part) will remove this "part" from our lore_parts list to prevent us from having 2 traumas or 3 hobbies, etc. The reason it's safe to directly modify this master array rather than making a duplicate like we did with our stats, is because this code only gets called once in this Godot scene. We can't generate new lore without first having to change scenes & then coming back to this one, which will reset the whole array anyways. With our stats, there is no scene change, so we don't want to make changes to the master array there.

So we have our "lore part", which is the category like trauma or ailments or job, but now we want our "lore specific", which is the actual event like "cut in half" or "confusing flatulence" or "pebble tender".

func add_lore_specific(lore_part):
    var lore:String
    
    if lore_part == "earliest memory":
        lore = memories[rng.randi() % memories.size()]
    elif lore_part == "trauma":
        lore = traumas[rng.randi() % traumas.size()]
    elif lore_part == "proud moment":
        lore = accomplishments[rng.randi() % accomplishments.size()]
    elif lore_part == "fear":
        lore = fears[rng.randi() % fears.size()]
    elif lore_part == "hobby":
        lore = hobbies[rng.randi() % hobbies.size()]
    elif lore_part == "ritual":
        lore = rituals[rng.randi() % rituals.size()]
    elif lore_part == "ailment":
        lore = ailments[rng.randi() % ailments.size()]
    elif lore_part == "quirk":
        lore = quirks[rng.randi() % quirks.size()]
    elif lore_part == "job":
        lore = jobs.things[rng.randi() % jobs.things.size()] + " " + jobs.actions[rng.randi() % jobs.actions.size()]
    
    return {"title": lore_part, "story": lore}

This no-doubt unoptimized if-chain of hell will find our lore "category" and choose an "event" from that array. It then wraps it up into a nice little returned object where title is our category and story is our event.

So, that's what gets returned, something like {"title": "proud moment", "story": "ate a whole goat"}. This is what's getting appended to our bio.

Back to our loop:

[...]
    for n in 3:
    bio.append(add_lore_part())
    record_lore(str(n+1), bio[n])

After we append the new lore to our bio, call the record_lore function.

func record_lore(spot, lore):
    lore_output.get_node("Lore" + spot + "Title").text = lore.title.to_upper() + ":"
    
    if lore.title == "proud moment" or lore.title == "ritual" or lore.title == "job":
        lore_output.get_node("Lore" + spot + "Title").set("custom_colors/font_color", Color("53ed53"))
    elif lore.title == "earliest memory" or lore.title == "hobby" or lore.title == "quirk":
        lore_output.get_node("Lore" + spot + "Title").set("custom_colors/font_color", Color("c0c0c0"))
    elif lore.title == "trauma" or lore.title == "fear" or lore.title == "ailment":
        lore_output.get_node("Lore" + spot + "Title").set("custom_colors/font_color", Color("ff3e3e"))
    
    lore_output.get_node("Lore" + spot + "Story").text = lore.story

This is not what you care about if you're not using Godot. The above code is responsible for writing our lore out to our screen, and coloring the text to match whether it's a happy thing, neutral thing, or bad thing.

I'll briefly explain some of the key points.

lore_output refers to a variable I setup at the top of the script: onready var lore_output = $ViewportContainer/Viewport/PaperBG/Lore. The $Viewport[...] is calling to a specific node in our scene tree, which looks like this:

image.png

That Lore node on the bottom is actually a scene, which looks like this:

image.png

Welcome to Godot, scenes of scenes of scenes of nodes. It actually makes a lot of sense.

lore_output.get_node("Lore" + spot + "Title").text = lore.title.to_upper() + ":" is turning one of our "TITLE:" texts from the above image, into something like "TRAUMA:" or "HOBBY:". It knows which node to target thanks to our spot parameter, which we passed in as n, our loop iteration count while generating lore segments, +1. You could just rename your nodes to start at 0 so you don't have to add 1 to your n parameter but... HOW MANY TIMES MUST I EXPLAIN MY CHIMP BRAIN REFUSES TO LET ME DO THINGS SIMPLY.

lore_output.get_node("Lore" + spot + "Title").set("custom_colors/font_color", Color("c0c0c0")) is again finding the correct "title" node, and then setting its font color to something that matches what type of lore it is. Good, bad, ugly neutral.

lore_output.get_node("Lore" + spot + "Story").text = lore.story is getting the "story" text node underneath our current "title" text node, and setting the lore event to it. No need to mess around with colors, all story events are just black to be nicer to read.

divider.png

A Well-Rounded Goblin

There you have it! Holy shit! Only a bajillion words later, and you have yourself a nice little goblin pal!

If you've been following along with the dev logs so far, this is where we're at:

EETRUGG THE GRUB LoreSheet.png

Well, sorta...

We covered goblin bodies, readable names, useful stats, and funky lore, but we haven't talked about actually making these images & saving them / the whole app UI in general.

Well, that's the path we're on now. Our next dev logs will cover image generation within Godot, and then finally go over how to make nice snappy UI.

The next one will be an extremely Godot-focused dev log, as it specifically covers how to handle "screenshotting" and saving textures within Godot.

Until then, stay safe, keep healthy, and get cozy. See you all soon!
- Chimp CEO 🐒

Twitter
Instagram

check out goblinfinite 👉 GOBLINFINITE.APP
0️⃣: Launch Announcement
1️⃣: Generating Goblins
2️⃣: Making Names
H2
H3
H4
3 columns
2 columns
1 column
10 Comments
Ecency